Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • adaures/castopod
  • mkljczk/castopod-host
  • spaetz/castopod-host
  • PatrykMis/castopod
  • jonas/castopod
  • ajeremias/castopod
  • misuzu/castopod
  • KrzysztofDomanczyk/castopod
  • Behel/castopod
  • nebulon/castopod
  • ewen/castopod
  • NeoluxConsulting/castopod
  • nateritter/castopod-og
  • prcutler/castopod
14 results
Show changes
Showing
with 1629 additions and 1563 deletions
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
...@@ -12,7 +12,12 @@ namespace App\Entities; ...@@ -12,7 +12,12 @@ namespace App\Entities;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter; use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
/** /**
* @property int $id * @property int $id
...@@ -35,11 +40,11 @@ class Page extends Entity ...@@ -35,11 +40,11 @@ class Page extends Entity
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'id' => 'integer', 'id' => 'integer',
'title' => 'string', 'title' => 'string',
'slug' => 'string', 'slug' => 'string',
'content_markdown' => 'string', 'content_markdown' => 'string',
'content_html' => 'string', 'content_html' => 'string',
]; ];
public function getLink(): string public function getLink(): string
...@@ -49,13 +54,20 @@ class Page extends Entity ...@@ -49,13 +54,20 @@ class Page extends Entity
public function setContentMarkdown(string $contentMarkdown): static public function setContentMarkdown(string $contentMarkdown): static
{ {
$converter = new CommonMarkConverter([ $config = [
'html_input' => 'strip',
'allow_unsafe_links' => false, 'allow_unsafe_links' => false,
]); ];
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
$converter = new MarkdownConverter($environment);
$this->attributes['content_markdown'] = $contentMarkdown; $this->attributes['content_markdown'] = $contentMarkdown;
$this->attributes['content_html'] = $converter->convertToHtml($contentMarkdown); $this->attributes['content_html'] = $converter->convert($contentMarkdown);
return $this; return $this;
} }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2021 Podlibre * @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
...@@ -12,6 +12,10 @@ namespace App\Entities; ...@@ -12,6 +12,10 @@ namespace App\Entities;
use App\Models\PersonModel; use App\Models\PersonModel;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use Modules\Media\Entities\Image;
use Modules\Media\Models\MediaModel;
use RuntimeException; use RuntimeException;
/** /**
...@@ -19,20 +23,15 @@ use RuntimeException; ...@@ -19,20 +23,15 @@ use RuntimeException;
* @property string $full_name * @property string $full_name
* @property string $unique_name * @property string $unique_name
* @property string|null $information_url * @property string|null $information_url
* @property Image $image * @property int $avatar_id
* @property string $image_path * @property ?Image $avatar
* @property string $image_mimetype
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
* @property object[]|null $roles * @property object[]|null $roles
*/ */
class Person extends Entity class Person extends Entity
{ {
protected Image $image; protected ?Image $avatar = null;
protected ?int $podcast_id = null;
protected ?int $episode_id = null;
/** /**
* @var object[]|null * @var object[]|null
...@@ -43,37 +42,59 @@ class Person extends Entity ...@@ -43,37 +42,59 @@ class Person extends Entity
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'id' => 'integer', 'id' => 'integer',
'full_name' => 'string', 'full_name' => 'string',
'unique_name' => 'string', 'unique_name' => 'string',
'information_url' => '?string', 'information_url' => '?string',
'image_path' => 'string', 'avatar_id' => '?int',
'image_mimetype' => 'string', 'podcast_id' => '?integer',
'podcast_id' => '?integer', 'episode_id' => '?integer',
'episode_id' => '?integer', 'created_by' => 'integer',
'created_by' => 'integer', 'updated_by' => 'integer',
'updated_by' => 'integer',
]; ];
/** /**
* Saves a picture in `public/media/persons/` * Saves the person avatar in `public/media/persons/`
*/ */
public function setImage(Image $image): static public function setAvatar(UploadedFile | File|null $file = null): static
{ {
helper('media'); if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
// Save image }
$image->saveImage('persons', $this->attributes['unique_name']);
$this->attributes['image_mimetype'] = $image->mimetype; if (array_key_exists('avatar_id', $this->attributes) && $this->attributes['avatar_id'] !== null) {
$this->attributes['image_path'] = $image->path; $this->getAvatar()
->setFile($file);
$this->getAvatar()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getAvatar());
} else {
$avatar = new Image([
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->personAvatarSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$avatar->setFile($file);
$this->attributes['avatar_id'] = (new MediaModel('image'))->saveMedia($avatar);
}
return $this; return $this;
} }
public function getImage(): Image public function getAvatar(): ?Image
{ {
return new Image(null, $this->attributes['image_path'], $this->attributes['image_mimetype']); if ($this->avatar_id === null) {
return null;
}
if (! $this->avatar instanceof Image) {
$this->avatar = (new MediaModel('image'))->getMediaById($this->avatar_id);
}
return $this->avatar;
} }
/** /**
...@@ -89,7 +110,7 @@ class Person extends Entity ...@@ -89,7 +110,7 @@ class Person extends Entity
$this->roles = (new PersonModel())->getPersonRoles( $this->roles = (new PersonModel())->getPersonRoles(
$this->id, $this->id,
(int) $this->attributes['podcast_id'], (int) $this->attributes['podcast_id'],
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null,
); );
} }
......
...@@ -3,22 +3,36 @@ ...@@ -3,22 +3,36 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
namespace App\Entities; namespace App\Entities;
use App\Libraries\SimpleRSSElement; use App\Models\ActorModel;
use App\Models\CategoryModel; use App\Models\CategoryModel;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use App\Models\PersonModel; use App\Models\PersonModel;
use App\Models\PlatformModel;
use App\Models\UserModel;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter; use CodeIgniter\Shield\Entities\User;
use Exception;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\Autolink\AutolinkExtension;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\DisallowedRawHtml\DisallowedRawHtmlExtension;
use League\CommonMark\Extension\SmartPunct\SmartPunctExtension;
use League\CommonMark\MarkdownConverter;
use Modules\Auth\Models\UserModel;
use Modules\Media\Entities\Image;
use Modules\Media\Models\MediaModel;
use Modules\Platforms\Entities\Platform;
use Modules\Platforms\Models\PlatformModel;
use Modules\PremiumPodcasts\Entities\Subscription;
use Modules\PremiumPodcasts\Models\SubscriptionModel;
use RuntimeException; use RuntimeException;
/** /**
...@@ -27,15 +41,17 @@ use RuntimeException; ...@@ -27,15 +41,17 @@ use RuntimeException;
* @property int $actor_id * @property int $actor_id
* @property Actor|null $actor * @property Actor|null $actor
* @property string $handle * @property string $handle
* @property string $at_handle
* @property string $link * @property string $link
* @property string $feed_url * @property string $feed_url
* @property string $title * @property string $title
* @property string|null $description Holds text only description, striped of any markdown or html special characters * @property string|null $description Holds text only description, striped of any markdown or html special characters
* @property string $description_markdown * @property string $description_markdown
* @property string $description_html * @property string $description_html
* @property Image $image * @property int $cover_id
* @property string $image_path * @property ?Image $cover
* @property string $image_mimetype * @property int|null $banner_id
* @property ?Image $banner
* @property string $language_code * @property string $language_code
* @property int $category_id * @property int $category_id
* @property Category|null $category * @property Category|null $category
...@@ -47,8 +63,6 @@ use RuntimeException; ...@@ -47,8 +63,6 @@ use RuntimeException;
* @property string $owner_email * @property string $owner_email
* @property string $type * @property string $type
* @property string|null $copyright * @property string|null $copyright
* @property string|null $episode_description_footer_markdown
* @property string|null $episode_description_footer_html
* @property bool $is_blocked * @property bool $is_blocked
* @property bool $is_completed * @property bool $is_completed
* @property bool $is_locked * @property bool $is_locked
...@@ -58,21 +72,20 @@ use RuntimeException; ...@@ -58,21 +72,20 @@ use RuntimeException;
* @property string|null $location_name * @property string|null $location_name
* @property string|null $location_geo * @property string|null $location_geo
* @property string|null $location_osm * @property string|null $location_osm
* @property string|null $payment_pointer * @property bool $is_published_on_hubs
* @property array|null $custom_rss
* @property string $custom_rss_string
* @property string|null $partner_id
* @property string|null $partner_link_url
* @property string|null $partner_image_url
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
* @property Time $created_at; * @property string $publication_status
* @property Time $updated_at; * @property bool $is_premium_by_default
* @property Time|null $deleted_at; * @property bool $is_premium
* @property Time|null $published_at
* @property Time $created_at
* @property Time $updated_at
* *
* @property Episode[] $episodes * @property Episode[] $episodes
* @property Person[] $persons * @property Person[] $persons
* @property User[] $contributors * @property User[] $contributors
* @property Subscription[] $subscriptions
* @property Platform[] $podcasting_platforms * @property Platform[] $podcasting_platforms
* @property Platform[] $social_platforms * @property Platform[] $social_platforms
* @property Platform[] $funding_platforms * @property Platform[] $funding_platforms
...@@ -81,9 +94,13 @@ class Podcast extends Entity ...@@ -81,9 +94,13 @@ class Podcast extends Entity
{ {
protected string $link; protected string $link;
protected string $at_handle;
protected ?Actor $actor = null; protected ?Actor $actor = null;
protected Image $image; protected ?Image $cover = null;
protected ?Image $banner = null;
protected ?string $description = null; protected ?string $description = null;
...@@ -95,9 +112,9 @@ class Podcast extends Entity ...@@ -95,9 +112,9 @@ class Podcast extends Entity
protected ?array $other_categories = null; protected ?array $other_categories = null;
/** /**
* @var string[]|null * @var int[]
*/ */
protected ?array $other_categories_ids = null; protected array $other_categories_ids = [];
/** /**
* @var Episode[]|null * @var Episode[]|null
...@@ -114,6 +131,11 @@ class Podcast extends Entity ...@@ -114,6 +131,11 @@ class Podcast extends Entity
*/ */
protected ?array $contributors = null; protected ?array $contributors = null;
/**
* @var Subscription[]|null
*/
protected ?array $subscriptions = null;
/** /**
* @var Platform[]|null * @var Platform[]|null
*/ */
...@@ -131,79 +153,150 @@ class Podcast extends Entity ...@@ -131,79 +153,150 @@ class Podcast extends Entity
protected ?Location $location = null; protected ?Location $location = null;
protected string $custom_rss_string; protected ?string $publication_status = null;
/**
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $dates = ['published_at', 'created_at', 'updated_at'];
/** /**
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'id' => 'integer', 'id' => 'integer',
'guid' => 'string', 'guid' => 'string',
'actor_id' => 'integer', 'actor_id' => 'integer',
'handle' => 'string', 'handle' => 'string',
'title' => 'string', 'title' => 'string',
'description_markdown' => 'string', 'description_markdown' => 'string',
'description_html' => 'string', 'description_html' => 'string',
'image_path' => 'string', 'cover_id' => 'int',
'image_mimetype' => 'string', 'banner_id' => '?int',
'language_code' => 'string', 'language_code' => 'string',
'category_id' => 'integer', 'category_id' => 'integer',
'parental_advisory' => '?string', 'parental_advisory' => '?string',
'publisher' => '?string', 'publisher' => '?string',
'owner_name' => 'string', 'owner_name' => 'string',
'owner_email' => 'string', 'owner_email' => 'string',
'type' => 'string', 'type' => 'string',
'copyright' => '?string', 'copyright' => '?string',
'episode_description_footer_markdown' => '?string', 'is_blocked' => 'boolean',
'episode_description_footer_html' => '?string', 'is_completed' => 'boolean',
'is_blocked' => 'boolean', 'is_locked' => 'boolean',
'is_completed' => 'boolean', 'is_premium_by_default' => 'boolean',
'is_locked' => 'boolean', 'imported_feed_url' => '?string',
'imported_feed_url' => '?string', 'new_feed_url' => '?string',
'new_feed_url' => '?string', 'location_name' => '?string',
'location_name' => '?string', 'location_geo' => '?string',
'location_geo' => '?string', 'location_osm' => '?string',
'location_osm' => '?string', 'is_published_on_hubs' => 'boolean',
'payment_pointer' => '?string', 'created_by' => 'integer',
'custom_rss' => '?json-array', 'updated_by' => 'integer',
'partner_id' => '?string',
'partner_link_url' => '?string',
'partner_image_url' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
]; ];
public function getActor(): Actor public function getAtHandle(): string
{
return '@' . $this->handle;
}
public function getActor(): ?Actor
{ {
if ($this->actor_id === 0) { if ($this->actor_id === 0) {
throw new RuntimeException('Podcast must have an actor_id before getting actor.'); throw new RuntimeException('Podcast must have an actor_id before getting actor.');
} }
if ($this->actor === null) { if (! $this->actor instanceof Actor) {
$this->actor = model('ActorModel') $this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id); ->getActorById($this->actor_id);
} }
return $this->actor; return $this->actor;
} }
/** public function setCover(UploadedFile | File|null $file = null): self
* Saves a cover image to the corresponding podcast folder in `public/media/podcast_name/`
*/
public function setImage(Image $image): static
{ {
// Save image if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
$image->saveImage('podcasts/' . $this->attributes['handle'], 'cover'); return $this;
}
$this->attributes['image_mimetype'] = $image->mimetype; if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
$this->attributes['image_path'] = $image->path; $this->getCover()
->setFile($file);
$this->getCover()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover());
} else {
$cover = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
'sizes' => config('Images')
->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$cover->setFile($file);
$this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
}
return $this; return $this;
} }
public function getImage(): Image public function getCover(): Image
{ {
return new Image(null, $this->image_path, $this->image_mimetype); if (! $this->cover instanceof Image) {
$cover = (new MediaModel('image'))->getMediaById($this->cover_id);
if (! $cover instanceof Image) {
throw new Exception('Could not retrieve podcast cover.');
}
$this->cover = $cover;
}
return $this->cover;
}
public function setBanner(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if (array_key_exists('banner_id', $this->attributes) && $this->attributes['banner_id'] !== null) {
$this->getBanner()
->setFile($file);
$this->getBanner()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getBanner());
} else {
$banner = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
'sizes' => config('Images')
->podcastBannerSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$banner->setFile($file);
$this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner);
}
return $this;
}
public function getBanner(): ?Image
{
if ($this->banner_id === null) {
return null;
}
if (! $this->banner instanceof Image) {
$this->banner = (new MediaModel('image'))->getMediaById($this->banner_id);
}
return $this->banner;
} }
public function getLink(): string public function getLink(): string
...@@ -213,7 +306,7 @@ class Podcast extends Entity ...@@ -213,7 +306,7 @@ class Podcast extends Entity
public function getFeedUrl(): string public function getFeedUrl(): string
{ {
return url_to('podcast_feed', $this->attributes['handle']); return url_to('podcast-rss-feed', $this->attributes['handle']);
} }
/** /**
...@@ -234,6 +327,18 @@ class Podcast extends Entity ...@@ -234,6 +327,18 @@ class Podcast extends Entity
return $this->episodes; return $this->episodes;
} }
/**
* Returns the podcast's episodes count
*/
public function getEpisodesCount(): int|string
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting number of episodes.');
}
return (new EpisodeModel())->getPodcastEpisodesCount($this->id);
}
/** /**
* Returns the podcast's persons * Returns the podcast's persons
* *
...@@ -261,13 +366,31 @@ class Podcast extends Entity ...@@ -261,13 +366,31 @@ class Podcast extends Entity
throw new RuntimeException('Podcast must be created before getting category.'); throw new RuntimeException('Podcast must be created before getting category.');
} }
if ($this->category === null) { if (! $this->category instanceof Category) {
$this->category = (new CategoryModel())->getCategoryById($this->category_id); $this->category = (new CategoryModel())->getCategoryById($this->category_id);
} }
return $this->category; return $this->category;
} }
/**
* Returns all podcast subscriptions
*
* @return Subscription[]
*/
public function getSubscriptions(): array
{
if ($this->id === null) {
throw new RuntimeException('Podcasts must be created before getting subscriptions.');
}
if ($this->subscriptions === null) {
$this->subscriptions = (new SubscriptionModel())->getPodcastSubscriptions($this->id);
}
return $this->subscriptions;
}
/** /**
* Returns all podcast contributors * Returns all podcast contributors
* *
...@@ -288,41 +411,21 @@ class Podcast extends Entity ...@@ -288,41 +411,21 @@ class Podcast extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static public function setDescriptionMarkdown(string $descriptionMarkdown): static
{ {
$converter = new CommonMarkConverter([ $config = [
'html_input' => 'strip', 'html_input' => 'escape',
'allow_unsafe_links' => false, 'allow_unsafe_links' => false,
]); ];
$this->attributes['description_markdown'] = $descriptionMarkdown; $environment = new Environment($config);
$this->attributes['description_html'] = $converter->convertToHtml($descriptionMarkdown); $environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
return $this; $converter = new MarkdownConverter($environment);
}
public function setEpisodeDescriptionFooterMarkdown(?string $episodeDescriptionFooterMarkdown = null): static
{
if ($episodeDescriptionFooterMarkdown === null || $episodeDescriptionFooterMarkdown === '') {
$this->attributes[
'episode_description_footer_markdown'
] = null;
$this->attributes[
'episode_description_footer_html'
] = null;
return $this; $this->attributes['description_markdown'] = $descriptionMarkdown;
} $this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$this->attributes[
'episode_description_footer_markdown'
] = $episodeDescriptionFooterMarkdown;
$this->attributes[
'episode_description_footer_html'
] = $converter->convertToHtml($episodeDescriptionFooterMarkdown);
return $this; return $this;
} }
...@@ -331,13 +434,28 @@ class Podcast extends Entity ...@@ -331,13 +434,28 @@ class Podcast extends Entity
{ {
if ($this->description === null) { if ($this->description === null) {
$this->description = trim( $this->description = trim(
(string) preg_replace('~\s+~', ' ', strip_tags($this->attributes['description_html'])), (string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
); );
} }
return $this->description; return $this->description;
} }
public function getPublicationStatus(): string
{
if ($this->publication_status === null) {
if (! $this->published_at instanceof Time) {
$this->publication_status = 'not_published';
} elseif ($this->published_at->isBefore(Time::now())) {
$this->publication_status = 'published';
} else {
$this->publication_status = 'scheduled';
}
}
return $this->publication_status;
}
/** /**
* Returns the podcast's podcasting platform links * Returns the podcast's podcasting platform links
* *
...@@ -350,7 +468,7 @@ class Podcast extends Entity ...@@ -350,7 +468,7 @@ class Podcast extends Entity
} }
if ($this->podcasting_platforms === null) { if ($this->podcasting_platforms === null) {
$this->podcasting_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'podcasting'); $this->podcasting_platforms = (new PlatformModel())->getPlatforms($this->id, 'podcasting');
} }
return $this->podcasting_platforms; return $this->podcasting_platforms;
...@@ -368,7 +486,7 @@ class Podcast extends Entity ...@@ -368,7 +486,7 @@ class Podcast extends Entity
} }
if ($this->social_platforms === null) { if ($this->social_platforms === null) {
$this->social_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'social'); $this->social_platforms = (new PlatformModel())->getPlatforms($this->id, 'social');
} }
return $this->social_platforms; return $this->social_platforms;
...@@ -386,7 +504,7 @@ class Podcast extends Entity ...@@ -386,7 +504,7 @@ class Podcast extends Entity
} }
if ($this->funding_platforms === null) { if ($this->funding_platforms === null) {
$this->funding_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'funding'); $this->funding_platforms = (new PlatformModel())->getPlatforms($this->id, 'funding');
} }
return $this->funding_platforms; return $this->funding_platforms;
...@@ -413,7 +531,7 @@ class Podcast extends Entity ...@@ -413,7 +531,7 @@ class Podcast extends Entity
*/ */
public function getOtherCategoriesIds(): array public function getOtherCategoriesIds(): array
{ {
if ($this->other_categories_ids === null) { if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id'); $this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
} }
...@@ -425,7 +543,7 @@ class Podcast extends Entity ...@@ -425,7 +543,7 @@ class Podcast extends Entity
*/ */
public function setLocation(?Location $location = null): static public function setLocation(?Location $location = null): static
{ {
if ($location === null) { if (! $location instanceof Location) {
$this->attributes['location_name'] = null; $this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null; $this->attributes['location_geo'] = null;
$this->attributes['location_osm'] = null; $this->attributes['location_osm'] = null;
...@@ -453,58 +571,16 @@ class Podcast extends Entity ...@@ -453,58 +571,16 @@ class Podcast extends Entity
return null; return null;
} }
if ($this->location === null) { if (! $this->location instanceof Location) {
$this->location = new Location($this->location_name, $this->location_geo, $this->location_osm); $this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
} }
return $this->location; return $this->location;
} }
/** public function getIsPremium(): bool
* Get custom rss tag as XML String
*/
public function getCustomRssString(): string
{ {
if ($this->attributes['custom_rss'] === null) { // podcast is premium if at least one of its episodes is set as premium
return ''; return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id);
}
helper('rss');
$xmlNode = (new SimpleRSSElement(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"/>',
))->addChild('channel');
array_to_rss([
'elements' => $this->custom_rss,
], $xmlNode);
return str_replace(['<channel>', '</channel>'], '', $xmlNode->asXML());
}
/**
* Saves custom rss tag into json
*/
public function setCustomRssString(string $customRssString): static
{
if ($customRssString === '') {
return $this;
}
helper('rss');
$customRssArray = rss_to_array(
simplexml_load_string(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>' .
$customRssString .
'</channel></rss>',
),
)['elements'][0];
if (array_key_exists('elements', $customRssArray)) {
$this->attributes['custom_rss'] = json_encode($customRssArray['elements']);
} else {
$this->attributes['custom_rss'] = null;
}
return $this;
} }
} }
...@@ -3,22 +3,22 @@ ...@@ -3,22 +3,22 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
namespace App\Entities; namespace App\Entities;
use ActivityPub\Entities\Status as ActivityPubStatus;
use App\Models\EpisodeModel; use App\Models\EpisodeModel;
use Modules\Fediverse\Entities\Post as FediversePost;
use RuntimeException; use RuntimeException;
/** /**
* @property int|null $episode_id * @property int|null $episode_id
* @property Episode|null $episode * @property Episode|null $episode
*/ */
class Status extends ActivityPubStatus class Post extends FediversePost
{ {
protected ?Episode $episode = null; protected ?Episode $episode = null;
...@@ -26,30 +26,30 @@ class Status extends ActivityPubStatus ...@@ -26,30 +26,30 @@ class Status extends ActivityPubStatus
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'id' => 'string', 'id' => 'string',
'uri' => 'string', 'uri' => 'string',
'actor_id' => 'integer', 'actor_id' => 'integer',
'in_reply_to_id' => '?string', 'in_reply_to_id' => '?string',
'reblog_of_id' => '?string', 'reblog_of_id' => '?string',
'episode_id' => '?integer', 'episode_id' => '?integer',
'message' => 'string', 'message' => 'string',
'message_html' => 'string', 'message_html' => 'string',
'favourites_count' => 'integer', 'favourites_count' => 'integer',
'reblogs_count' => 'integer', 'reblogs_count' => 'integer',
'replies_count' => 'integer', 'replies_count' => 'integer',
'created_by' => 'integer', 'created_by' => 'integer',
]; ];
/** /**
* Returns the status' attached episode * Returns the post's attached episode
*/ */
public function getEpisode(): ?Episode public function getEpisode(): ?Episode
{ {
if ($this->episode_id === null) { if ($this->episode_id === null) {
throw new RuntimeException('Status must have an episode_id before getting episode.'); throw new RuntimeException('Post must have an episode_id before getting episode.');
} }
if ($this->episode === null) { if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id); $this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
} }
......
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use App\Models\PodcastModel;
use Myth\Auth\Entities\User as MythAuthUser;
use RuntimeException;
/**
* @property int $id
* @property string $username
* @property string $email
* @property string $password
* @property bool $active
* @property bool $force_pass_reset
* @property int|null $podcast_id
* @property string|null $podcast_role
*
* @property Podcast[] $podcasts All podcasts the user is contributing to
*/
class User extends MythAuthUser
{
/**
* @var Podcast[]|null
*/
protected ?array $podcasts = null;
/**
* Array of field names and the type of value to cast them as when they are accessed.
*
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'active' => 'boolean',
'force_pass_reset' => 'boolean',
'podcast_id' => '?integer',
'podcast_role' => '?string',
];
/**
* Returns the podcasts the user is contributing to
*
* @return Podcast[]
*/
public function getPodcasts(): array
{
if ($this->id === null) {
throw new RuntimeException('Users must be created before getting podcasts.');
}
if ($this->podcasts === null) {
$this->podcasts = (new PodcastModel())->getUserPodcasts($this->id);
}
return $this->podcasts;
}
}
<?php
declare(strict_types=1);
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Override;
class AllowCorsFilter implements FilterInterface
{
/**
* @param list<string>|null $arguments
*
* @return RequestInterface|ResponseInterface|string|null
*/
#[Override]
public function before(RequestInterface $request, $arguments = null)
{
return null;
}
/**
* @param list<string>|null $arguments
*
* @return ResponseInterface|null
*/
#[Override]
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
if (! $response->hasHeader('Cache-Control')) {
$response->setHeader('Cache-Control', 'public, max-age=86400');
}
$response->setHeader('Access-Control-Allow-Origin', '*') // for allowing any domain, insecure
->setHeader('Access-Control-Allow-Headers', '*') // for allowing any headers, insecure
->setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') // allows GET and OPTIONS methods only
->setHeader('Access-Control-Max-Age', '86400');
return $response;
}
}
<?php
declare(strict_types=1);
namespace App\Filters;
use App\Models\PodcastModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Services;
use Myth\Auth\Exceptions\PermissionException;
class PermissionFilter implements FilterInterface
{
/**
* Do whatever processing this filter needs to do. By default it should not return anything during normal execution.
* However, when an abnormal state is found, it should return an instance of CodeIgniter\HTTP\Response. If it does,
* script execution will end and that Response will be sent back to the client, allowing for error pages, redirects,
* etc.
*
* @param string[]|null $params
* @return void|mixed
*/
public function before(RequestInterface $request, $params = null)
{
helper('auth');
if ($params === null) {
return;
}
$authenticate = Services::authentication();
// if no user is logged in then send to the login form
if (! $authenticate->check()) {
session()->set('redirect_url', current_url());
return redirect('login');
}
helper('misc');
$authorize = Services::authorization();
$router = Services::router();
$routerParams = $router->params();
$result = false;
// Check if user has at least one of the permissions
foreach ($params as $permission) {
// check if permission is for a specific podcast
if (
(str_starts_with($permission, 'podcast-') ||
str_starts_with($permission, 'podcast_episodes-')) &&
count($routerParams) > 0
) {
if (
($groupId = (new PodcastModel())->getContributorGroupId(
$authenticate->id(),
$routerParams[0],
)) &&
$authorize->groupHasPermission($permission, $groupId)
) {
$result = true;
break;
}
} elseif (
$authorize->hasPermission($permission, $authenticate->id())
) {
$result = true;
break;
}
}
if (! $result) {
if ($authenticate->silent()) {
$redirectURL = session('redirect_url') ?? '/';
unset($_SESSION['redirect_url']);
return redirect()
->to($redirectURL)
->with('error', lang('Auth.notEnoughPrivilege'));
}
throw new PermissionException(lang('Auth.notEnoughPrivilege'));
}
}
//--------------------------------------------------------------------
/**
* Allows After filters to inspect and modify the response object as needed. This method does not allow any way to
* stop execution of other after filters, short of throwing an Exception or Error.
*
* @param string[]|null $arguments
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
}
//--------------------------------------------------------------------
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use ActivityPub\Entities\Actor;
use App\Entities\User;
use CodeIgniter\Database\Exceptions\DataException;
use Config\Services;
if (! function_exists('user')) {
/**
* Returns the User instance for the current logged in user.
*/
function user(): ?User
{
$authenticate = Services::authentication();
$authenticate->check();
return $authenticate->user();
}
}
if (! function_exists('set_interact_as_actor')) {
/**
* Sets the actor id of which the user is acting as
*/
function set_interact_as_actor(int $actorId): void
{
$authenticate = Services::authentication();
$authenticate->check();
$session = session();
$session->set('interact_as_actor_id', $actorId);
}
}
if (! function_exists('remove_interact_as_actor')) {
/**
* Removes the actor id of which the user is acting as
*/
function remove_interact_as_actor(): void
{
$session = session();
$session->remove('interact_as_actor_id');
}
}
if (! function_exists('interact_as_actor_id')) {
/**
* Sets the podcast id of which the user is acting as
*/
function interact_as_actor_id(): int
{
$authenticate = Services::authentication();
$authenticate->check();
$session = session();
return $session->get('interact_as_actor_id');
}
}
if (! function_exists('interact_as_actor')) {
/**
* Get the actor the user is currently interacting as
*/
function interact_as_actor(): Actor | false
{
$authenticate = Services::authentication();
$authenticate->check();
$session = session();
if ($session->has('interact_as_actor_id')) {
return model('ActorModel')->getActorById($session->get('interact_as_actor_id'));
}
return false;
}
}
if (! function_exists('can_user_interact')) {
/**
* @throws DataException
*/
function can_user_interact(): bool
{
return (bool) interact_as_actor();
}
}
...@@ -2,35 +2,25 @@ ...@@ -2,35 +2,25 @@
declare(strict_types=1); declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use Config\Services;
if (! function_exists('render_breadcrumb')) { if (! function_exists('render_breadcrumb')) {
/** /**
* Renders the breadcrumb navigation through the Breadcrumb service * Renders the breadcrumb navigation through the Breadcrumb service
* *
* @param string $class to be added to the breadcrumb nav * @param string|null $class to be added to the breadcrumb nav
* @return string html breadcrumb * @return string html breadcrumb
*/ */
function render_breadcrumb(string $class = null): string function render_breadcrumb(?string $class = null): string
{ {
$breadcrumb = Services::breadcrumb(); return service('breadcrumb')->render($class);
return $breadcrumb->render($class);
} }
} }
if (! function_exists('replace_breadcrumb_params')) { if (! function_exists('replace_breadcrumb_params')) {
/** /**
* @param string[] $newParams * @param array<string|int,string> $newParams
*/ */
function replace_breadcrumb_params(array $newParams): void function replace_breadcrumb_params(array $newParams): void
{ {
$breadcrumb = Services::breadcrumb(); service('breadcrumb')->replaceParams($newParams);
$breadcrumb->replaceParams($newParams);
} }
} }
...@@ -3,176 +3,17 @@ ...@@ -3,176 +3,17 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
use App\Entities\Category;
use App\Entities\Episode;
use App\Entities\Location; use App\Entities\Location;
use App\Entities\Person;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use CodeIgniter\View\Table; use CodeIgniter\View\Table;
if (! function_exists('button')) {
/**
* Button component
*
* Creates a stylized button or button like anchor tag if the URL is defined.
*
* @param array<string, string|null|bool> $customOptions button options: variant, size, iconLeft, iconRight
* @param array<string, string> $customAttributes Additional attributes
*/
function button(
string $label = '',
string $uri = '',
array $customOptions = [],
array $customAttributes = []
): string {
$defaultOptions = [
'variant' => 'default',
'size' => 'base',
'iconLeft' => null,
'iconRight' => null,
'isSquared' => false,
];
$options = array_merge($defaultOptions, $customOptions);
$baseClass =
'inline-flex items-center font-semibold shadow-xs rounded-full focus:outline-none focus:ring';
$variantClass = [
'default' => 'text-black bg-gray-300 hover:bg-gray-400',
'primary' => 'text-white bg-pine-700 hover:bg-pine-800',
'secondary' => 'text-white bg-gray-700 hover:bg-gray-800',
'accent' => 'text-white bg-rose-600 hover:bg-rose-800',
'success' => 'text-white bg-green-600 hover:bg-green-700',
'danger' => 'text-white bg-red-600 hover:bg-red-700',
'warning' => 'text-black bg-yellow-500 hover:bg-yellow-600',
'info' => 'text-white bg-blue-500 hover:bg-blue-600',
];
$sizeClass = [
'small' => 'text-xs md:text-sm',
'base' => 'text-sm md:text-base',
'large' => 'text-lg md:text-xl',
];
$basePaddings = [
'small' => 'px-2 md:px-3 md:py-1',
'base' => 'px-3 py-1 md:px-4 md:py-2',
'large' => 'px-3 py-2 md:px-5',
];
$squaredPaddings = [
'small' => 'p-1',
'base' => 'p-2',
'large' => 'p-3',
];
$buttonClass =
$baseClass .
' ' .
($options['isSquared']
? $squaredPaddings[$options['size']]
: $basePaddings[$options['size']]) .
' ' .
$sizeClass[$options['size']] .
' ' .
$variantClass[$options['variant']];
if (array_key_exists('class', $customAttributes)) {
$buttonClass .= ' ' . $customAttributes['class'];
unset($customAttributes['class']);
}
if ($options['iconLeft']) {
$label = icon((string) $options['iconLeft'], 'mr-2') . $label;
}
if ($options['iconRight']) {
$label .= icon((string) $options['iconRight'], 'ml-2');
}
if ($uri !== '') {
return anchor($uri, $label, array_merge([
'class' => $buttonClass,
], $customAttributes));
}
$defaultButtonAttributes = [
'type' => 'button',
];
$attributes = stringify_attributes(array_merge($defaultButtonAttributes, $customAttributes));
return <<<CODE_SAMPLE
<button class="{$buttonClass}" {$attributes}>
{$label}
</button>
CODE_SAMPLE;
}
}
// ------------------------------------------------------------------------
if (! function_exists('icon_button')) {
/**
* Icon Button component
*
* Abstracts the `button()` helper to create a stylized icon button
*
* @param string $icon The button icon
* @param string $title The button label
* @param array<string, string|null|bool> $customOptions button options: variant, size, iconLeft, iconRight
* @param array<string, string> $customAttributes Additional attributes
*/
function icon_button(
string $icon,
string $title,
string $uri = '',
array $customOptions = [],
array $customAttributes = []
): string {
$defaultOptions = [
'isSquared' => true,
];
$options = array_merge($defaultOptions, $customOptions);
$defaultAttributes = [
'title' => $title,
'data-toggle' => 'tooltip',
'data-placement' => 'bottom',
];
$attributes = array_merge($defaultAttributes, $customAttributes);
return button(icon($icon), $uri, $options, $attributes);
}
}
// ------------------------------------------------------------------------
if (! function_exists('hint_tooltip')) {
/**
* Hint component
*
* Used to produce tooltip with a question mark icon for hint texts
*
* @param string $hintText The hint text
*/
function hint_tooltip(string $hintText = '', string $class = ''): string
{
$tooltip =
'<span data-toggle="tooltip" data-placement="bottom" tabindex="0" title="' .
$hintText .
'" class="inline-block text-gray-500 align-middle outline-none focus:ring';
if ($class !== '') {
$tooltip .= ' ' . $class;
}
return $tooltip . '">' . icon('question') . '</span>';
}
}
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (! function_exists('data_table')) { if (! function_exists('data_table')) {
...@@ -185,22 +26,21 @@ if (! function_exists('data_table')) { ...@@ -185,22 +26,21 @@ if (! function_exists('data_table')) {
* @param mixed[] $data data to loop through and display in rows * @param mixed[] $data data to loop through and display in rows
* @param mixed ...$rest Any other argument to pass to the `cell` function * @param mixed ...$rest Any other argument to pass to the `cell` function
*/ */
function data_table(array $columns, array $data = [], ...$rest): string function data_table(array $columns, array $data = [], string $class = '', mixed ...$rest): string
{ {
$table = new Table(); $table = new Table();
$template = [ $template = [
'table_open' => '<table class="w-full whitespace-no-wrap">', 'table_open' => '<table class="w-full whitespace-nowrap">',
'thead_open' => 'thead_open' => '<thead class="text-xs font-semibold text-left uppercase text-skin-muted">',
'<thead class="text-xs font-semibold text-left text-gray-500 uppercase border-b">',
'heading_cell_start' => '<th class="px-4 py-2">', 'heading_cell_start' => '<th class="px-4 py-2">',
'cell_start' => '<td class="px-4 py-2">', 'cell_start' => '<td class="px-4 py-2">',
'cell_alt_start' => '<td class="px-4 py-2">', 'cell_alt_start' => '<td class="px-4 py-2">',
'row_start' => '<tr class="bg-gray-100 hover:bg-pine-100">', 'row_start' => '<tr class="border-t border-subtle hover:bg-base">',
'row_alt_start' => '<tr class="hover:bg-pine-100">', 'row_alt_start' => '<tr class="border-t border-subtle hover:bg-base">',
]; ];
$table->setTemplate($template); $table->setTemplate($template);
...@@ -219,13 +59,20 @@ if (! function_exists('data_table')) { ...@@ -219,13 +59,20 @@ if (! function_exists('data_table')) {
foreach ($columns as $column) { foreach ($columns as $column) {
$rowData[] = $column['cell']($row, ...$rest); $rowData[] = $column['cell']($row, ...$rest);
} }
$table->addRow($rowData); $table->addRow($rowData);
} }
} else { } else {
return lang('Common.no_data'); $table->addRow([
[
'colspan' => count($tableHeaders),
'class' => 'px-4 py-2 italic font-semibold text-center',
'data' => lang('Common.no_data'),
],
]);
} }
return '<div class="overflow-x-auto bg-white rounded-lg shadow" >' . return '<div class="overflow-x-auto rounded-lg bg-elevated border-3 border-subtle ' . $class . '" >' .
$table->generate() . $table->generate() .
'</div>'; '</div>';
} }
...@@ -241,34 +88,29 @@ if (! function_exists('publication_pill')) { ...@@ -241,34 +88,29 @@ if (! function_exists('publication_pill')) {
*/ */
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{ {
if ($publicationDate === null) { $variant = match ($publicationStatus) {
return ''; 'published' => 'success',
} 'scheduled' => 'warning',
'with_podcast' => 'info',
$class = 'not_published' => 'default',
$publicationStatus === 'published' default => 'default',
? 'text-pine-500 border-pine-500' };
: 'text-red-600 border-red-600';
$title = match ($publicationStatus) {
$langOptions = [ 'published', 'scheduled' => (string) $publicationDate,
'<time pubdate datetime="' . 'with_podcast' => lang('Episode.with_podcast_hint'),
$publicationDate->format(DateTime::ATOM) . 'not_published' => '',
'" title="' . default => '',
$publicationDate . };
'">' .
lang('Common.mediumDate', [$publicationDate]) . $label = lang('Episode.publication_status.' . $publicationStatus);
'</time>',
]; // @icon("error-warning-fill")
return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
$label = lang('Episode.publication_status.' . $publicationStatus, $langOptions); '">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
'class' => 'flex-shrink-0 ml-1 text-lg',
return '<span class="px-1 font-semibold border ' . ]) : '') .
$class . '</x-Pill>';
' ' .
$customClass .
'">' .
$label .
'</span>';
} }
} }
...@@ -276,31 +118,31 @@ if (! function_exists('publication_pill')) { ...@@ -276,31 +118,31 @@ if (! function_exists('publication_pill')) {
if (! function_exists('publication_button')) { if (! function_exists('publication_button')) {
/** /**
* Publication button component * Publication button component for episodes
* *
* Displays the appropriate publication button depending on the publication status. * Displays the appropriate publication button depending on the publication post.
*/ */
function publication_button(int $podcastId, int $episodeId, string $publicationStatus): string function publication_button(int $podcastId, int $episodeId, string $publicationStatus): string
{ {
/** @phpstan-ignore-next-line */
switch ($publicationStatus) { switch ($publicationStatus) {
case 'not_published': case 'not_published':
$label = lang('Episode.publish'); $label = lang('Episode.publish');
$route = route_to('episode-publish', $podcastId, $episodeId); $route = route_to('episode-publish', $podcastId, $episodeId);
$variant = 'primary'; $variant = 'primary';
$iconLeft = 'upload-cloud'; $iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break; break;
case 'with_podcast':
case 'scheduled': case 'scheduled':
$label = lang('Episode.publish_edit'); $label = lang('Episode.publish_edit');
$route = route_to('episode-publish_edit', $podcastId, $episodeId); $route = route_to('episode-publish_edit', $podcastId, $episodeId);
$variant = 'accent'; $variant = 'warning';
$iconLeft = 'upload-cloud'; $iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break; break;
case 'published': case 'published':
$label = lang('Episode.unpublish'); $label = lang('Episode.unpublish');
$route = route_to('episode-unpublish', $podcastId, $episodeId); $route = route_to('episode-unpublish', $podcastId, $episodeId);
$variant = 'danger'; $variant = 'danger';
$iconLeft = 'cloud-off'; $iconLeft = 'cloud-off-fill'; // @icon("cloud-off-fill")
break; break;
default: default:
$label = ''; $label = '';
...@@ -310,10 +152,106 @@ if (! function_exists('publication_button')) { ...@@ -310,10 +152,106 @@ if (! function_exists('publication_button')) {
break; break;
} }
return button($label, $route, [ return <<<HTML
'variant' => $variant, <x-Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</x-Button>
'iconLeft' => $iconLeft, HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('publication_status_banner')) {
/**
* Publication status banner component for podcasts
*
* Displays the appropriate banner depending on the podcast's publication status.
*/
function publication_status_banner(?Time $publicationDate, int $podcastId, string $publicationStatus): string
{
switch ($publicationStatus) {
case 'not_published':
$bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode');
$bannerText = lang('Podcast.publication_status_banner.not_published');
$linkRoute = route_to('podcast-publish', $podcastId);
$linkLabel = lang('Podcast.publish');
break;
case 'scheduled':
$bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode');
$bannerText = lang('Podcast.publication_status_banner.scheduled', [
'publication_date' => local_datetime($publicationDate),
]);
$linkRoute = route_to('podcast-publish_edit', $podcastId);
$linkLabel = lang('Podcast.publish_edit');
break;
default:
$bannerDisclaimer = '';
$bannerText = '';
$linkRoute = '';
$linkLabel = '';
break;
}
return <<<HTML
<div class="flex flex-wrap items-baseline px-4 py-2 border-b md:px-12 bg-stripes-default border-subtle" role="alert">
<p class="flex items-baseline text-gray-900">
<span class="text-xs font-semibold tracking-wide uppercase">{$bannerDisclaimer}</span>
<span class="ml-3 text-sm">{$bannerText}</span>
</p>
<a href="{$linkRoute}" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline">{$linkLabel}</a>
</div>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('episode_publication_status_banner')) {
/**
* Publication status banner component for podcasts
*
* Displays the appropriate banner depending on the podcast's publication status.
*/
function episode_publication_status_banner(Episode $episode, string $class = ''): string
{
switch ($episode->publication_status) {
case 'not_published':
$linkRoute = route_to('episode-publish', $episode->podcast_id, $episode->id);
$publishLinkLabel = lang('Episode.publish');
break;
case 'scheduled':
case 'with_podcast':
$linkRoute = route_to('episode-publish_edit', $episode->podcast_id, $episode->id);
$publishLinkLabel = lang('Episode.publish_edit');
break;
default:
$bannerDisclaimer = '';
$linkRoute = '';
$publishLinkLabel = '';
break;
}
$bannerDisclaimer = lang('Episode.publication_status_banner.draft_mode');
$bannerText = lang('Episode.publication_status_banner.text', [
'publication_status' => $episode->publication_status,
'publication_date' => $episode->published_at instanceof Time ? local_datetime(
$episode->published_at,
) : null,
]); ]);
$previewLinkLabel = lang('Episode.publication_status_banner.preview');
return <<<HTML
<div class="flex flex-wrap gap-4 items-baseline px-4 md:px-12 py-2 bg-stripes-default border-subtle {$class}" role="alert">
<p class="flex items-baseline text-gray-900">
<span class="text-xs font-semibold tracking-wide uppercase">{$bannerDisclaimer}</span>
<span class="ml-3 text-sm">{$bannerText}</span>
</p>
<div class="flex items-baseline">
<a href="{$episode->preview_link}" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline">{$previewLinkLabel}</a>
<span class="mx-1">•</span>
<a href="{$linkRoute}" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline">{$publishLinkLabel}</a>
</div>
</div>
HTML;
} }
} }
...@@ -329,7 +267,7 @@ if (! function_exists('episode_numbering')) { ...@@ -329,7 +267,7 @@ if (! function_exists('episode_numbering')) {
?int $episodeNumber = null, ?int $episodeNumber = null,
?int $seasonNumber = null, ?int $seasonNumber = null,
string $class = '', string $class = '',
bool $isAbbr = false bool $isAbbr = false,
): string { ): string {
if (! $episodeNumber && ! $seasonNumber) { if (! $episodeNumber && ! $seasonNumber) {
return ''; return '';
...@@ -338,11 +276,11 @@ if (! function_exists('episode_numbering')) { ...@@ -338,11 +276,11 @@ if (! function_exists('episode_numbering')) {
$transKey = ''; $transKey = '';
$args = []; $args = [];
if ($episodeNumber !== null) { if ($episodeNumber !== null) {
$args['episodeNumber'] = $episodeNumber; $args['episodeNumber'] = sprintf('%02d', $episodeNumber);
} }
if ($seasonNumber !== null) { if ($seasonNumber !== null) {
$args['seasonNumber'] = $seasonNumber; $args['seasonNumber'] = sprintf('%02d', $seasonNumber);
} }
if ($episodeNumber !== null && $seasonNumber !== null) { if ($episodeNumber !== null && $seasonNumber !== null) {
...@@ -354,11 +292,11 @@ if (! function_exists('episode_numbering')) { ...@@ -354,11 +292,11 @@ if (! function_exists('episode_numbering')) {
} }
if ($isAbbr) { if ($isAbbr) {
return '<abbr class="' . return '<abbr class="tracking-wider ' .
$class . $class .
'" title="' . '" title="' .
lang($transKey, $args) . lang($transKey, $args) .
'">' . '" data-tooltip="bottom">' .
lang($transKey . '_abbr', $args) . lang($transKey . '_abbr', $args) .
'</abbr>'; '</abbr>';
} }
...@@ -371,25 +309,28 @@ if (! function_exists('episode_numbering')) { ...@@ -371,25 +309,28 @@ if (! function_exists('episode_numbering')) {
} }
} }
// ------------------------------------------------------------------------
if (! function_exists('location_link')) { if (! function_exists('location_link')) {
/** /**
* Returns link to display from location info * Returns link to display from location info
*/ */
function location_link(?Location $location, string $class = ''): string function location_link(?Location $location, string $class = ''): string
{ {
if ($location === null) { if (! $location instanceof Location) {
return ''; return '';
} }
return anchor( return anchor(
$location->url, $location->url,
icon('map-pin', 'mr-2') . $location->name, icon('map-pin-2-fill', [
'class' => 'mr-2 flex-shrink-0',
]) . '<span class="truncate">' . esc($location->name) . '</span>',
[ [
'class' => 'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
'inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"), ($class === '' ? '' : " {$class}"),
'target' => '_blank', 'target' => '_blank',
'rel' => 'noreferrer noopener', 'rel' => 'noreferrer noopener',
], ],
); );
} }
...@@ -397,57 +338,170 @@ if (! function_exists('location_link')) { ...@@ -397,57 +338,170 @@ if (! function_exists('location_link')) {
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (! function_exists('person_list')) { if (! function_exists('audio_player')) {
/** /**
* Returns list of persons images * Returns audio player
*
* @param Person[] $persons
*/ */
function person_list(array $persons, string $class = ''): string function audio_player(string $source, string $mediaType, string $class = ''): string
{
$language = service('request')
->getLocale();
return <<<HTML
<vm-player
id="castopod-vm-player"
theme="light"
language="{$language}"
class="{$class} relative z-0"
style="--vm-player-box-shadow:0; --vm-player-theme: hsl(var(--color-accent-base)); --vm-control-focus-color: hsl(var(--color-accent-contrast)); --vm-control-spacing: 4px; --vm-menu-item-focus-bg: hsl(var(--color-background-highlight));"
>
<vm-audio preload="none">
<source src="{$source}" type="{$mediaType}" />
</vm-audio>
<vm-ui>
<vm-icon-library></vm-icon-library>
<vm-controls full-width>
<vm-playback-control></vm-playback-control>
<vm-volume-control></vm-volume-control>
<vm-current-time></vm-current-time>
<vm-scrubber-control></vm-scrubber-control>
<vm-end-time></vm-end-time>
<vm-settings-control></vm-settings-control>
<vm-default-settings></vm-default-settings>
</vm-controls>
</vm-ui>
</vm-player>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('relative_time')) {
function relative_time(Time $time, string $class = ''): string
{ {
if ($persons === []) { $formatter = new IntlDateFormatter(service(
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM);
return <<<HTML
<relative-time tense="auto" class="{$class}" datetime="{$datetime}">
<time
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</relative-time>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('local_datetime')) {
function local_datetime(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ATOM);
return <<<HTML
<relative-time datetime="{$datetime}"
prefix=""
threshold="PT0S"
weekday="long"
day="numeric"
month="long"
year="numeric"
hour="numeric"
minute="numeric">
<time
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</relative-time>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('local_date')) {
function local_date(Time $time): string
{
$formatter = new IntlDateFormatter(service(
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
return <<<HTML
<time title="{$time}">{$translatedDate}</time>
HTML;
}
}
// ------------------------------------------------------------------------
if (! function_exists('explicit_badge')) {
function explicit_badge(bool $isExplicit, string $class = ''): string
{
if (! $isExplicit) {
return ''; return '';
} }
$personList = "<div class='flex w-full space-x-2 overflow-y-auto {$class}'>"; $explicitLabel = lang('Common.explicit');
return <<<HTML
<span class="px-1 text-xs font-semibold leading-tight tracking-wider uppercase border md:border-white/50 {$class}">{$explicitLabel}</span>
HTML;
}
}
foreach ($persons as $person) { // ------------------------------------------------------------------------
$personList .= anchor(
$person->information_url ?? '#', if (! function_exists('category_label')) {
"<img function category_label(Category $category): string
src='{$person->image->thumbnail_url}' {
alt='{$person->full_name}' $categoryLabel = '';
class='object-cover w-12 h-12 rounded-full' />", if ($category->parent_id !== null) {
[ $categoryLabel .= lang('Podcast.category_options.' . $category->parent->code) . ' › ';
'class' =>
'flex-shrink-0 focus:outline-none focus:ring focus:ring-inset',
'target' => '_blank',
'rel' => 'noreferrer noopener',
'title' =>
'<strong>' .
$person->full_name .
'</strong>' .
implode(
'',
array_map(function ($role) {
return '<br />' .
lang(
'PersonsTaxonomy.persons.' .
$role->group .
'.roles.' .
$role->role .
'.label',
);
}, $person->roles),
),
'data-toggle' => 'tooltip',
'data-placement' => 'bottom',
],
);
} }
return $personList . '</div>'; return $categoryLabel . lang('Podcast.category_options.' . $category->code);
} }
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (! function_exists('downloads_abbr')) {
function downloads_abbr(int $downloads): string
{
if ($downloads < 1000) {
return (string) $downloads;
}
$option = match (true) {
$downloads < 1_000_000 => [
'divider' => 1_000,
'suffix' => 'K',
],
$downloads < 1_000_000_000 => [
'divider' => 1_000_000,
'suffix' => 'M',
],
default => [
'divider' => 1_000_000_000,
'suffix' => 'B',
],
};
$formatter = new NumberFormatter(service('request')->getLocale(), NumberFormatter::DECIMAL);
$formatter->setPattern('#,##0.##');
$abbr = $formatter->format($downloads / $option['divider']) . $option['suffix'];
return <<<HTML
<abbr title="{$downloads}">{$abbr}</abbr>
HTML;
}
}
...@@ -2,315 +2,83 @@ ...@@ -2,315 +2,83 @@
declare(strict_types=1); declare(strict_types=1);
/** if (! function_exists('form_textarea')) {
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('form_section')) {
/** /**
* Form section * Adapted textarea field from CI4 core: without value escaping.
*
* Used to produce a responsive form section with a title and subtitle. To close section, use form_section_close()
*
* @param string $title The section title
* @param string $subtitle The section subtitle
* @param array<string, string> $attributes Additional attributes
*/ */
function form_section( function form_textarea(mixed $data = '', string $value = '', mixed $extra = ''): string
string $title = '',
string $subtitle = '',
array $attributes = [],
string $customSubtitleClass = ''
): string {
$subtitleClass = 'text-sm text-gray-600';
if ($customSubtitleClass !== '') {
$subtitleClass = $customSubtitleClass;
}
$section =
'<div class="flex flex-wrap w-full gap-6 mb-8"' .
stringify_attributes($attributes) .
">\n";
$info =
'<div class="w-full max-w-xs"><h2 class="text-lg font-semibold">' .
$title .
'</h2><p class="' .
$subtitleClass .
'">' .
$subtitle .
'</p></div>';
return $section . $info . '<div class="flex flex-col w-full max-w-lg">';
}
}
//--------------------------------------------------------------------
if (! function_exists('form_section_close')) {
/**
* Form Section close Tag
*/
function form_section_close(string $extra = ''): string
{ {
return '</div></div>' . $extra; $defaults = [
} 'name' => is_array($data) ? '' : $data,
} 'cols' => '40',
'rows' => '10',
//-------------------------------------------------------------------- ];
if (! is_array($data) || ! isset($data['value'])) {
if (! function_exists('form_switch')) { $val = $value;
/** } else {
* Form Checkbox Switch $val = $data['value'];
* unset($data['value']); // textareas don't use the value attribute
* Abstracts form_label to stylize it as a switch toggle
*
* @param mixed[] $data
* @param mixed[] $extra
*/
function form_switch(
string $label = '',
array $data = [],
string $value = '',
bool $checked = false,
string $class = '',
array $extra = []
): string {
$data['class'] = 'form-switch';
return '<label class="relative inline-flex items-center' .
' ' .
$class .
'">' .
form_checkbox($data, $value, $checked, $extra) .
'<span class="form-switch-slider"></span>' .
'<span class="ml-2">' .
$label .
'</span></label>';
}
}
//--------------------------------------------------------------------
if (! function_exists('form_label')) {
/**
* Form Label Tag
*
* @param string $text The text to appear onscreen
* @param string $id The id the label applies to
* @param array<string, string> $attributes Additional attributes
* @param string $hintText Hint text to add next to the label
* @param boolean $isOptional adds an optional text if true
*/
function form_label(
string $text = '',
string $id = '',
array $attributes = [],
string $hintText = '',
bool $isOptional = false
): string {
$label = '<label';
if ($id !== '') {
$label .= ' for="' . $id . '"';
}
if (is_array($attributes) && $attributes) {
foreach ($attributes as $key => $val) {
$label .= ' ' . $key . '="' . $val . '"';
}
} }
$labelContent = $text; // Unsets default rows and cols if defined in extra field as array or string.
if ($isOptional) { if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
$labelContent .= (string) preg_replace('~\s+~', '', $extra),
'<small class="ml-1 lowercase">(' . 'rows=',
lang('Common.optional') . ) !== false)) {
')</small>'; unset($defaults['rows']);
} }
if ($hintText !== '') { if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
$labelContent .= hint_tooltip($hintText, 'ml-1'); (string) preg_replace('~\s+~', '', $extra),
'cols=',
) !== false)) {
unset($defaults['cols']);
} }
return $label . '>' . $labelContent . '</label>'; return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra,
) . '>' . $val . "</textarea>\n";
} }
} }
//-------------------------------------------------------------------- if (! function_exists('parse_form_attributes')) {
if (! function_exists('form_multiselect')) {
/** /**
* Multi-select menu * Parse the form attributes
* *
* @param array<string, string> $options * Helper function used by some of the form helpers
* @param string[] $selected
* @param array<string, string> $customExtra
*/
function form_multiselect(
string $name = '',
array $options = [],
array $selected = [],
array $customExtra = []
): string {
$defaultExtra = [
'data-class' => $customExtra['class'],
'multiple' => 'multiple',
];
$extra = array_merge($defaultExtra, $customExtra);
return form_dropdown($name, $options, $selected, $extra);
}
}
//--------------------------------------------------------------------
if (! function_exists('form_dropdown')) {
/**
* Drop-down Menu (based on html select tag)
* *
* @param array<string, mixed> $options * @param array<string, string>|string $attributes List of attributes
* @param string[] $selected * @param array<string, mixed> $default Default values
* @param array<string, mixed> $customExtra
*/ */
function form_dropdown( function parse_form_attributes(array|string $attributes, array $default): string
string $name = '', {
array $options = [], if (is_array($attributes)) {
array $selected = [], foreach (array_keys($default) as $key) {
array $customExtra = [] if (isset($attributes[$key])) {
): string { $default[$key] = $attributes[$key];
$defaultExtra = [ unset($attributes[$key]);
'data-select-text' => lang('Common.forms.multiSelect.selectText'), }
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'), }
'data-no-results-text' => lang('Common.forms.multiSelect.noResultsText'),
'data-no-choices-text' => lang('Common.forms.multiSelect.noChoicesText'),
'data-max-item-text' => lang('Common.forms.multiSelect.maxItemText'),
];
$extra = array_merge($defaultExtra, $customExtra);
$defaults = [
'name' => $name,
];
// standardize selected as strings, like the option keys will be.
foreach ($selected as $key => $item) {
$selected[$key] = $item;
}
$placeholderOption = ''; if ($attributes !== []) {
if (isset($extra['placeholder'])) { $default = array_merge($default, $attributes);
$placeholderOption = '<option value="" disabled="disabled" hidden="hidden"' . (in_array( }
'',
$selected,
true
) ? ' selected="selected"' : '') . '>' . $extra['placeholder'] . '</option>';
unset($extra['placeholder']);
} }
$extra = stringify_attributes($extra); $att = '';
$multiple = (count($selected) > 1 && stripos($extra, 'multiple') === false) ? ' multiple="multiple"' : '';
$form = '<select ' . rtrim(parse_form_attributes($name, $defaults)) . $extra . $multiple . ">\n";
$form .= $placeholderOption;
foreach ($options as $key => $val) { foreach ($default as $key => $val) {
if (is_array($val)) { if (! is_bool($val)) {
if ($val === []) { if ($key === 'name' && ! strlen((string) $default['name'])) {
continue; continue;
} }
$form .= '<optgroup label="' . $key . "\">\n";
foreach ($val as $optgroupKey => $optgroupVal) { $att .= $key . '="' . $val . '"' . ($key === array_key_last($default) ? '' : ' ');
$sel = in_array($optgroupKey, $selected, true) ? ' selected="selected"' : '';
$form .= '<option value="' . htmlspecialchars($optgroupKey) . '"' . $sel . '>'
. $optgroupVal . "</option>\n";
}
$form .= "</optgroup>\n";
} else { } else {
/** @noRector RecastingRemovalRector */ $att .= $key . ' ';
$form .= '<option value="' . htmlspecialchars((string) $key) . '"'
. (in_array($key, $selected, true) ? ' selected="selected"' : '') . '>'
. $val . "</option>\n";
} }
} }
return $form . "</select>\n"; return $att;
}
}
//--------------------------------------------------------------------
if (! function_exists('form_editor')) {
/**
* Markdown editor
*
* @param array<string, mixed> $data
* @param array<string, mixed>|string $extra
*/
function form_markdown_editor(array $data = [], string $value = '', string | array $extra = ''): string
{
$editorClass = 'w-full flex flex-col bg-white border border-gray-500 focus-within:ring-1 focus-within:ring-blue-600';
if (array_key_exists('class', $data) && $data['class'] !== '') {
$editorClass .= ' ' . $data['class'];
unset($data['class']);
}
$data['class'] = 'border-none outline-none focus:border-none focus:outline-none w-full h-full';
return '<div class="' . $editorClass . '">' .
'<header class="sticky top-0 z-20 flex flex-wrap justify-between bg-white border-b border-gray-500">' .
'<markdown-write-preview for="' . $data['id'] . '" class="relative inline-flex h-8">' .
'<button type="button" slot="write" class="px-2 font-semibold focus:outline-none focus:ring-inset focus:ring-2 focus:ring-pine-600">' . lang(
'Common.forms.editor.write'
) . '</button>' .
'<button type="button" slot="preview" class="px-2 focus:outline-none focus:ring-inset focus:ring-2 focus:ring-pine-600">' . lang(
'Common.forms.editor.preview'
) . '</button>' .
'</markdown-write-preview>' .
'<markdown-toolbar for="' . $data['id'] . '" class="flex gap-4 px-2 py-1">' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-header class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'heading'
) . '</md-header>' .
'<md-bold class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'bold'
) . '</md-bold>' .
'<md-italic class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'italic'
) . '</md-italic>' .
'</div>' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-unordered-list class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'list-unordered'
) . '</md-unordered-list>' .
'<md-ordered-list class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'list-ordered'
) . '</md-ordered-list>' .
'</div>' .
'<div class="inline-flex text-2xl gap-x-1">' .
'<md-quote class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'quote'
) . '</md-quote>' .
'<md-link class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'link'
) . '</md-link>' .
'<md-image class="opacity-50 hover:opacity-100 focus:outline-none focus:ring-2 focus:opacity-100 focus:ring-pine-600">' . icon(
'image-add'
) . '</md-image>' .
'</div>' .
'</markdown-toolbar>' .
'</header>' .
'<div class="relative">' .
form_textarea($data, $value, $extra) .
'<markdown-preview for="' . $data['id'] . '" class="absolute top-0 left-0 hidden w-full h-full p-2 overflow-y-auto prose bg-gray-50" showClass="bg-white"></markdown-preview>' .
'</div>' .
'<footer class="flex px-2 py-1 bg-gray-100 border-t">' .
'<a href="https://commonmark.org/help/" class="inline-flex items-center text-xs font-semibold text-gray-500 hover:text-gray-700" target="_blank" rel="noopener noreferrer">' . icon(
'markdown',
'mr-1 text-lg text-gray-400'
) . lang('Common.forms.editor.help') . '</a>' .
'</footer>' .
'</div>';
} }
} }
// ------------------------------------------------------------------------
...@@ -3,35 +3,14 @@ ...@@ -3,35 +3,14 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
use App\Entities\Episode; use App\Entities\Episode;
use CodeIgniter\Files\File; use CodeIgniter\I18n\Time;
use JamesHeinrich\GetID3\GetID3;
use JamesHeinrich\GetID3\WriteTags; use JamesHeinrich\GetID3\WriteTags;
use Modules\Media\FileManagers\FileManagerInterface;
if (! function_exists('get_file_tags')) {
/**
* Gets audio file metadata and ID3 info
*
* @return array<string, string|double|int>
*/
function get_file_tags(File $file): array
{
$getID3 = new GetID3();
$FileInfo = $getID3->analyze((string) $file);
return [
'filesize' => $FileInfo['filesize'],
'mime_type' => $FileInfo['mime_type'],
'avdataoffset' => $FileInfo['avdataoffset'],
'playtime_seconds' => $FileInfo['playtime_seconds'],
];
}
}
if (! function_exists('write_audio_file_tags')) { if (! function_exists('write_audio_file_tags')) {
/** /**
...@@ -45,15 +24,16 @@ if (! function_exists('write_audio_file_tags')) { ...@@ -45,15 +24,16 @@ if (! function_exists('write_audio_file_tags')) {
// Initialize getID3 tag-writing module // Initialize getID3 tag-writing module
$tagwriter = new WriteTags(); $tagwriter = new WriteTags();
$tagwriter->filename = media_path($episode->audio_file_path); $tagwriter->filename = $episode->audio->file_name;
// set various options (optional) // set various options (optional)
$tagwriter->tagformats = ['id3v2.4']; $tagwriter->tagformats = ['id3v2.4'];
$tagwriter->tag_encoding = $TextEncoding; $tagwriter->tag_encoding = $TextEncoding;
$cover = new File(media_path($episode->image->id3_path)); /** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$APICdata = file_get_contents($cover->getRealPath()); $APICdata = (string) $fileManager->getFileContents($episode->cover->id3_key);
// TODO: variables used for podcast specific tags // TODO: variables used for podcast specific tags
// $podcastUrl = $episode->podcast->link; // $podcastUrl = $episode->podcast->link;
...@@ -62,24 +42,16 @@ if (! function_exists('write_audio_file_tags')) { ...@@ -62,24 +42,16 @@ if (! function_exists('write_audio_file_tags')) {
// populate data array // populate data array
$TagData = [ $TagData = [
'title' => [$episode->title], 'title' => [esc($episode->title)],
'artist' => [ 'artist' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
$episode->podcast->publisher === null 'album' => [esc($episode->podcast->title)],
? $episode->podcast->owner_name 'year' => [$episode->published_at instanceof Time ? $episode->published_at->format('Y') : ''],
: $episode->podcast->publisher, 'genre' => ['Podcast'],
], 'comment' => [$episode->description],
'album' => [$episode->podcast->title], 'track_number' => [(string) $episode->number],
'year' => [$episode->published_at !== null ? $episode->published_at->format('Y') : ''],
'genre' => ['Podcast'],
'comment' => [$episode->description],
'track_number' => [(string) $episode->number],
'copyright_message' => [$episode->podcast->copyright], 'copyright_message' => [$episode->podcast->copyright],
'publisher' => [ 'publisher' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
$episode->podcast->publisher === null 'encoded_by' => ['Castopod'],
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'encoded_by' => ['Castopod'],
// TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it // TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it
// 'website' => [$podcast_url], // 'website' => [$podcast_url],
...@@ -92,23 +64,21 @@ if (! function_exists('write_audio_file_tags')) { ...@@ -92,23 +64,21 @@ if (! function_exists('write_audio_file_tags')) {
$TagData['attached_picture'][] = [ $TagData['attached_picture'][] = [
// picturetypeid == Cover. More: module.tag.id3v2.php // picturetypeid == Cover. More: module.tag.id3v2.php
'picturetypeid' => 2, 'picturetypeid' => 2,
'data' => $APICdata, 'data' => $APICdata,
'description' => 'cover', 'description' => 'cover',
'mime' => $cover->getMimeType(), 'mime' => $episode->cover->file_mimetype,
]; ];
$tagwriter->tag_data = $TagData; $tagwriter->tag_data = $TagData;
// write tags // write tags
if ($tagwriter->WriteTags()) { if ($tagwriter->WriteTags()) {
echo 'Successfully wrote tags<br>'; // Successfully wrote tags
if ($tagwriter->warnings !== []) { if ($tagwriter->warnings !== []) {
echo 'There were some warnings:<br>' . log_message('warning', 'There were some warnings:' . PHP_EOL . implode(PHP_EOL, $tagwriter->warnings));
implode('<br><br>', $tagwriter->warnings);
} }
} else { } else {
echo 'Failed to write tags!<br>' . log_message('critical', 'Failed to write tags!' . PHP_EOL . implode(PHP_EOL, $tagwriter->errors));
implode('<br><br>', $tagwriter->errors);
} }
} }
} }
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Mimes;
use Config\Services;
if (! function_exists('save_media')) {
/**
* Saves a file to the corresponding podcast folder in `public/media`
*
* @param File|UploadedFile $file
*/
function save_media(File $file, string $folder = '', string $filename = ''): string
{
if (($extension = $file->getExtension()) !== '') {
$filename = $filename . '.' . $extension;
}
$mediaRoot = config('App')
->mediaRoot . '/' . $folder;
if (! file_exists($mediaRoot)) {
mkdir($mediaRoot, 0777, true);
touch($mediaRoot . '/index.html');
}
// move to media folder, overwrite file if already existing
$file->move($mediaRoot . '/', $filename, true);
return $folder . '/' . $filename;
}
}
if (! function_exists('download_file')) {
function download_file(string $fileUrl, string $mimetype = ''): File
{
$client = Services::curlrequest();
$response = $client->get($fileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
]);
// redirect to new file location
$newFileUrl = $fileUrl;
while (
in_array(
$response->getStatusCode(),
[
ResponseInterface::HTTP_MOVED_PERMANENTLY,
ResponseInterface::HTTP_FOUND,
ResponseInterface::HTTP_SEE_OTHER,
ResponseInterface::HTTP_NOT_MODIFIED,
ResponseInterface::HTTP_TEMPORARY_REDIRECT,
ResponseInterface::HTTP_PERMANENT_REDIRECT,
],
true,
)
) {
$newFileUrl = trim($response->getHeader('location')->getValue());
$response = $client->get($newFileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
'http_errors' => false,
]);
}
$fileExtension = pathinfo(parse_url($newFileUrl, PHP_URL_PATH), PATHINFO_EXTENSION);
$extension = $fileExtension === '' ? Mimes::guessExtensionFromType($mimetype) : $fileExtension;
$tmpFilename =
time() .
'_' .
bin2hex(random_bytes(10)) .
'.' .
$extension;
$tmpFilePath = WRITEPATH . 'uploads/' . $tmpFilename;
file_put_contents($tmpFilePath, $response->getBody());
return new File($tmpFilePath);
}
}
if (! function_exists('media_path')) {
/**
* Prefixes the root media path to a given uri
*
* @param string|string[] $uri URI string or array of URI segments
*/
function media_path(string | array $uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
return config('App')->mediaRoot . '/' . $uri;
}
}
if (! function_exists('media_base_url')) {
/**
* Return the media base URL to use in views
*
* @param string|string[] $uri URI string or array of URI segments
*/
function media_base_url(string | array $uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
$appConfig = config('App');
$mediaBaseUrl = $appConfig->mediaBaseURL === '' ? $appConfig->baseURL : $appConfig->mediaBaseURL;
return rtrim($mediaBaseUrl, '/') .
'/' .
$appConfig->mediaRoot .
'/' .
$uri;
}
}
...@@ -2,20 +2,31 @@ ...@@ -2,20 +2,31 @@
declare(strict_types=1); declare(strict_types=1);
use App\Entities\Person;
use App\Entities\Podcast;
use Cocur\Slugify\Slugify;
use Config\Images;
use Modules\Media\Entities\Image;
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
if (! function_exists('get_browser_language')) { if (! function_exists('get_browser_language')) {
/** /**
* Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE` * Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`. Returns Castopod's default
* locale if `HTTP_ACCEPT_LANGUAGE` is null.
* *
* @return string ISO 639-1 language code * @return string ISO 639-1 language code
*/ */
function get_browser_language(string $httpAcceptLanguage): string function get_browser_language(?string $httpAcceptLanguage = null): string
{ {
if ($httpAcceptLanguage === null) {
return config('App')->defaultLocale;
}
$langs = explode(',', $httpAcceptLanguage); $langs = explode(',', $httpAcceptLanguage);
return substr($langs[0], 0, 2); return substr($langs[0], 0, 2);
...@@ -23,112 +34,15 @@ if (! function_exists('get_browser_language')) { ...@@ -23,112 +34,15 @@ if (! function_exists('get_browser_language')) {
} }
if (! function_exists('slugify')) { if (! function_exists('slugify')) {
function slugify(string $text, int $maxLength = 191): string function slugify(string $text, int $maxLength = 128): string
{ {
// trim text to the nearest whole word if too long // trim text to the nearest whole word if too long
if (strlen($text) > $maxLength) { if (strlen($text) > $maxLength) {
$text = substr($text, 0, strrpos(substr($text, 0, $maxLength), ' ')); $text = substr($text, 0, strrpos(substr($text, 0, $maxLength), ' '));
} }
// replace non letter or digits by - $slugify = new Slugify();
$text = preg_replace('~[^\pL\d]+~u', '-', $text); return $slugify->slugify($text);
$unwanted = [
'Š' => 'S',
'š' => 's',
'Đ' => 'Dj',
'đ' => 'dj',
'Ž' => 'Z',
'ž' => 'z',
'Č' => 'C',
'č' => 'c',
'Ć' => 'C',
'ć' => 'c',
'À' => 'A',
'Á' => 'A',
'Â' => 'A',
'Ã' => 'A',
'Ä' => 'A',
'Å' => 'A',
'Æ' => 'AE',
'Ç' => 'C',
'È' => 'E',
'É' => 'E',
'Ê' => 'E',
'Ë' => 'E',
'Ì' => 'I',
'Í' => 'I',
'Î' => 'I',
'Ï' => 'I',
'Ñ' => 'N',
'Ò' => 'O',
'Ó' => 'O',
'Ô' => 'O',
'Õ' => 'O',
'Ö' => 'O',
'Ø' => 'O',
'Œ' => 'OE',
'Ù' => 'U',
'Ú' => 'U',
'Û' => 'U',
'Ü' => 'U',
'Ý' => 'Y',
'Þ' => 'B',
'ß' => 'Ss',
'à' => 'a',
'á' => 'a',
'â' => 'a',
'ã' => 'a',
'ä' => 'a',
'å' => 'a',
'æ' => 'ae',
'ç' => 'c',
'è' => 'e',
'é' => 'e',
'ê' => 'e',
'ë' => 'e',
'ì' => 'i',
'í' => 'i',
'î' => 'i',
'ï' => 'i',
'ð' => 'o',
'ñ' => 'n',
'ò' => 'o',
'ó' => 'o',
'ô' => 'o',
'õ' => 'o',
'ö' => 'o',
'ø' => 'o',
'œ' => 'OE',
'ù' => 'u',
'ú' => 'u',
'û' => 'u',
'ý' => 'y',
'þ' => 'b',
'ÿ' => 'y',
'Ŕ' => 'R',
'ŕ' => 'r',
'/' => '-',
' ' => '-',
];
$text = strtr($text, $unwanted);
// transliterate
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
// remove unwanted characters
$text = preg_replace('~[^\-\w]+~', '', $text);
// trim
$text = trim($text, '-');
// remove duplicate -
$text = preg_replace('~-+~', '-', $text);
// lowercase
$text = strtolower($text);
return $text;
} }
} }
...@@ -136,35 +50,222 @@ if (! function_exists('slugify')) { ...@@ -136,35 +50,222 @@ if (! function_exists('slugify')) {
if (! function_exists('format_duration')) { if (! function_exists('format_duration')) {
/** /**
* Formats duration in seconds to an hh:mm:ss string * Formats duration in seconds to an hh:mm:ss string.
*
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
* *
* @param int $seconds seconds to format * @param int $seconds seconds to format
*/ */
function format_duration(int $seconds, string $separator = ':'): string function format_duration(int $seconds, bool $showLeadingZeros = false): string
{ {
return sprintf( if ($showLeadingZeros) {
'%02d%s%02d%s%02d', return gmdate('H:i:s', $seconds);
floor($seconds / 3600), }
$separator,
($seconds / 60) % 60, if ($seconds < 60) {
$separator, return '0:' . sprintf('%02d', $seconds);
$seconds % 60, }
);
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i:s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('H:i:s', $seconds), '0');
}
return gmdate('H:i:s', $seconds);
} }
} }
if (! function_exists('podcast_uuid')) { if (! function_exists('format_duration_symbol')) {
/** /**
* Generate UUIDv5 for podcast. For more information, see * Formats duration in seconds to an hh(h) mm(min) ss(s) string. Doesn't show leading zeros if any.
* https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid *
* ⚠️ This uses php's gmdate function so any duration > 86000 seconds (24 hours) will not be formatted properly.
*
* @param int $seconds seconds to format
*/ */
function podcast_uuid(string $feedUrl): string function format_duration_symbol(int $seconds): string
{
if ($seconds < 60) {
return $seconds . 's';
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i\m\i\n s\s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('h\h i\m\i\n s\s', $seconds), '0');
}
return gmdate('h\h i\m\i\n s\s', $seconds);
}
}
//--------------------------------------------------------------------
if (! function_exists('generate_random_salt')) {
function generate_random_salt(int $length = 64): string
{ {
$uuid = service('uuid'); $salt = '';
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace while (strlen($salt) < $length) {
return $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $feedUrl) $charNumber = random_int(33, 126);
->toString(); // Exclude " ' \ `
if (! in_array($charNumber, [34, 39, 92, 96], true)) {
$salt .= chr($charNumber);
}
}
return $salt;
} }
} }
//-------------------------------------------------------------------- //--------------------------------------------------------------------
if (! function_exists('file_upload_max_size')) {
/**
* Returns a file size limit in bytes based on the PHP upload_max_filesize and post_max_size Adapted from:
* https://stackoverflow.com/a/25370978
*/
function file_upload_max_size(): float
{
static $max_size = -1;
if ($max_size < 0) {
// Start with post_max_size.
$post_max_size = parse_size((string) ini_get('post_max_size'));
if ($post_max_size > 0) {
$max_size = $post_max_size;
}
// If upload_max_size is less, then reduce. Except if upload_max_size is
// zero, which indicates no limit.
$upload_max = parse_size((string) ini_get('upload_max_filesize'));
if ($upload_max > 0 && $upload_max < $max_size) {
$max_size = $upload_max;
}
}
return $max_size;
}
}
if (! function_exists('parse_size')) {
function parse_size(string $size): float
{
$unit = (string) preg_replace('~[^bkmgtpezy]~i', '', $size); // Remove the non-unit characters from the size.
$size = (float) preg_replace('~[^0-9\.]~', '', $size); // Remove the non-numeric characters from the size.
if ($unit !== '') {
// Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
return round($size * 1024 ** ((float) stripos('bkmgtpezy', $unit[0])));
}
return round($size);
}
}
if (! function_exists('format_bytes')) {
/**
* Adapted from https://stackoverflow.com/a/2510459
*/
function formatBytes(float $bytes, bool $is_binary = false, int $precision = 2): string
{
$units = $is_binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log($is_binary ? 1024 : 1000));
$pow = min($pow, count($units) - 1);
$bytes /= ($is_binary ? 1024 : 1000) ** $pow;
return round($bytes, $precision) . $units[$pow];
}
}
if (! function_exists('get_site_icon_url')) {
function get_site_icon_url(string $size): string
{
if (config('App')->siteIcon['ico'] === service('settings')->get('App.siteIcon')['ico']) {
// return default site icon url
return base_url(service('settings')->get('App.siteIcon')[$size]);
}
return service('file_manager')->getUrl(service('settings')->get('App.siteIcon')[$size]);
}
}
if (! function_exists('get_podcast_banner')) {
function get_podcast_banner_url(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
Images::class,
)->podcastBannerDefaultPaths['default'];
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return base_url(
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null),
);
}
$sizeKey = $size . '_url';
return $podcast->banner->{$sizeKey};
}
}
if (! function_exists('get_podcast_banner_mimetype')) {
function get_podcast_banner_mimetype(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
Images::class,
)->podcastBannerDefaultMimeType;
}
$mimetype = $size . '_mimetype';
return $podcast->banner->{$mimetype};
}
}
if (! function_exists('get_avatar_url')) {
function get_avatar_url(Person $person, string $size): string
{
if (! $person->avatar instanceof Image) {
$defaultAvatarPath = config('Images')
->avatarDefaultPath;
$sizes = config('Images')
->personAvatarSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default avatar url
return base_url(change_file_path($defaultAvatarPath, '_' . $size, $sizeConfig['extension'] ?? null));
}
$sizeKey = $size . '_url';
return $person->avatar->{$sizeKey};
}
}
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
...@@ -16,21 +16,36 @@ if (! function_exists('render_page_links')) { ...@@ -16,21 +16,36 @@ if (! function_exists('render_page_links')) {
* *
* @return string html pages navigation * @return string html pages navigation
*/ */
function render_page_links(string $class = null): string function render_page_links(?string $class = null, ?string $podcastHandle = null): string
{ {
$pages = (new PageModel())->findAll(); $pages = (new PageModel())->findAll();
$links = anchor(route_to('home'), lang('Common.home'), [ $links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 underline hover:no-underline', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
$links .= anchor(route_to('credits'), lang('Person.credits'), [ $links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 underline hover:no-underline', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
$links .= anchor(route_to('map'), lang('Page.map'), [ $links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 underline hover:no-underline', 'class' => 'px-2 py-1 underline hover:no-underline',
]); ]);
foreach ($pages as $page) { foreach ($pages as $page) {
$links .= anchor($page->link, $page->title, [ $links .= anchor($page->link, esc($page->title), [
'class' => 'px-2 underline hover:no-underline', 'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
// if set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [
'class' => 'px-2 py-1 underline hover:no-underline',
'target' => '_blank',
'rel' => 'noopener noreferrer',
]); ]);
} }
......
...@@ -3,16 +3,21 @@ ...@@ -3,16 +3,21 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
use App\Entities\Category; use App\Entities\Category;
use App\Entities\Location;
use App\Entities\Podcast; use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement; use App\Libraries\RssFeed;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use Config\Mimes; use Config\Mimes;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Transcript;
use Modules\Plugins\Core\Plugins;
use Modules\PremiumPodcasts\Entities\Subscription;
if (! function_exists('get_rss_feed')) { if (! function_exists('get_rss_feed')) {
/** /**
...@@ -21,120 +26,185 @@ if (! function_exists('get_rss_feed')) { ...@@ -21,120 +26,185 @@ if (! function_exists('get_rss_feed')) {
* @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded * @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded
* @return string rss feed as xml * @return string rss feed as xml
*/ */
function get_rss_feed(Podcast $podcast, string $serviceSlug = ''): string function get_rss_feed(
{ Podcast $podcast,
$episodes = $podcast->episodes; string $serviceSlug = '',
?Subscription $subscription = null,
?string $token = null,
): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; $episodes = $podcast->episodes;
$podcastNamespace = $rss = new RssFeed();
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md';
$rss = new SimpleRSSElement( $plugins->rssBeforeChannel($podcast);
"<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunesNamespace}' xmlns:podcast='{$podcastNamespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>",
);
$channel = $rss->addChild('channel'); $channel = $rss->addChild('channel');
$atomLink = $channel->addChild('atom:link', null, 'http://www.w3.org/2005/Atom'); $atomLink = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLink->addAttribute('href', $podcast->feed_url); $atomLink->addAttribute('href', $podcast->feed_url);
$atomLink->addAttribute('rel', 'self'); $atomLink->addAttribute('rel', 'self');
$atomLink->addAttribute('type', 'application/rss+xml'); $atomLink->addAttribute('type', 'application/rss+xml');
// websub: add links to hubs defined in config
$websubHubs = config('WebSub')
->hubs;
foreach ($websubHubs as $websubHub) {
$atomLinkHub = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLinkHub->addAttribute('href', $websubHub);
$atomLinkHub->addAttribute('rel', 'hub');
$atomLinkHub->addAttribute('type', 'application/rss+xml');
}
if ($podcast->new_feed_url !== null) { if ($podcast->new_feed_url !== null) {
$channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace); $channel->addChild('new-feed-url', $podcast->new_feed_url, RssFeed::ITUNES_NAMESPACE);
} }
// the last build date corresponds to the creation of the feed.xml cache // the last build date corresponds to the creation of the feed.xml cache
$channel->addChild('lastBuildDate', (new Time('now'))->format(DATE_RFC1123)); $channel->addChild('lastBuildDate', (new Time('now'))->format(DATE_RFC1123));
$channel->addChild('generator', 'Castopod Host - https://castopod.org/'); $channel->addChild('generator', 'Castopod - https://castopod.org/');
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html'); $channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
$channel->addChild('guid', $podcast->guid, $podcastNamespace); if ($podcast->guid === '') {
// FIXME: guid shouldn't be empty here as it should be filled upon Podcast creation
$uuid = service('uuid');
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
$podcast->guid = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $podcast->feed_url)
->toString();
(new PodcastModel())->save($podcast);
}
$channel->addChild('guid', $podcast->guid, RssFeed::PODCAST_NAMESPACE);
$channel->addChild('title', $podcast->title, null, false); $channel->addChild('title', $podcast->title, null, false);
$channel->addChildWithCDATA('description', $podcast->description_html); $channel->addChildWithCDATA('description', $podcast->description_html);
$itunesImage = $channel->addChild('image', null, $itunesNamespace); $itunesImage = $channel->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
// FIXME: This should be downsized to 1400x1400 $itunesImage->addAttribute('href', $podcast->cover->feed_url);
$itunesImage->addAttribute('href', $podcast->image->url);
$channel->addChild('language', $podcast->language_code); $channel->addChild('language', $podcast->language_code);
if ($podcast->location !== null) { if ($podcast->location instanceof Location) {
$locationElement = $channel->addChild( $locationElement = $channel->addChild('location', $podcast->location->name, RssFeed::PODCAST_NAMESPACE);
'location',
htmlspecialchars($podcast->location->name),
$podcastNamespace,
);
if ($podcast->location->geo !== null) { if ($podcast->location->geo !== null) {
$locationElement->addAttribute('geo', $podcast->location->geo); $locationElement->addAttribute('geo', $podcast->location->geo);
} }
if ($podcast->location->osm !== null) { if ($podcast->location->osm !== null) {
$locationElement->addAttribute('osm', $podcast->location->osm); $locationElement->addAttribute('osm', $podcast->location->osm);
} }
} }
if ($podcast->payment_pointer !== null) {
$valueElement = $channel->addChild('value', null, $podcastNamespace);
$valueElement->addAttribute('type', 'webmonetization');
$valueElement->addAttribute('method', '');
$valueElement->addAttribute('suggested', '');
$recipientElement = $valueElement->addChild('valueRecipient', null, $podcastNamespace);
$recipientElement->addAttribute('name', $podcast->owner_name);
$recipientElement->addAttribute('type', 'ILP');
$recipientElement->addAttribute('address', $podcast->payment_pointer);
$recipientElement->addAttribute('split', '100');
}
$channel $channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace) ->addChild('locked', $podcast->is_locked ? 'yes' : 'no', RssFeed::PODCAST_NAMESPACE)
->addAttribute('owner', $podcast->owner_email); ->addAttribute('owner', $podcast->owner_email);
if ($podcast->imported_feed_url !== null) { if ($podcast->imported_feed_url !== null) {
$channel->addChild('previousUrl', $podcast->imported_feed_url, $podcastNamespace); $channel->addChild('previousUrl', $podcast->imported_feed_url, RssFeed::PODCAST_NAMESPACE);
} }
foreach ($podcast->podcasting_platforms as $podcastingPlatform) { foreach ($podcast->podcasting_platforms as $podcastingPlatform) {
$podcastingPlatformElement = $channel->addChild('id', null, $podcastNamespace); $podcastingPlatformElement = $channel->addChild('id', null, RssFeed::PODCAST_NAMESPACE);
$podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug); $podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
if ($podcastingPlatform->link_content !== null) { if ($podcastingPlatform->account_id !== null) {
$podcastingPlatformElement->addAttribute('id', $podcastingPlatform->link_content); $podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id);
} }
if ($podcastingPlatform->link_url !== null) { if ($podcastingPlatform->link_url !== null) {
$podcastingPlatformElement->addAttribute('url', htmlspecialchars($podcastingPlatform->link_url)); $podcastingPlatformElement->addAttribute('url', $podcastingPlatform->link_url);
} }
} }
$castopodSocialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$castopodSocialElement->addAttribute('priority', '1');
$castopodSocialElement->addAttribute('platform', 'castopod');
$castopodSocialElement->addAttribute('protocol', 'activitypub');
$castopodSocialElement->addAttribute('accountId', "@{$podcast->actor->username}@{$podcast->actor->domain}");
$castopodSocialElement->addAttribute('accountUrl', $podcast->link);
foreach ($podcast->social_platforms as $socialPlatform) { foreach ($podcast->social_platforms as $socialPlatform) {
$socialPlatformElement = $channel->addChild( $socialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
'social', $socialElement->addAttribute('priority', '2');
$socialPlatform->link_content, $socialElement->addAttribute('platform', $socialPlatform->slug);
$podcastNamespace,
); // TODO: get activitypub info somewhere else
$socialPlatformElement->addAttribute('platform', $socialPlatform->slug); if (in_array(
$socialPlatform->slug,
['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'],
true,
)) {
$socialElement->addAttribute('protocol', 'activitypub');
} else {
$socialElement->addAttribute('protocol', $socialPlatform->slug);
}
if ($socialPlatform->account_id !== null) {
$socialElement->addAttribute('accountId', esc($socialPlatform->account_id));
}
if ($socialPlatform->link_url !== null) { if ($socialPlatform->link_url !== null) {
$socialPlatformElement->addAttribute('url', htmlspecialchars($socialPlatform->link_url)); $socialElement->addAttribute('accountUrl', esc($socialPlatform->link_url));
}
if ($socialPlatform->slug === 'mastodon') {
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, RssFeed::PODCAST_NAMESPACE);
$socialSignUpelement->addAttribute('priority', '1');
$socialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$socialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
$castopodSocialSignUpelement = $castopodSocialElement->addChild(
'socialSignUp',
null,
RssFeed::PODCAST_NAMESPACE,
);
$castopodSocialSignUpelement->addAttribute('priority', '1');
$castopodSocialSignUpelement->addAttribute(
'homeUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$castopodSocialSignUpelement->addAttribute(
'signUpUrl',
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
} }
} }
foreach ($podcast->funding_platforms as $fundingPlatform) { foreach ($podcast->funding_platforms as $fundingPlatform) {
$fundingPlatformElement = $channel->addChild( $fundingPlatformElement = $channel->addChild(
'funding', 'funding',
$fundingPlatform->link_content, $fundingPlatform->account_id,
$podcastNamespace, RssFeed::PODCAST_NAMESPACE,
); );
$fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug); $fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
if ($fundingPlatform->link_url !== null) { if ($fundingPlatform->link_url !== null) {
$fundingPlatformElement->addAttribute('url', htmlspecialchars($fundingPlatform->link_url)); $fundingPlatformElement->addAttribute('url', $fundingPlatform->link_url);
} }
} }
foreach ($podcast->persons as $person) { foreach ($podcast->persons as $person) {
foreach ($person->roles as $role) { foreach ($person->roles as $role) {
$personElement = $channel->addChild( $personElement = $channel->addChild('person', $person->full_name, RssFeed::PODCAST_NAMESPACE);
'person',
htmlspecialchars($person->full_name),
$podcastNamespace,
);
$personElement->addAttribute('img', $person->image->large_url); $personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) { if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url); $personElement->addAttribute('href', $person->information_url);
...@@ -142,14 +212,12 @@ if (! function_exists('get_rss_feed')) { ...@@ -142,14 +212,12 @@ if (! function_exists('get_rss_feed')) {
$personElement->addAttribute( $personElement->addAttribute(
'role', 'role',
htmlspecialchars( lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
),
); );
$personElement->addAttribute( $personElement->addAttribute(
'group', 'group',
htmlspecialchars(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')), lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en'),
); );
} }
} }
...@@ -163,76 +231,77 @@ if (! function_exists('get_rss_feed')) { ...@@ -163,76 +231,77 @@ if (! function_exists('get_rss_feed')) {
$channel->addChild( $channel->addChild(
'explicit', 'explicit',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false', $podcast->parental_advisory === 'explicit' ? 'true' : 'false',
$itunesNamespace, RssFeed::ITUNES_NAMESPACE,
); );
$channel->addChild( $channel->addChild('author', $podcast->publisher ?: $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
'author',
$podcast->publisher ? $podcast->publisher : $podcast->owner_name,
$itunesNamespace,
);
$channel->addChild('link', $podcast->link); $channel->addChild('link', $podcast->link);
$owner = $channel->addChild('owner', null, $itunesNamespace); $owner = $channel->addChild('owner', null, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('name', $podcast->owner_name, $itunesNamespace);
$owner->addChild('email', $podcast->owner_email, $itunesNamespace); $owner->addChild('name', $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$owner->addChild('email', $podcast->owner_email, RssFeed::ITUNES_NAMESPACE);
$channel->addChild('type', $podcast->type, $itunesNamespace); $channel->addChild('type', $podcast->type, RssFeed::ITUNES_NAMESPACE);
$podcast->copyright && $podcast->copyright &&
$channel->addChild('copyright', $podcast->copyright); $channel->addChild('copyright', $podcast->copyright);
$podcast->is_blocked && if ($podcast->is_blocked || $subscription instanceof Subscription) {
$channel->addChild('block', 'Yes', $itunesNamespace); $channel->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
$podcast->is_completed && }
$channel->addChild('complete', 'Yes', $itunesNamespace);
if ($podcast->is_completed) {
$channel->addChild('complete', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
$image = $channel->addChild('image'); $image = $channel->addChild('image');
$image->addChild('url', $podcast->image->feed_url); $image->addChild('url', $podcast->cover->feed_url);
$image->addChild('title', $podcast->title, null, false); $image->addChild('title', $podcast->title, null, false);
$image->addChild('link', $podcast->link); $image->addChild('link', $podcast->link);
if ($podcast->custom_rss !== null) { // run plugins hook at the end
array_to_rss([ $plugins->rssAfterChannel($podcast, $channel);
'elements' => $podcast->custom_rss,
], $channel);
}
foreach ($episodes as $episode) { foreach ($episodes as $episode) {
if ($episode->is_premium && ! $subscription instanceof Subscription) {
continue;
}
$plugins->rssBeforeItem($episode);
$item = $channel->addChild('item'); $item = $channel->addChild('item');
$item->addChild('title', $episode->title, null, false); $item->addChild('title', $episode->title, null, false);
$enclosure = $item->addChild('enclosure'); $enclosure = $item->addChild('enclosure');
$enclosureParams = implode('&', array_filter([
$episode->is_premium ? 'token=' . $token : null,
$serviceSlug !== '' ? '_from=' . urlencode($serviceSlug) : null,
]));
$enclosure->addAttribute( $enclosure->addAttribute(
'url', 'url',
$episode->audio_file_analytics_url . $episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
($serviceSlug === ''
? ''
: '?_from=' . urlencode($serviceSlug)),
); );
$enclosure->addAttribute('length', (string) $episode->audio_file_size); $enclosure->addAttribute('length', (string) $episode->audio->file_size);
$enclosure->addAttribute('type', $episode->audio_file_mimetype); $enclosure->addAttribute('type', $episode->audio->file_mimetype);
$item->addChild('guid', $episode->guid); $item->addChild('guid', $episode->guid);
$item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123)); $item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
if ($episode->location !== null) { if ($episode->location instanceof Location) {
$locationElement = $item->addChild( $locationElement = $item->addChild('location', $episode->location->name, RssFeed::PODCAST_NAMESPACE);
'location',
htmlspecialchars($episode->location->name),
$podcastNamespace,
);
if ($episode->location->geo !== null) { if ($episode->location->geo !== null) {
$locationElement->addAttribute('geo', $episode->location->geo); $locationElement->addAttribute('geo', $episode->location->geo);
} }
if ($episode->location->osm !== null) { if ($episode->location->osm !== null) {
$locationElement->addAttribute('osm', $episode->location->osm); $locationElement->addAttribute('osm', $episode->location->osm);
} }
} }
$item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug));
$item->addChild('duration', (string) $episode->audio_file_duration, $itunesNamespace); $item->addChildWithCDATA('description', $episode->description_html);
$item->addChild('duration', (string) round($episode->audio->duration), RssFeed::ITUNES_NAMESPACE);
$item->addChild('link', $episode->link); $item->addChild('link', $episode->link);
$episodeItunesImage = $item->addChild('image', null, $itunesNamespace); $episodeItunesImage = $item->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$episodeItunesImage->addAttribute('href', $episode->image->feed_url); $episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
$episode->parental_advisory && $episode->parental_advisory &&
$item->addChild( $item->addChild(
...@@ -240,65 +309,97 @@ if (! function_exists('get_rss_feed')) { ...@@ -240,65 +309,97 @@ if (! function_exists('get_rss_feed')) {
$episode->parental_advisory === 'explicit' $episode->parental_advisory === 'explicit'
? 'true' ? 'true'
: 'false', : 'false',
$itunesNamespace, RssFeed::ITUNES_NAMESPACE,
); );
$episode->number && $episode->number &&
$item->addChild('episode', (string) $episode->number, $itunesNamespace); $item->addChild('episode', (string) $episode->number, RssFeed::ITUNES_NAMESPACE);
$episode->season_number && $episode->season_number &&
$item->addChild('season', (string) $episode->season_number, $itunesNamespace); $item->addChild('season', (string) $episode->season_number, RssFeed::ITUNES_NAMESPACE);
$item->addChild('episodeType', $episode->type, $itunesNamespace); $item->addChild('episodeType', $episode->type, RssFeed::ITUNES_NAMESPACE);
// If episode is of type trailer, add podcast:trailer tag on channel level
if ($episode->type === 'trailer') {
$trailer = $channel->addChild('trailer', $episode->title, RssFeed::PODCAST_NAMESPACE);
$trailer->addAttribute('pubdate', $episode->published_at->format(DATE_RFC2822));
$trailer->addAttribute(
'url',
$episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$trailer->addAttribute('length', (string) $episode->audio->file_size);
$trailer->addAttribute('type', $episode->audio->file_mimetype);
if ($episode->season_number !== null) {
$trailer->addAttribute('season', (string) $episode->season_number);
}
}
// add link to episode comments as podcast-activity format // add link to episode comments as podcast-activity format
$comments = $item->addChild('comments', null, $podcastNamespace); $comments = $item->addChild('comments', null, RssFeed::PODCAST_NAMESPACE);
$comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug)); $comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
$comments->addAttribute('contentType', 'application/podcast-activity+json'); $comments->addAttribute('contentType', 'application/podcast-activity+json');
if ($episode->transcript_file_url) { if ($episode->getPosts()) {
$transcriptElement = $item->addChild('transcript', null, $podcastNamespace); $socialInteractUri = $episode->getPosts()[0]
$transcriptElement->addAttribute('url', $episode->transcript_file_url); ->uri;
$socialInteractElement = $item->addChild('socialInteract', null, RssFeed::PODCAST_NAMESPACE);
$socialInteractElement->addAttribute('uri', $socialInteractUri);
$socialInteractElement->addAttribute('priority', '1');
$socialInteractElement->addAttribute('platform', 'castopod');
$socialInteractElement->addAttribute('protocol', 'activitypub');
$socialInteractElement->addAttribute(
'accountId',
"@{$podcast->actor->username}@{$podcast->actor->domain}",
);
$socialInteractElement->addAttribute(
'pubDate',
$episode->getPosts()[0]
->published_at->format(DateTime::ISO8601),
);
}
if ($episode->transcript instanceof Transcript) {
$transcriptElement = $item->addChild('transcript', null, RssFeed::PODCAST_NAMESPACE);
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
$transcriptElement->addAttribute( $transcriptElement->addAttribute(
'type', 'type',
Mimes::guessTypeFromExtension( Mimes::guessTypeFromExtension(
pathinfo($episode->transcript_file_url, PATHINFO_EXTENSION) pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION),
) ?? 'text/html', ) ?? 'text/html',
); );
// Castopod only allows for captions (SubRip files)
$transcriptElement->addAttribute('rel', 'captions');
// TODO: allow for multiple languages
$transcriptElement->addAttribute('language', $podcast->language_code); $transcriptElement->addAttribute('language', $podcast->language_code);
} }
if ($episode->chapters_file_url) { if ($episode->getChapters() instanceof Chapters) {
$chaptersElement = $item->addChild('chapters', null, $podcastNamespace); $chaptersElement = $item->addChild('chapters', null, RssFeed::PODCAST_NAMESPACE);
$chaptersElement->addAttribute('url', $episode->chapters_file_url); $chaptersElement->addAttribute('url', $episode->chapters->file_url);
$chaptersElement->addAttribute('type', 'application/json+chapters'); $chaptersElement->addAttribute('type', 'application/json+chapters');
} }
foreach ($episode->soundbites as $soundbite) { foreach ($episode->soundbites as $soundbite) {
$soundbiteElement = $item->addChild('soundbite', $soundbite->label, $podcastNamespace); // TODO: differentiate video from soundbites?
$soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time); $soundbiteElement = $item->addChild('soundbite', $soundbite->title, RssFeed::PODCAST_NAMESPACE);
$soundbiteElement->addAttribute('duration', (string) $soundbite->duration); $soundbiteElement->addAttribute('startTime', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3));
} }
foreach ($episode->persons as $person) { foreach ($episode->persons as $person) {
foreach ($person->roles as $role) { foreach ($person->roles as $role) {
$personElement = $item->addChild( $personElement = $item->addChild('person', esc($person->full_name), RssFeed::PODCAST_NAMESPACE);
'person',
htmlspecialchars($person->full_name),
$podcastNamespace,
);
$personElement->addAttribute( $personElement->addAttribute(
'role', 'role',
htmlspecialchars( esc(lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en')),
lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),
),
); );
$personElement->addAttribute( $personElement->addAttribute(
'group', 'group',
htmlspecialchars(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')), esc(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
); );
$personElement->addAttribute('img', $person->image->large_url); $personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) { if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url); $personElement->addAttribute('href', $person->information_url);
...@@ -306,14 +407,11 @@ if (! function_exists('get_rss_feed')) { ...@@ -306,14 +407,11 @@ if (! function_exists('get_rss_feed')) {
} }
} }
$episode->is_blocked && if ($episode->is_blocked) {
$item->addChild('block', 'Yes', $itunesNamespace); $item->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
if ($episode->custom_rss !== null) {
array_to_rss([
'elements' => $episode->custom_rss,
], $item);
} }
$plugins->rssAfterItem($episode, $item);
} }
return $rss->asXML(); return $rss->asXML();
...@@ -324,92 +422,22 @@ if (! function_exists('add_category_tag')) { ...@@ -324,92 +422,22 @@ if (! function_exists('add_category_tag')) {
/** /**
* Adds <itunes:category> and <category> tags to node for a given category * Adds <itunes:category> and <category> tags to node for a given category
*/ */
function add_category_tag(SimpleXMLElement $node, Category $category): void function add_category_tag(RssFeed $node, Category $category): void
{ {
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; $itunesCategory = $node->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategory = $node->addChild('category', '', $itunesNamespace);
$itunesCategory->addAttribute( $itunesCategory->addAttribute(
'text', 'text',
$category->parent !== null $category->parent instanceof Category
? $category->parent->apple_category ? $category->parent->apple_category
: $category->apple_category, : $category->apple_category,
); );
if ($category->parent !== null) { if ($category->parent instanceof Category) {
$itunesCategoryChild = $itunesCategory->addChild('category', '', $itunesNamespace); $itunesCategoryChild = $itunesCategory->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategoryChild->addAttribute('text', $category->apple_category); $itunesCategoryChild->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category); $node->addChild('category', $category->parent->apple_category);
} }
$node->addChild('category', $category->apple_category);
}
}
if (! function_exists('rss_to_array')) { $node->addChild('category', $category->apple_category);
/**
* Converts XML to array
*
* FIXME: param should be SimpleRSSElement
*
* @return array<string, mixed>
*/
function rss_to_array(SimpleXMLElement $rssNode): array
{
$nameSpaces = [
'',
'http://www.itunes.com/dtds/podcast-1.0.dtd',
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
];
$arrayNode = [];
$arrayNode['name'] = $rssNode->getName();
$arrayNode['namespace'] = $rssNode->getNamespaces(false);
foreach ($rssNode->attributes() as $key => $value) {
$arrayNode['attributes'][$key] = (string) $value;
}
$textcontent = trim((string) $rssNode);
if (strlen($textcontent) > 0) {
$arrayNode['content'] = $textcontent;
}
foreach ($nameSpaces as $currentNameSpace) {
foreach ($rssNode->children($currentNameSpace) as $childXmlNode) {
$arrayNode['elements'][] = rss_to_array($childXmlNode);
}
}
return $arrayNode;
}
}
if (! function_exists('array_to_rss')) {
/**
* Inserts array (converted to XML node) in XML node
*
* @param array<string, mixed> $arrayNode
* @param SimpleRSSElement $xmlNode The XML parent node where this arrayNode should be attached
*/
function array_to_rss(array $arrayNode, SimpleRSSElement &$xmlNode): SimpleRSSElement
{
if (array_key_exists('elements', $arrayNode)) {
foreach ($arrayNode['elements'] as $childArrayNode) {
$childXmlNode = $xmlNode->addChild(
$childArrayNode['name'],
$childArrayNode['content'] ?? null,
$childArrayNode['namespace'] === []
? null
: current($childArrayNode['namespace'])
);
if (array_key_exists('attributes', $childArrayNode)) {
foreach (
$childArrayNode['attributes']
as $attributeKey => $attributeValue
) {
$childXmlNode->addAttribute($attributeKey, $attributeValue);
}
}
array_to_rss($childArrayNode, $childXmlNode);
}
}
return $xmlNode;
} }
} }
<?php
declare(strict_types=1);
use App\Entities\Actor;
use App\Entities\Episode;
use App\Entities\EpisodeComment;
use App\Entities\Page;
use App\Entities\Podcast;
use App\Entities\Post;
use App\Libraries\HtmlHead;
use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing;
use Modules\Fediverse\Entities\PreviewCard;
/**
* @copyright 2024 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('set_podcast_metatags')) {
function set_podcast_metatags(Podcast $podcast, string $page): void
{
$category = '';
if ($podcast->category->parent_id !== null) {
$category .= $podcast->category->parent->apple_category . ' › ';
}
$category .= $podcast->category->apple_category;
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
]),
);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(sprintf('%s (@%s) • %s', $podcast->title, $podcast->handle, lang('Podcast.' . $page)))
->description(esc($podcast->description))
->image((string) $podcast->cover->og_url)
->canonical((string) current_url())
->og('image:width', (string) config('Images')->podcastCoverSizes['og']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $podcast->language_code)
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)),
])->appendRawContent('<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title,
) . '" href="' . $podcast->feed_url . '" />' . $schema);
}
}
if (! function_exists('set_episode_metatags')) {
function set_episode_metatags(Episode $episode): void
{
$schema = new Schema(
new Thing('PodcastEpisode', [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [
'contentUrl' => $episode->audio_url,
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
]),
]),
);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title($episode->title)
->description(esc($episode->description))
->image((string) $episode->cover->og_url, 'player')
->canonical($episode->link)
->og('site_name', esc(service('settings')->get('App.siteName')))
->og('image:width', (string) config('Images')->podcastCoverSizes['og']['width'])
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_opengraph_url)
->og('audio:type', $episode->audio->file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ATOM))
->meta('article:modified_time', $episode->updated_at->format(DATE_ATOM))
->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', esc($episode->podcast->owner_name))
->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height)
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => $episode->link,
])
->appendRawContent('<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc(
$episode->title,
) . ' oEmbed json" />' . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . $schema);
}
}
if (! function_exists('set_post_metatags')) {
function set_post_metatags(Post $post): void
{
$socialMediaPosting = new Thing('SocialMediaPosting', [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
]),
'text' => $post->message,
]);
if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing('Person', [
'name' => $post->episode->podcast->owner_name,
]),
]));
} elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'name' => $post->preview_card->author_name,
]),
]));
}
$schema = new Schema($socialMediaPosting);
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('post', esc($post->actor->username), $post->id),
])->appendRawContent((string) $schema);
}
}
if (! function_exists('set_episode_comment_metatags')) {
function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{
$schema = new Schema(new Thing('SocialMediaPosting', [
'@id' => url_to(
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
]),
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
]));
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title,
]))
->description($episodeComment->message)
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to(
'episode-comment',
$episodeComment->actor->username,
$episodeComment->episode->slug,
$episodeComment->id,
),
])->appendRawContent((string) $schema);
}
}
if (! function_exists('set_follow_metatags')) {
function set_follow_metatags(Actor $actor): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]))
->description($actor->summary)
->image($actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_remote_actions_metatags')) {
function set_remote_actions_metatags(Post $post, string $action): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
],))
->description($post->message)
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_home_metatags')) {
function set_home_metatags(): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(service('settings')->get('App.siteName'))
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('set_page_metatags')) {
function set_page_metatags(Page $page): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings',
)->get('App.siteName'),
)
->description(esc(service('settings')->get('App.siteDescription')))
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
}
}
if (! function_exists('iso8601_duration')) {
// From https://stackoverflow.com/a/40761380
function iso8601_duration(float $seconds): string
{
$days = floor($seconds / 86400);
$seconds = (int) $seconds % 86400;
$hours = floor($seconds / 3600);
$seconds %= 3600;
$minutes = floor($seconds / 60);
$seconds %= 60;
return sprintf('P%dDT%dH%dM%dS', $days, $hours, $minutes, $seconds);
}
}
...@@ -3,44 +3,26 @@ ...@@ -3,44 +3,26 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
if (! function_exists('icon')) {
/**
* Returns the inline svg icon
*
* @param string $name name of the icon file without the .svg extension
* @param string $class to be added to the svg string
* @return string svg contents
*/
function icon(string $name, string $class = ''): string
{
$svgContents = file_get_contents('assets/icons/' . $name . '.svg');
if ($class !== '') {
$svgContents = str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
}
return $svgContents;
}
}
if (! function_exists('svg')) { if (! function_exists('svg')) {
/** /**
* Returns the inline svg image * Returns the inline svg image
* *
* @param string $name name of the image file without the .svg extension * @param string $name name of the image file without the .svg extension
* @param string $class to be added to the svg string * @param string|null $class to be added to the svg string
* @return string svg contents * @return string svg contents
*/ */
function svg(string $name, ?string $class = null): string function svg(string $name, ?string $class = null): string
{ {
$svgContents = file_get_contents('assets/images/' . $name . '.svg'); $svgContents = file_get_contents('assets/images/' . $name . '.svg');
if ($class) { if ($class) {
$svgContents = str_replace('<svg', '<svg class="' . $class . '"', $svgContents); return str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
} }
return $svgContents; return $svgContents;
} }
} }
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* @copyright 2020 Podlibre * @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/ * @link https://castopod.org/
*/ */
...@@ -16,13 +16,14 @@ if (! function_exists('host_url')) { ...@@ -16,13 +16,14 @@ if (! function_exists('host_url')) {
*/ */
function host_url(): ?string function host_url(): ?string
{ {
if (isset($_SERVER['HTTP_HOST'])) { $superglobals = service('superglobals');
if ($superglobals->server('HTTP_HOST') !== null) {
$protocol = $protocol =
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($superglobals->server('HTTPS') !== null && $superglobals->server('HTTPS') !== 'off') ||
$_SERVER['SERVER_PORT'] === 443 (int) $superglobals->server('SERVER_PORT') === 443
? 'https://' ? 'https://'
: 'http://'; : 'http://';
return $protocol . $_SERVER['HTTP_HOST'] . '/'; return $protocol . $superglobals->server('HTTP_HOST') . '/';
} }
return null; return null;
...@@ -31,6 +32,24 @@ if (! function_exists('host_url')) { ...@@ -31,6 +32,24 @@ if (! function_exists('host_url')) {
//-------------------------------------------------------------------- //--------------------------------------------------------------------
/**
* Return the host URL to use in views
*/
if (! function_exists('current_domain')) {
/**
* Returns instance's domain name
*/
function current_domain(): string
{
/** @var URI $uri */
$uri = current_url(true);
return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : '');
}
}
//--------------------------------------------------------------------
if (! function_exists('extract_params_from_episode_uri')) { if (! function_exists('extract_params_from_episode_uri')) {
/** /**
* Returns podcast name and episode slug from episode string * Returns podcast name and episode slug from episode string
...@@ -40,7 +59,7 @@ if (! function_exists('extract_params_from_episode_uri')) { ...@@ -40,7 +59,7 @@ if (! function_exists('extract_params_from_episode_uri')) {
function extract_params_from_episode_uri(URI $episodeUri): ?array function extract_params_from_episode_uri(URI $episodeUri): ?array
{ {
preg_match( preg_match(
'~@(?P<podcastHandle>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,191})~', '~@(?P<podcastHandle>[a-zA-Z0-9\_]{1,32})\/episodes\/(?P<episodeSlug>[a-zA-Z0-9\-]{1,128})~',
$episodeUri->getPath(), $episodeUri->getPath(),
$matches, $matches,
); );
...@@ -58,7 +77,7 @@ if (! function_exists('extract_params_from_episode_uri')) { ...@@ -58,7 +77,7 @@ if (! function_exists('extract_params_from_episode_uri')) {
return [ return [
'podcastHandle' => $matches['podcastHandle'], 'podcastHandle' => $matches['podcastHandle'],
'episodeSlug' => $matches['episodeSlug'], 'episodeSlug' => $matches['episodeSlug'],
]; ];
} }
} }
+ en/***
+ fr/***
+ pl/***
+ de/***
+ pt-br/***
+ nn-no/***
+ es/***
+ zh-hans/***
+ ca/***
+ br/***
+ sr-latn/***
- **