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 1536 additions and 1934 deletions
<?php <?php
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\Entities\Location; use App\Entities\Clip\Soundbite;
use App\Libraries\SimpleRSSElement; use App\Models\ClipModel;
use App\Models\EpisodeCommentModel;
use App\Models\EpisodeModel;
use App\Models\PersonModel;
use App\Models\PodcastModel; use App\Models\PodcastModel;
use App\Models\SoundbiteModel; use App\Models\PostModel;
use App\Models\EpisodePersonModel;
use App\Models\NoteModel;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File; use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile; use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter; 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\Media\Entities\Audio;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Image;
use Modules\Media\Entities\Transcript;
use Modules\Media\Models\MediaModel;
use Override;
use RuntimeException; use RuntimeException;
/** /**
* @property int $id * @property int $id
* @property int $podcast_id * @property int $podcast_id
* @property Podcast $podcast * @property Podcast $podcast
* @property ?string $preview_id
* @property string $preview_link
* @property string $link * @property string $link
* @property string $guid * @property string $guid
* @property string $slug * @property string $slug
* @property string $title * @property string $title
* @property File $audio_file * @property int $audio_id
* @property string $audio_file_url * @property ?Audio $audio
* @property string $audio_file_analytics_url * @property string $audio_url
* @property string $audio_file_web_url * @property string $audio_web_url
* @property string $audio_file_opengraph_url * @property string $audio_opengraph_url
* @property string $audio_file_path * @property string|null $description Holds text only description, striped of any markdown or html special characters
* @property double $audio_file_duration
* @property string $audio_file_mimetype
* @property int $audio_file_size
* @property int $audio_file_header_size
* @property string $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|null $image_path * @property ?Image $cover
* @property string|null $image_mimetype * @property int|null $transcript_id
* @property File|null $transcript_file * @property Transcript|null $transcript
* @property string|null $transcript_file_url * @property string|null $transcript_remote_url
* @property string|null $transcript_file_path * @property int|null $chapters_id
* @property string|null $transcript_file_remote_url * @property Chapters|null $chapters
* @property File|null $chapters_file * @property string|null $chapters_remote_url
* @property string|null $chapters_file_url
* @property string|null $chapters_file_path
* @property string|null $chapters_file_remote_url
* @property string|null $parental_advisory * @property string|null $parental_advisory
* @property int $number * @property int $number
* @property int $season_number * @property int $season_number
* @property string $type * @property string $type
* @property bool $is_blocked * @property bool $is_blocked
* @property Location $location * @property Location|null $location
* @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_id * @property string|null $location_osm
* @property array|null $custom_rss * @property bool $is_published_on_hubs
* @property string $custom_rss_string * @property int $downloads_count
* @property int $favourites_total * @property int $posts_count
* @property int $reblogs_total * @property int $comments_count
* @property int $notes_total * @property EpisodeComment[]|null $comments
* @property bool $is_premium
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
* @property string $publication_status; * @property string $publication_status
* @property Time|null $published_at; * @property Time|null $published_at
* @property Time $created_at; * @property Time $created_at
* @property Time $updated_at; * @property Time $updated_at
* @property Time|null $deleted_at;
* *
* @property EpisodePerson[] $persons; * @property Person[] $persons
* @property Soundbite[] $soundbites; * @property Soundbite[] $soundbites
* @property string $embeddable_player_url; * @property string $embed_url
*/ */
class Episode extends Entity class Episode extends Entity
{ {
/** public string $link = '';
* @var Podcast
*/
protected $podcast;
/** public string $audio_url = '';
* @var string
*/
protected $link;
/** public string $audio_web_url = '';
* @var File
*/
protected $audio_file;
/** public string $audio_opengraph_url = '';
* @var string
*/
protected $audio_file_url;
/** protected Podcast $podcast;
* @var string
*/
protected $audio_file_analytics_url;
/** protected ?Audio $audio = null;
* @var string
*/
protected $audio_file_web_url;
/** protected string $embed_url = '';
* @var string
*/
protected $audio_file_opengraph_url;
/** protected ?Image $cover = null;
* @var string
*/
protected $embeddable_player_url;
/** protected ?string $description = null;
* @var Image
*/
protected $image;
/** protected ?Transcript $transcript = null;
* @var string
*/
protected $description;
/** protected ?Chapters $chapters = null;
* @var File
*/
protected $transcript_file;
/** /**
* @var File * @var Person[]|null
*/ */
protected $chapters_file; protected ?array $persons = null;
/** /**
* @var EpisodePerson[] * @var Soundbite[]|null
*/ */
protected $persons; protected ?array $soundbites = null;
/** /**
* @var Soundbite[] * @var Post[]|null
*/ */
protected $soundbites; protected ?array $posts = null;
/** /**
* @var Note[] * @var EpisodeComment[]|null
*/ */
protected $notes; protected ?array $comments = null;
/** protected ?Location $location = null;
* @var Location|null
*/
protected $location;
/** protected ?string $publication_status = null;
* @var string
*/
protected $custom_rss_string;
/** /**
* @var string * @var array<int, string>
* @phpstan-var list<string>
*/ */
protected $publication_status; protected $dates = ['published_at', 'created_at', 'updated_at'];
/**
* @var string[]
*/
protected $dates = [
'published_at',
'created_at',
'updated_at',
'deleted_at',
];
/** /**
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'id' => 'integer', 'id' => 'integer',
'podcast_id' => 'integer', 'podcast_id' => 'integer',
'guid' => 'string', 'preview_id' => '?string',
'slug' => 'string', 'guid' => 'string',
'title' => 'string', 'slug' => 'string',
'audio_file_path' => 'string', 'title' => 'string',
'audio_file_duration' => 'double', 'audio_id' => 'integer',
'audio_file_mimetype' => 'string', 'description_markdown' => 'string',
'audio_file_size' => 'integer', 'description_html' => 'string',
'audio_file_header_size' => 'integer', 'cover_id' => '?integer',
'description_markdown' => 'string', 'transcript_id' => '?integer',
'description_html' => 'string', 'transcript_remote_url' => '?string',
'image_path' => '?string', 'chapters_id' => '?integer',
'image_mimetype' => '?string', 'chapters_remote_url' => '?string',
'transcript_file_path' => '?string', 'parental_advisory' => '?string',
'transcript_file_remote_url' => '?string', 'number' => '?integer',
'chapters_file_path' => '?string', 'season_number' => '?integer',
'chapters_file_remote_url' => '?string', 'type' => 'string',
'parental_advisory' => '?string', 'is_blocked' => 'boolean',
'number' => '?integer', 'location_name' => '?string',
'season_number' => '?integer', 'location_geo' => '?string',
'type' => 'string', 'location_osm' => '?string',
'is_blocked' => 'boolean', 'is_published_on_hubs' => 'boolean',
'location_name' => '?string', 'downloads_count' => 'integer',
'location_geo' => '?string', 'posts_count' => 'integer',
'location_osm_id' => '?string', 'comments_count' => 'integer',
'custom_rss' => '?json-array', 'is_premium' => 'boolean',
'favourites_total' => 'integer', 'created_by' => 'integer',
'reblogs_total' => 'integer', 'updated_by' => 'integer',
'notes_total' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
]; ];
/** /**
* Saves an episode image * @param array<string, mixed> $data
*
* @param Image|null $image
*/ */
public function setImage($image = null): self #[Override]
public function injectRawData(array $data): static
{ {
if ($image === null) { parent::injectRawData($data);
return $this;
} $this->link = url_to('episode', esc($this->getPodcast()->handle, 'url'), esc($this->attributes['slug'], 'url'));
// Save image $this->audio_url = url_to(
$image->saveImage( 'episode-audio',
'podcasts/' . $this->getPodcast()->name, $this->getPodcast()
$this->attributes['slug'], ->handle,
$this->slug,
$this->getAudio()
->file_extension,
); );
$this->attributes['image_mimetype'] = $image->mimetype; $this->audio_opengraph_url = $this->audio_url . '?_from=-+Open+Graph+-';
$this->attributes['image_path'] = $image->path; $this->audio_web_url = $this->audio_url . '?_from=-+Website+-';
return $this; return $this;
} }
public function getImage(): Image public function setCover(UploadedFile | File|null $file = null): self
{ {
if ($imagePath = $this->attributes['image_path']) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return new Image( return $this;
null,
$imagePath,
$this->attributes['image_mimetype'],
);
} }
return $this->podcast->image; if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
} $this->getCover()
->setFile($file);
/** $this->getCover()
* Saves an audio file ->updated_by = $this->attributes['updated_by'];
* (new MediaModel('image'))->updateMedia($this->getCover());
* @param UploadedFile|File $audioFile } else {
*/ $cover = new Image([
public function setAudioFile($audioFile) 'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
{ 'sizes' => config('Images')
helper(['media', 'id3']); ->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'],
$audio_metadata = get_file_tags($audioFile); 'updated_by' => $this->attributes['updated_by'],
]);
$cover->setFile($file);
$this->attributes['audio_file_path'] = save_media( $this->attributes['cover_id'] = (new MediaModel('image'))->saveMedia($cover);
$audioFile, }
'podcasts/' . $this->getPodcast()->name,
$this->attributes['slug'],
);
$this->attributes['audio_file_duration'] =
$audio_metadata['playtime_seconds'];
$this->attributes['audio_file_mimetype'] = $audio_metadata['mime_type'];
$this->attributes['audio_file_size'] = $audio_metadata['filesize'];
$this->attributes['audio_file_header_size'] =
$audio_metadata['avdataoffset'];
return $this; return $this;
} }
/** public function getCover(): Image
* Saves an episode transcript file
*
* @param UploadedFile|File $transcriptFile
*/
public function setTranscriptFile($transcriptFile)
{ {
helper('media'); if ($this->cover instanceof Image) {
return $this->cover;
}
$this->attributes['transcript_file_path'] = save_media( if ($this->cover_id === null) {
$transcriptFile, $this->cover = $this->getPodcast()
$this->getPodcast()->name, ->getCover();
$this->attributes['slug'] . '-transcript',
);
return $this; return $this->cover;
}
$this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
return $this->cover;
} }
/** public function setAudio(UploadedFile | File|null $file = null): self
* Saves an episode chapters file
*
* @param UploadedFile|File $chaptersFile
*/
public function setChaptersFile($chaptersFile)
{ {
helper('media'); if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
$this->attributes['chapters_file_path'] = save_media( if ($this->audio_id !== 0) {
$chaptersFile, $this->getAudio()
$this->getPodcast()->name, ->setFile($file);
$this->attributes['slug'] . '-chapters', $this->getAudio()
); ->updated_by = $this->attributes['updated_by'];
(new MediaModel('audio'))->updateMedia($this->getAudio());
} else {
$audio = new Audio([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $file->getRandomName(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$audio->setFile($file);
$this->attributes['audio_id'] = (new MediaModel())->saveMedia($audio);
}
return $this; return $this;
} }
public function getAudioFile(): File public function getAudio(): Audio
{ {
helper('media'); if (! $this->audio instanceof Audio) {
$this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
}
return new File(media_path($this->audio_file_path)); return $this->audio;
} }
public function getTranscriptFile(): ?File public function setTranscript(UploadedFile | File|null $file = null): self
{ {
if ($this->attributes['transcript_file_path']) { if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
helper('media'); return $this;
}
return new File( if ($this->getTranscript() instanceof Transcript) {
media_path($this->attributes['transcript_file_path']), $this->getTranscript()
); ->setFile($file);
$this->getTranscript()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('transcript'))->updateMedia($this->getTranscript());
} else {
$transcript = new Transcript([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$transcript->setFile($file);
$this->attributes['transcript_id'] = (new MediaModel('transcript'))->saveMedia($transcript);
} }
return null; return $this;
} }
public function getChaptersFile(): ?File public function getTranscript(): ?Transcript
{ {
if ($this->attributes['chapters_file_path']) { if ($this->transcript_id !== null && ! $this->transcript instanceof Transcript) {
helper('media'); $this->transcript = (new MediaModel('transcript'))->getMediaById($this->transcript_id);
return new File(
media_path($this->attributes['chapters_file_path']),
);
} }
return null; return $this->transcript;
} }
public function getAudioFileUrl(): string public function setChapters(UploadedFile | File|null $file = null): self
{ {
helper('media'); if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
return media_base_url($this->audio_file_path); if ($this->getChapters() instanceof Chapters) {
} $this->getChapters()
->setFile($file);
$this->getChapters()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('chapters'))->updateMedia($this->getChapters());
} else {
$chapters = new Chapters([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$chapters->setFile($file);
public function getAudioFileAnalyticsUrl(): string $this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters);
{ }
helper('analytics');
return generate_episode_analytics_url(
$this->podcast_id,
$this->id,
$this->audio_file_path,
$this->audio_file_duration,
$this->audio_file_size,
$this->audio_file_header_size,
$this->published_at,
);
}
public function getAudioFileWebUrl(): string return $this;
{
return $this->getAudioFileAnalyticsUrl() . '?_from=-+Website+-';
} }
public function getAudioFileOpengraphUrl(): string public function getChapters(): ?Chapters
{ {
return $this->getAudioFileAnalyticsUrl() . '?_from=-+Open+Graph+-'; if ($this->chapters_id !== null && ! $this->chapters instanceof Chapters) {
$this->chapters = (new MediaModel('chapters'))->getMediaById($this->chapters_id);
}
return $this->chapters;
} }
/** /**
* Gets transcript url from transcript file uri if it exists * Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null.
* or returns the transcript_file_remote_url which can be null.
*/ */
public function getTranscriptFileUrl(): ?string public function getTranscriptUrl(): ?string
{ {
if ($this->attributes['transcript_file_path']) { if ($this->transcript instanceof Transcript) {
return media_base_url($this->attributes['transcript_file_path']); return $this->transcript->file_url;
} else {
return $this->attributes['transcript_file_remote_url'];
} }
return $this->transcript_remote_url;
} }
/** /**
* Gets chapters file url from chapters file uri if it exists * Gets chapters file url from chapters file uri if it exists or returns the chapters_remote_url which can be null.
* or returns the chapters_file_remote_url which can be null.
*/ */
public function getChaptersFileUrl(): ?string public function getChaptersFileUrl(): ?string
{ {
if ($this->chapters_file_path) { if ($this->chapters instanceof Chapters) {
return media_base_url($this->chapters_file_path); return $this->chapters->file_url;
} }
return $this->chapters_file_remote_url; return $this->chapters_remote_url;
} }
/** /**
* Returns the episode's persons * Returns the episode's persons
* *
* @return EpisodePerson[] * @return Person[]
*/ */
public function getPersons(): array public function getPersons(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Episode must be created before getting persons.');
'Episode must be created before getting persons.',
);
} }
if (empty($this->persons)) { if ($this->persons === null) {
$this->persons = (new EpisodePersonModel())->getEpisodePersons( $this->persons = (new PersonModel())->getEpisodePersons($this->podcast_id, $this->id);
$this->podcast_id,
$this->id,
);
} }
return $this->persons; return $this->persons;
} }
/** /**
* Returns the episode’s soundbites * Returns the episode’s clips
* *
* @return Soundbite[] * @return Soundbite[]
*/ */
public function getSoundbites(): array public function getSoundbites(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Episode must be created before getting soundbites.');
'Episode must be created before getting soundbites.',
);
} }
if (empty($this->soundbites)) { if ($this->soundbites === null) {
$this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites( $this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
$this->getPodcast()->id,
$this->id,
);
} }
return $this->soundbites; return $this->soundbites;
} }
/** /**
* @return Note[] * @return Post[]
*/ */
public function getNotes(): array public function getPosts(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Episode must be created before getting posts.');
'Episode must be created before getting soundbites.',
);
} }
if (empty($this->notes)) { if ($this->posts === null) {
$this->notes = (new NoteModel())->getEpisodeNotes($this->id); $this->posts = (new PostModel())->getEpisodePosts($this->id);
} }
return $this->notes; return $this->posts;
} }
public function getLink(): string /**
* @return EpisodeComment[]
*/
public function getComments(): array
{ {
return base_url( if ($this->id === null) {
route_to( throw new RuntimeException('Episode must be created before getting comments.');
'episode', }
$this->getPodcast()->name,
$this->attributes['slug'], if ($this->comments === null) {
), $this->comments = (new EpisodeCommentModel())->getEpisodeComments($this->id);
); }
return $this->comments;
} }
public function getEmbeddablePlayerUrl($theme = null): string public function getEmbedUrl(?string $theme = null): string
{ {
return base_url( return $theme
$theme ? url_to('embed-theme', esc($this->getPodcast()->handle), esc($this->attributes['slug']), $theme)
? route_to( : url_to('embed', esc($this->getPodcast()->handle), esc($this->attributes['slug']));
'embeddable-player-theme',
$this->getPodcast()->name,
$this->attributes['slug'],
$theme,
)
: route_to(
'embeddable-player',
$this->getPodcast()->name,
$this->attributes['slug'],
),
);
} }
public function setGuid(?string $guid = null) public function setGuid(?string $guid = null): static
{ {
if ($guid === null) { $this->attributes['guid'] = $guid ?? $this->link;
$this->attributes['guid'] = $this->getLink();
} else {
$this->attributes['guid'] = $guid;
}
return $this; return $this;
} }
public function getPodcast(): Podcast public function getPodcast(): ?Podcast
{ {
return (new PodcastModel())->getPodcastById( return (new PodcastModel())->getPodcastById($this->podcast_id);
$this->attributes['podcast_id'],
);
} }
public function setDescriptionMarkdown(string $descriptionMarkdown) 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( $environment->addExtension(new CommonMarkCoreExtension());
$descriptionMarkdown, $environment->addExtension(new AutolinkExtension());
); $environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
return $this; $converter = new MarkdownConverter($environment);
}
public function getDescriptionHtml(?string $serviceSlug = null): string $this->attributes['description_markdown'] = $descriptionMarkdown;
{ $this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
$descriptionHtml = '';
if (
$this->getPodcast()->partner_id !== null &&
$this->getPodcast()->partner_link_url !== null &&
$this->getPodcast()->partner_image_url !== null
) {
$descriptionHtml .= "<div><a href=\"{$this->getPartnerLink(
$serviceSlug,
)}\" rel=\"sponsored noopener noreferrer\" target=\"_blank\"><img src=\"{$this->getPartnerImageUrl(
$serviceSlug,
)}\" alt=\"Partner image\" /></a></div>";
}
$descriptionHtml .= $this->attributes['description_html'];
if ($this->getPodcast()->episode_description_footer_html) {
$descriptionHtml .= "<footer>{$this->getPodcast()->episode_description_footer_html}</footer>";
}
return $descriptionHtml; return $this;
} }
public function getDescription(): string public function getDescription(): string
{ {
if ($this->description === null) { if ($this->description === null) {
$this->description = trim( $this->description = trim(
preg_replace( (string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
'/\s+/',
' ',
strip_tags($this->attributes['description_html']),
),
); );
} }
...@@ -575,47 +504,43 @@ class Episode extends Entity ...@@ -575,47 +504,43 @@ class Episode extends Entity
public function getPublicationStatus(): string public function getPublicationStatus(): string
{ {
if ($this->publication_status) { if ($this->publication_status === null) {
return $this->publication_status; if (! $this->published_at instanceof Time) {
} $this->publication_status = 'not_published';
} elseif ($this->getPodcast()->publication_status !== 'published') {
if (!$this->published_at) { $this->publication_status = 'with_podcast';
return 'not_published'; } elseif ($this->published_at->isBefore(Time::now())) {
} $this->publication_status = 'published';
} else {
helper('date'); $this->publication_status = 'scheduled';
if ($this->published_at->isBefore(Time::now())) { }
return 'published';
} }
return 'scheduled'; return $this->publication_status;
} }
/** /**
* Saves the location name and fetches OpenStreetMap info * Saves the location name and fetches OpenStreetMap info
*/ */
public function setLocation(?string $newLocationName = null) public function setLocation(?Location $location = null): static
{ {
if ($newLocationName === 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_id'] = null; $this->attributes['location_osm'] = null;
}
helper('location');
$oldLocationName = $this->attributes['location_name']; return $this;
}
if ( if (
$oldLocationName === null || ! isset($this->attributes['location_name']) ||
$oldLocationName !== $newLocationName $this->attributes['location_name'] !== $location->name
) { ) {
$this->attributes['location_name'] = $newLocationName; $location->fetchOsmLocation();
if ($location = fetch_osm_location($newLocationName)) { $this->attributes['location_name'] = $location->name;
$this->attributes['location_geo'] = $location['geo']; $this->attributes['location_geo'] = $location->geo;
$this->attributes['location_osm_id'] = $location['osm_id']; $this->attributes['location_osm'] = $location->osm;
}
} }
return $this; return $this;
...@@ -627,101 +552,36 @@ class Episode extends Entity ...@@ -627,101 +552,36 @@ class Episode extends Entity
return null; return null;
} }
if ($this->location === null) { if (! $this->location instanceof Location) {
$this->location = new Location([ $this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
'name' => $this->location_name,
'geo' => $this->location_geo,
'osm_id' => $this->location_osm_id,
]);
} }
return $this->location; return $this->location;
} }
/** public function getPreviewLink(): string
* Get custom rss tag as XML String
*/
function getCustomRssString(): string
{ {
if ($this->custom_rss === null) { if ($this->preview_id === null) {
return ''; // generate preview id
} if (! $previewUUID = (new EpisodeModel())->setEpisodePreviewId($this->id)) {
throw new Exception('Could not set episode preview id');
}
helper('rss'); $this->preview_id = $previewUUID;
}
$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')
->addChild('item');
array_to_rss(
[
'elements' => $this->custom_rss,
],
$xmlNode,
);
return str_replace(['<item>', '</item>'], '', $xmlNode->asXML()); return url_to('episode-preview', (string) $this->preview_id);
} }
/** /**
* Saves custom rss tag into json * Returns the episode's clip count
*/ */
function setCustomRssString(?string $customRssString = null) public function getClipCount(): int|string
{
if ($customRssString === null) {
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><item>' .
$customRssString .
'</item></channel></rss>',
),
)['elements'][0]['elements'][0];
if (array_key_exists('elements', $customRssArray)) {
$this->attributes['custom_rss'] = json_encode(
$customRssArray['elements'],
);
} else {
$this->attributes['custom_rss'] = null;
}
return $this;
}
function getPartnerLink(?string $serviceSlug = null): string
{
$partnerLink =
rtrim($this->getPodcast()->partner_link_url, '/') .
'?pid=' .
$this->getPodcast()->partner_id .
'&guid=' .
urlencode($this->attributes['guid']);
if ($serviceSlug !== null) {
$partnerLink .= '&_from=' . $serviceSlug;
}
return $partnerLink;
}
function getPartnerImageUrl($serviceSlug = null): string
{ {
$partnerImageUrl = if ($this->id === null) {
rtrim($this->getPodcast()->partner_image_url, '/') . throw new RuntimeException('Episode must be created before getting number of video clips.');
'?pid=' .
$this->getPodcast()->partner_id .
'&guid=' .
urlencode($this->attributes['guid']);
if ($serviceSlug !== null) {
$partnerImageUrl = '&_from=' . $serviceSlug;
} }
return $partnerImageUrl; return (new ClipModel())->getClipCount($this->podcast_id, $this->id);
} }
} }
<?php
declare(strict_types=1);
/**
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use App\Models\ActorModel;
use App\Models\EpisodeCommentModel;
use App\Models\EpisodeModel;
use CodeIgniter\I18n\Time;
use Michalsn\Uuid\UuidEntity;
use RuntimeException;
/**
* @property string $id
* @property string $uri
* @property int $episode_id
* @property Episode|null $episode
* @property int $actor_id
* @property Actor|null $actor
* @property string $in_reply_to_id
* @property EpisodeComment|null $reply_to_comment
* @property string $message
* @property string $message_html
* @property int $likes_count
* @property int $replies_count
* @property Time $created_at
* @property int $created_by
*
* @property EpisodeComment[] $replies
*/
class EpisodeComment extends UuidEntity
{
protected ?Episode $episode = null;
protected ?Actor $actor = null;
protected ?EpisodeComment $reply_to_comment = null;
/**
* @var EpisodeComment[]|null
*/
protected ?array $replies = null;
protected bool $has_replies = false;
/**
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $dates = ['created_at'];
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'string',
'uri' => 'string',
'episode_id' => 'integer',
'actor_id' => 'integer',
'in_reply_to_id' => '?string',
'message' => 'string',
'message_html' => 'string',
'likes_count' => 'integer',
'replies_count' => 'integer',
'created_by' => 'integer',
'is_from_post' => 'boolean',
];
public function getEpisode(): ?Episode
{
if ($this->episode_id === null) {
throw new RuntimeException('Comment must have an episode_id before getting episode.');
}
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
}
return $this->episode;
}
/**
* Returns the comment's actor
*/
public function getActor(): ?Actor
{
if ($this->actor_id === null) {
throw new RuntimeException('Comment must have an actor_id before getting actor.');
}
if (! $this->actor instanceof Actor) {
$this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id);
}
return $this->actor;
}
/**
* @return EpisodeComment[]
*/
public function getReplies(): array
{
if ($this->id === null) {
throw new RuntimeException('Comment must be created before getting replies.');
}
if ($this->replies === null) {
$this->replies = (new EpisodeCommentModel())->getCommentReplies($this->id);
}
return $this->replies;
}
public function getHasReplies(): bool
{
return $this->getReplies() !== [];
}
public function getReplyToComment(): ?self
{
if ($this->in_reply_to_id === null) {
throw new RuntimeException('Comment is not a reply.');
}
if (! $this->reply_to_comment instanceof self) {
$this->reply_to_comment = model(EpisodeCommentModel::class, false)
->getCommentById($this->in_reply_to_id);
}
return $this->reply_to_comment;
}
public function setMessage(string $message): static
{
helper('fediverse');
$messageWithoutTags = strip_tags($message);
$this->attributes['message'] = $messageWithoutTags;
$this->attributes['message_html'] = str_replace("\n", '<br />', linkify($messageWithoutTags));
return $this;
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity\Entity;
use App\Models\PersonModel;
/**
* @property int $id
* @property int $podcast_id
* @property int $episode_id
* @property int $person_id
* @property Person $person
* @property string|null $person_group
* @property string|null $person_role
*/
class EpisodePerson extends Entity
{
/**
* @var Person
*/
protected $person;
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'person_id' => 'integer',
'person_group' => '?string',
'person_role' => '?string',
];
public function getPerson(): Person
{
return (new PersonModel())->getPersonById(
$this->attributes['person_id'],
);
}
}
<?php
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use Config\Images as ImagesConfig;
use Config\Services;
use RuntimeException;
/**
* @property File|null $file
* @property string $dirname
* @property string $filename
* @property string $extension
* @property string $mimetype
* @property string $path
* @property string $url
* @property string $thumbnail_path
* @property string $thumbnail_url
* @property string $medium_path
* @property string $medium_url
* @property string $large_path
* @property string $large_url
* @property string $feed_path
* @property string $feed_url
* @property string $id3_path
* @property string $id3_url
*/
class Image extends Entity
{
/**
* @var ImagesConfig
*/
protected $config;
/**
* @var null|File
*/
protected $file;
/**
* @var string
*/
protected $dirname;
/**
* @var string
*/
protected $filename;
/**
* @var string
*/
protected $extension;
public function __construct(
?File $file,
string $path = '',
string $mimetype = ''
) {
if ($file === null && $path === '') {
throw new RuntimeException(
'File or path must be set to create an Image.',
);
}
$this->config = config('Images');
$dirname = '';
$filename = '';
$extension = '';
if ($file !== null) {
$dirname = $file->getPath();
$filename = $file->getBasename();
$extension = $file->getExtension();
$mimetype = $file->getMimeType();
}
if ($path !== '') {
[
'filename' => $filename,
'dirname' => $dirname,
'extension' => $extension,
] = pathinfo($path);
}
$this->file = $file;
$this->dirname = $dirname;
$this->filename = $filename;
$this->extension = $extension;
$this->mimetype = $mimetype;
}
function getFile(): File
{
if ($this->file === null) {
$this->file = new File($this->path);
}
return $this->file;
}
function getPath(): string
{
return $this->dirname . '/' . $this->filename . '.' . $this->extension;
}
function getUrl(): string
{
helper('media');
return media_base_url($this->path);
}
function getThumbnailPath(): string
{
return $this->dirname .
'/' .
$this->filename .
$this->config->thumbnailSuffix .
'.' .
$this->extension;
}
function getThumbnailUrl(): string
{
helper('media');
return media_base_url($this->thumbnail_path);
}
function getMediumPath(): string
{
return $this->dirname .
'/' .
$this->filename .
$this->config->mediumSuffix .
'.' .
$this->extension;
}
function getMediumUrl(): string
{
helper('media');
return media_base_url($this->medium_path);
}
function getLargePath(): string
{
return $this->dirname .
'/' .
$this->filename .
$this->config->largeSuffix .
'.' .
$this->extension;
}
function getLargeUrl(): string
{
helper('media');
return media_base_url($this->large_path);
}
function getFeedPath(): string
{
return $this->dirname .
'/' .
$this->filename .
$this->config->feedSuffix .
'.' .
$this->extension;
}
function getFeedUrl(): string
{
helper('media');
return media_base_url($this->feed_path);
}
function getId3Path(): string
{
return $this->dirname .
'/' .
$this->filename .
$this->config->id3Suffix .
'.' .
$this->extension;
}
function getId3Url(): string
{
helper('media');
return media_base_url($this->id3_path);
}
public function saveImage(string $dirname, string $filename): void
{
helper('media');
$this->dirname = $dirname;
$this->filename = $filename;
save_media($this->file, $this->dirname, $this->filename);
$imageService = Services::image();
$thumbnailSize = $this->config->thumbnailSize;
$mediumSize = $this->config->mediumSize;
$largeSize = $this->config->largeSize;
$feedSize = $this->config->feedSize;
$id3Size = $this->config->id3Size;
$imageService
->withFile(media_path($this->path))
->resize($thumbnailSize, $thumbnailSize)
->save(media_path($this->thumbnail_path));
$imageService
->withFile(media_path($this->path))
->resize($mediumSize, $mediumSize)
->save(media_path($this->medium_path));
$imageService
->withFile(media_path($this->path))
->resize($largeSize, $largeSize)
->save(media_path($this->large_path));
$imageService
->withFile(media_path($this->path))
->resize($feedSize, $feedSize)
->save(media_path($this->feed_path));
$imageService
->withFile(media_path($this->path))
->resize($id3Size, $id3Size)
->save(media_path($this->id3_path));
}
}
<?php <?php
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/
*/ */
...@@ -20,7 +22,7 @@ class Language extends Entity ...@@ -20,7 +22,7 @@ class Language extends Entity
* @var array<string, string> * @var array<string, string>
*/ */
protected $casts = [ protected $casts = [
'code' => 'string', 'code' => 'string',
'native_name' => 'string', 'native_name' => 'string',
]; ];
} }
<?php
declare(strict_types=1);
/**
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use Michalsn\Uuid\UuidEntity;
/**
* @property int $actor_id
* @property string $comment_id
*/
class Like extends UuidEntity
{
/**
* @var string[]
*/
protected $uuids = ['comment_id'];
/**
* @var array<string, string>
*/
protected $casts = [
'actor_id' => 'integer',
'comment_id' => 'string',
];
}
<?php <?php
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/
*/ */
...@@ -14,24 +16,52 @@ use CodeIgniter\Entity\Entity; ...@@ -14,24 +16,52 @@ use CodeIgniter\Entity\Entity;
* @property string $url * @property string $url
* @property string $name * @property string $name
* @property string|null $geo * @property string|null $geo
* @property string|null $osm_id * @property string|null $osm
* @property double|null $latitude
* @property double|null $longitude
*/ */
class Location extends Entity class Location extends Entity
{ {
/** private const string OSM_URL = 'https://www.openstreetmap.org/';
* @var string
*/ private const string NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
const OSM_URL = 'https://www.openstreetmap.org/';
public function __construct(
protected string $name,
protected ?string $geo = null,
protected ?string $osm = null,
) {
$latitude = null;
$longitude = null;
if ($geo !== null) {
$geoArray = explode(',', substr($geo, 4));
if (count($geoArray) === 2) {
$latitude = (float) $geoArray[0];
$longitude = (float) $geoArray[1];
}
}
parent::__construct([
'name' => $name,
'geo' => $geo,
'osm' => $osm,
'latitude' => $latitude,
'longitude' => $longitude,
]);
}
public function getUrl(): string public function getUrl(): string
{ {
if ($this->osm_id !== null) { if ($this->osm !== null) {
return self::OSM_URL . return self::OSM_URL .
['N' => 'node', 'W' => 'way', 'R' => 'relation'][ [
substr($this->osm_id, 0, 1) 'N' => 'node',
] . 'W' => 'way',
'R' => 'relation',
][substr($this->osm, 0, 1)] .
'/' . '/' .
substr($this->osm_id, 1); substr($this->osm, 1);
} }
if ($this->geo !== null) { if ($this->geo !== null) {
...@@ -42,4 +72,48 @@ class Location extends Entity ...@@ -42,4 +72,48 @@ class Location extends Entity
return self::OSM_URL . 'search?query=' . urlencode($this->name); return self::OSM_URL . 'search?query=' . urlencode($this->name);
} }
/**
* Fetches places from Nominatim OpenStreetMap
*/
public function fetchOsmLocation(): static
{
$client = service('curlrequest');
$response = $client->request(
'GET',
self::NOMINATIM_URL .
'search.php?q=' .
urlencode($this->name) .
'&polygon_geojson=1&format=jsonv2',
[
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
'Accept' => 'application/json',
],
],
);
$places = json_decode((string) $response->getBody(), false, 512, JSON_THROW_ON_ERROR);
if ($places === []) {
return $this;
}
if (property_exists($places[0], 'lat') && $places[0]->lat !== null && (property_exists(
$places[0],
'lon',
) && $places[0]->lon !== null)) {
$this->attributes['geo'] = "geo:{$places[0]->lat},{$places[0]->lon}";
}
if (property_exists($places[0], 'osm_type') && $places[0]->osm_type !== null && (property_exists(
$places[0],
'osm_id',
) && $places[0]->osm_id !== null)) {
$this->attributes['osm'] = strtoupper(substr((string) $places[0]->osm_type, 0, 1)) . $places[0]->osm_id;
}
return $this;
}
} }
<?php <?php
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/
*/ */
...@@ -10,7 +12,12 @@ namespace App\Entities; ...@@ -10,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
...@@ -25,25 +32,19 @@ use League\CommonMark\CommonMarkConverter; ...@@ -25,25 +32,19 @@ use League\CommonMark\CommonMarkConverter;
*/ */
class Page extends Entity class Page extends Entity
{ {
/** protected string $link;
* @var string
*/
protected $link;
/** protected string $content_html;
* @var string
*/
protected $content_html;
/** /**
* @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
...@@ -51,17 +52,22 @@ class Page extends Entity ...@@ -51,17 +52,22 @@ class Page extends Entity
return url_to('page', $this->attributes['slug']); return url_to('page', $this->attributes['slug']);
} }
public function setContentMarkdown(string $contentMarkdown): self 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( $this->attributes['content_html'] = $converter->convert($contentMarkdown);
$contentMarkdown,
);
return $this; return $this;
} }
......
<?php <?php
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/
*/ */
namespace App\Entities; namespace App\Entities;
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;
/** /**
* @property int $id * @property int $id
* @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
*/ */
class Person extends Entity class Person extends Entity
{ {
protected ?Image $avatar = null;
/** /**
* @var Image * @var object[]|null
*/ */
protected $image; protected ?array $roles = null;
/** /**
* @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',
'created_by' => 'integer', 'episode_id' => '?integer',
'updated_by' => 'integer', 'created_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): self public function setAvatar(UploadedFile | File|null $file = null): static
{ {
helper('media'); if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
// Save image if (array_key_exists('avatar_id', $this->attributes) && $this->attributes['avatar_id'] !== null) {
$image->saveImage('persons', $this->attributes['unique_name']); $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['image_mimetype'] = $image->mimetype; $this->attributes['avatar_id'] = (new MediaModel('image'))->saveMedia($avatar);
$this->attributes['image_path'] = $image->path; }
return $this; return $this;
} }
public function getImage(): Image public function getAvatar(): ?Image
{ {
return new Image( if ($this->avatar_id === null) {
null, return null;
$this->attributes['image_path'], }
$this->attributes['image_mimetype'],
); if (! $this->avatar instanceof Image) {
$this->avatar = (new MediaModel('image'))->getMediaById($this->avatar_id);
}
return $this->avatar;
}
/**
* @return object[]
*/
public function getRoles(): array
{
if ($this->attributes['podcast_id'] === null) {
throw new RuntimeException('Person must have a podcast_id before getting roles.');
}
if ($this->roles === null) {
$this->roles = (new PersonModel())->getPersonRoles(
$this->id,
(int) $this->attributes['podcast_id'],
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null,
);
}
return $this->roles;
} }
} }
<?php <?php
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\PlatformModel; use App\Models\PersonModel;
use App\Models\PodcastPersonModel;
use CodeIgniter\Entity\Entity; use CodeIgniter\Entity\Entity;
use App\Models\UserModel; 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;
/** /**
* @property int $id * @property int $id
* @property string $guid
* @property int $actor_id * @property int $actor_id
* @property Actor $actor * @property Actor|null $actor
* @property string $name * @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 $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 $category * @property Category|null $category
* @property int[] $other_categories_ids * @property int[] $other_categories_ids
* @property Category[] $other_categories * @property Category[] $other_categories
* @property string|null $parental_advisory * @property string|null $parental_advisory
...@@ -44,197 +63,250 @@ use RuntimeException; ...@@ -44,197 +63,250 @@ 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
* @property string|null $imported_feed_url * @property string|null $imported_feed_url
* @property string|null $new_feed_url * @property string|null $new_feed_url
* @property Location $location * @property Location|null $location
* @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_id * @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 PodcastPerson[] $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
*
*/ */
class Podcast extends Entity class Podcast extends Entity
{ {
/** protected string $link;
* @var string
*/
protected $link;
/** protected string $at_handle;
* @var Actor
*/
protected $actor;
/** protected ?Actor $actor = null;
* @var Image
*/
protected $image;
/** protected ?Image $cover = null;
* @var string
*/
protected $description;
/** protected ?Image $banner = null;
* @var Category
*/ protected ?string $description = null;
protected $category;
protected ?Category $category = null;
/** /**
* @var Category[] * @var Category[]|null
*/ */
protected $other_categories; protected ?array $other_categories = null;
/** /**
* @var string[] * @var int[]
*/ */
protected $other_categories_ids; protected array $other_categories_ids = [];
/** /**
* @var Episode[] * @var Episode[]|null
*/ */
protected $episodes; protected ?array $episodes = null;
/** /**
* @var PodcastPerson[] * @var Person[]|null
*/ */
protected $persons; protected ?array $persons = null;
/** /**
* @var User[] * @var User[]|null
*/ */
protected $contributors; protected ?array $contributors = null;
/** /**
* @var Platform[] * @var Subscription[]|null
*/ */
protected $podcasting_platforms; protected ?array $subscriptions = null;
/** /**
* @var Platform[] * @var Platform[]|null
*/ */
protected $social_platforms; protected ?array $podcasting_platforms = null;
/** /**
* @var Platform[] * @var Platform[]|null
*/ */
protected $funding_platforms; protected ?array $social_platforms = null;
/** /**
* @var Location|null * @var Platform[]|null
*/ */
protected $location; protected ?array $funding_platforms = null;
protected ?Location $location = null;
protected ?string $publication_status = null;
/** /**
* @var string * @var array<int, string>
* @phpstan-var list<string>
*/ */
protected $custom_rss_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',
'actor_id' => 'integer', 'guid' => 'string',
'name' => 'string', 'actor_id' => 'integer',
'title' => 'string', 'handle' => 'string',
'description_markdown' => 'string', 'title' => 'string',
'description_html' => 'string', 'description_markdown' => 'string',
'image_path' => 'string', 'description_html' => 'string',
'image_mimetype' => 'string', 'cover_id' => 'int',
'language_code' => 'string', 'banner_id' => '?int',
'category_id' => 'integer', 'language_code' => 'string',
'parental_advisory' => '?string', 'category_id' => 'integer',
'publisher' => '?string', 'parental_advisory' => '?string',
'owner_name' => 'string', 'publisher' => '?string',
'owner_email' => 'string', 'owner_name' => 'string',
'type' => 'string', 'owner_email' => 'string',
'copyright' => '?string', 'type' => 'string',
'episode_description_footer_markdown' => '?string', 'copyright' => '?string',
'episode_description_footer_html' => '?string', 'is_blocked' => 'boolean',
'is_blocked' => 'boolean', 'is_completed' => 'boolean',
'is_completed' => 'boolean', 'is_locked' => 'boolean',
'is_locked' => 'boolean', 'is_premium_by_default' => '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_id' => '?string', 'location_osm' => '?string',
'payment_pointer' => '?string', 'is_published_on_hubs' => 'boolean',
'custom_rss' => '?json-array', 'created_by' => 'integer',
'partner_id' => '?string', 'updated_by' => 'integer',
'partner_link_url' => '?string',
'partner_image_url' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
]; ];
public function getActor(): Actor public function getAtHandle(): string
{ {
if (!$this->actor_id) { return '@' . $this->handle;
throw new RuntimeException( }
'Podcast must have an actor_id before getting actor.',
); public function getActor(): ?Actor
{
if ($this->actor_id === 0) {
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')->getActorById($this->actor_id); $this->actor = model(ActorModel::class, false)
->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/` {
* if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
* @param Image $image return $this;
*/ }
public function setImage($image): self
if (array_key_exists('cover_id', $this->attributes) && $this->attributes['cover_id'] !== null) {
$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;
}
public function getCover(): Image
{
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
{ {
// Save image if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
$image->saveImage('podcasts/' . $this->attributes['name'], 'cover'); 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['image_mimetype'] = $image->mimetype; $this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner);
$this->attributes['image_path'] = $image->path; }
return $this; return $this;
} }
public function getImage(): Image public function getBanner(): ?Image
{ {
return new Image(null, $this->image_path, $this->image_mimetype); 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
{ {
return url_to('podcast-activity', $this->attributes['name']); return url_to('podcast-activity', $this->attributes['handle']);
} }
public function getFeedUrl(): string public function getFeedUrl(): string
{ {
return url_to('podcast_feed', $this->attributes['name']); return url_to('podcast-rss-feed', $this->attributes['handle']);
} }
/** /**
...@@ -244,39 +316,42 @@ class Podcast extends Entity ...@@ -244,39 +316,42 @@ class Podcast extends Entity
*/ */
public function getEpisodes(): array public function getEpisodes(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting episodes.');
'Podcast must be created before getting episodes.',
);
} }
if (empty($this->episodes)) { if ($this->episodes === null) {
$this->episodes = (new EpisodeModel())->getPodcastEpisodes( $this->episodes = (new EpisodeModel())->getPodcastEpisodes($this->id, $this->type);
$this->id,
$this->type,
);
} }
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
* *
* @return PodcastPerson[] * @return Person[]
*/ */
public function getPersons(): array public function getPersons(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting persons.');
'Podcast must be created before getting persons.',
);
} }
if (empty($this->persons)) { if ($this->persons === null) {
$this->persons = (new PodcastPersonModel())->getPodcastPersons( $this->persons = (new PersonModel())->getPodcastPersons($this->id);
$this->id,
);
} }
return $this->persons; return $this->persons;
...@@ -284,26 +359,38 @@ class Podcast extends Entity ...@@ -284,26 +359,38 @@ class Podcast extends Entity
/** /**
* Returns the podcast category entity * Returns the podcast category entity
*
* @return Category
*/ */
public function getCategory(): Category public function getCategory(): ?Category
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting category.');
'Podcast must be created before getting category.',
);
} }
if (empty($this->category)) { if (! $this->category instanceof Category) {
$this->category = (new CategoryModel())->getCategoryById( $this->category = (new CategoryModel())->getCategoryById($this->category_id);
$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
* *
...@@ -311,69 +398,62 @@ class Podcast extends Entity ...@@ -311,69 +398,62 @@ class Podcast extends Entity
*/ */
public function getContributors(): array public function getContributors(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcasts must be created before getting contributors.');
'Podcasts must be created before getting contributors.',
);
} }
if (empty($this->contributors)) { if ($this->contributors === null) {
$this->contributors = (new UserModel())->getPodcastContributors( $this->contributors = (new UserModel())->getPodcastContributors($this->id);
$this->id,
);
} }
return $this->contributors; return $this->contributors;
} }
public function setDescriptionMarkdown(string $descriptionMarkdown): self public function setDescriptionMarkdown(string $descriptionMarkdown): static
{ {
$converter = new CommonMarkConverter([ $config = [
'html_input' => 'strip', 'html_input' => 'escape',
'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['description_markdown'] = $descriptionMarkdown; $this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml( $this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
$descriptionMarkdown,
);
return $this; return $this;
} }
public function setEpisodeDescriptionFooterMarkdown( public function getDescription(): string
?string $episodeDescriptionFooterMarkdown = null {
): self { if ($this->description === null) {
if ($episodeDescriptionFooterMarkdown) { $this->description = trim(
$converter = new CommonMarkConverter([ (string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
'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->description;
} }
public function getDescription(): string public function getPublicationStatus(): string
{ {
if ($this->description) { if ($this->publication_status === null) {
return $this->description; 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 trim( return $this->publication_status;
preg_replace(
'/\s+/',
' ',
strip_tags($this->attributes['description_html']),
),
);
} }
/** /**
...@@ -383,17 +463,12 @@ class Podcast extends Entity ...@@ -383,17 +463,12 @@ class Podcast extends Entity
*/ */
public function getPodcastingPlatforms(): array public function getPodcastingPlatforms(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting podcasting platform links.');
'Podcast must be created before getting podcasting platform links.',
);
} }
if (empty($this->podcasting_platforms)) { if ($this->podcasting_platforms === null) {
$this->podcasting_platforms = (new PlatformModel())->getPodcastPlatforms( $this->podcasting_platforms = (new PlatformModel())->getPlatforms($this->id, 'podcasting');
$this->id,
'podcasting',
);
} }
return $this->podcasting_platforms; return $this->podcasting_platforms;
...@@ -406,17 +481,12 @@ class Podcast extends Entity ...@@ -406,17 +481,12 @@ class Podcast extends Entity
*/ */
public function getSocialPlatforms(): array public function getSocialPlatforms(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting social platform links.');
'Podcast must be created before getting social platform links.',
);
} }
if (empty($this->social_platforms)) { if ($this->social_platforms === null) {
$this->social_platforms = (new PlatformModel())->getPodcastPlatforms( $this->social_platforms = (new PlatformModel())->getPlatforms($this->id, 'social');
$this->id,
'social',
);
} }
return $this->social_platforms; return $this->social_platforms;
...@@ -429,17 +499,12 @@ class Podcast extends Entity ...@@ -429,17 +499,12 @@ class Podcast extends Entity
*/ */
public function getFundingPlatforms(): array public function getFundingPlatforms(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting funding platform links.');
'Podcast must be created before getting funding platform links.',
);
} }
if (empty($this->funding_platforms)) { if ($this->funding_platforms === null) {
$this->funding_platforms = (new PlatformModel())->getPodcastPlatforms( $this->funding_platforms = (new PlatformModel())->getPlatforms($this->id, 'funding');
$this->id,
'funding',
);
} }
return $this->funding_platforms; return $this->funding_platforms;
...@@ -450,31 +515,24 @@ class Podcast extends Entity ...@@ -450,31 +515,24 @@ class Podcast extends Entity
*/ */
public function getOtherCategories(): array public function getOtherCategories(): array
{ {
if (empty($this->id)) { if ($this->id === null) {
throw new RuntimeException( throw new RuntimeException('Podcast must be created before getting other categories.');
'Podcast must be created before getting other categories.',
);
} }
if (empty($this->other_categories)) { if ($this->other_categories === null) {
$this->other_categories = (new CategoryModel())->getPodcastCategories( $this->other_categories = (new CategoryModel())->getPodcastCategories($this->id);
$this->id,
);
} }
return $this->other_categories; return $this->other_categories;
} }
/** /**
* @return array<int> * @return int[]
*/ */
public function getOtherCategoriesIds(): array public function getOtherCategoriesIds(): array
{ {
if (empty($this->other_categories_ids)) { if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column( $this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
$this->getOtherCategories(),
'id',
);
} }
return $this->other_categories_ids; return $this->other_categories_ids;
...@@ -483,28 +541,25 @@ class Podcast extends Entity ...@@ -483,28 +541,25 @@ class Podcast extends Entity
/** /**
* Saves the location name and fetches OpenStreetMap info * Saves the location name and fetches OpenStreetMap info
*/ */
public function setLocation(?string $newLocationName = null) public function setLocation(?Location $location = null): static
{ {
if ($newLocationName === 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_id'] = null; $this->attributes['location_osm'] = null;
}
helper('location'); return $this;
}
$oldLocationName = $this->attributes['location_name'];
if ( if (
$oldLocationName === null || ! isset($this->attributes['location_name']) ||
$oldLocationName !== $newLocationName $this->attributes['location_name'] !== $location->name
) { ) {
$this->attributes['location_name'] = $newLocationName; $location->fetchOsmLocation();
if ($location = fetch_osm_location($newLocationName)) { $this->attributes['location_name'] = $location->name;
$this->attributes['location_geo'] = $location['geo']; $this->attributes['location_geo'] = $location->geo;
$this->attributes['location_osm_id'] = $location['osm_id']; $this->attributes['location_osm'] = $location->osm;
}
} }
return $this; return $this;
...@@ -516,71 +571,16 @@ class Podcast extends Entity ...@@ -516,71 +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 = new Location($this->location_name, $this->location_geo, $this->location_osm);
'name' => $this->location_name,
'geo' => $this->location_geo,
'osm_id' => $this->location_osm_id,
]);
} }
return $this->location; return $this->location;
} }
/** public function getIsPremium(): bool
* Get custom rss tag as XML String
*
* @return string
*/
function getCustomRssString(): string
{ {
if (empty($this->attributes['custom_rss'])) { // 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
*
* @param string $customRssString
*/
function setCustomRssString($customRssString): self
{
if (empty($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;
} }
} }
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity\Entity;
use App\Models\PersonModel;
/**
* @property int $id
* @property int $podcast_id
* @property int $person_id
* @property Person $person
* @property string|null $person_group
* @property string|null $person_role
*/
class PodcastPerson extends Entity
{
/**
* @var Person
*/
protected $person;
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'person_id' => 'integer',
'person_group' => '?string',
'person_role' => '?string',
];
public function getPerson(): ?Person
{
return (new PersonModel())->getPersonById(
$this->attributes['person_id'],
);
}
}
<?php <?php
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\Note as ActivityPubNote;
use App\Models\ActorModel;
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 Note extends ActivityPubNote class Post extends FediversePost
{ {
protected ?Episode $episode = null;
/** /**
* @var Episode|null * @var array<string, string>
*/ */
protected $episode;
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 note's attached episode * Returns the post's attached episode
*
* @return \App\Entities\Episode
*/ */
public function getEpisode() public function getEpisode(): ?Episode
{ {
if ($this->episode_id === null) { if ($this->episode_id === null) {
throw new RuntimeException( throw new RuntimeException('Post must have an episode_id before getting episode.');
'Note must have an episode_id before getting episode.',
);
} }
if ($this->episode === null) { if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById( $this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
$this->episode_id,
);
} }
return $this->episode; return $this->episode;
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity\Entity;
/**
* @property int $id
* @property int $podcast_id
* @property int $episode_id
* @property double $start_time
* @property double $duration
* @property string|null $label
* @property int $created_by
* @property int $updated_by
*/
class Soundbite extends Entity
{
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'episode_id' => 'integer',
'start_time' => 'double',
'duration' => 'double',
'label' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
];
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use RuntimeException;
use App\Models\PodcastModel;
use Myth\Auth\Entities\User as MythAuthUser;
/**
* @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[]
*/
protected $podcasts = [];
/**
* 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
namespace App\Filters;
use App\Models\PodcastModel;
use Config\Services;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Filters\FilterInterface;
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 array|null $params
* @return void|mixed
*/
public function before(RequestInterface $request, $params = null)
{
helper('auth');
if (empty($params)) {
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 (
(startsWith($permission, 'podcast-') ||
startsWith($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 array|null $arguments
*/
public function after(
RequestInterface $request,
ResponseInterface $response,
$arguments = null
): void {
}
//--------------------------------------------------------------------
}
<?php
/**
* @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($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
*
* @return Actor|false
*/
function interact_as_actor()
{
$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();
}
}
<?php <?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 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')) {
function replace_breadcrumb_params($newParams): void /**
* @param array<string|int,string> $newParams
*/
function replace_breadcrumb_params(array $newParams): void
{ {
$breadcrumb = Services::breadcrumb(); service('breadcrumb')->replaceParams($newParams);
$breadcrumb->replaceParams($newParams);
} }
} }
<?php <?php
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 CodeIgniter\View\Table;
use CodeIgniter\I18n\Time; use CodeIgniter\I18n\Time;
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 $customOptions button options: variant, size, iconLeft, iconRight
* @param array $customAttributes Additional attributes
*
* @return string
*/
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 (!empty($customAttributes['class'])) {
$buttonClass .= ' ' . $customAttributes['class'];
unset($customAttributes['class']);
}
if ($options['iconLeft']) {
$label = icon($options['iconLeft'], 'mr-2') . $label;
}
if ($options['iconRight']) {
$label .= icon($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 <<<HTML
<button class="{$buttonClass}" {$attributes}>
{$label}
</button>
HTML;
}
}
// ------------------------------------------------------------------------
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 $customOptions button options: variant, size, iconLeft, iconRight
* @param array $customAttributes Additional attributes
*
* @return string
*/
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
*
* @return string
*/
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')) {
/** /**
* Data table component * Data table component
* *
* Creates a stylized table. * Creates a stylized table.
* *
* @param array $columns array of associate arrays with `header` and `cell` keys where `cell` is a function with a row of $data as parameter * @param array<array<string, mixed>> $columns array of associate arrays with `header` and `cell` keys where `cell` is a function with a row of $data as parameter
* @param array $data data to loop through and display in rows * @param mixed[] $data data to loop through and display in rows
* @param array ...$rest Any other argument to pass to the `cell` function * @param mixed ...$rest Any other argument to pass to the `cell` function
*
* @return string
*/ */
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);
...@@ -233,13 +59,20 @@ if (!function_exists('data_table')) { ...@@ -233,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>';
} }
...@@ -247,91 +80,69 @@ if (!function_exists('data_table')) { ...@@ -247,91 +80,69 @@ if (!function_exists('data_table')) {
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (!function_exists('publication_pill')) { if (! function_exists('publication_pill')) {
/** /**
* Publication pill component * Publication pill component
* *
* Shows the stylized publication datetime in regards to current datetime. * Shows the stylized publication datetime in regards to current datetime.
*
* @return string
*/ */
function publication_pill( function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
?Time $publicationDate, {
string $publicationStatus, $variant = match ($publicationStatus) {
string $customClass = '' 'published' => 'success',
): string { 'scheduled' => 'warning',
if ($publicationDate === null) { 'with_podcast' => 'info',
return ''; 'not_published' => 'default',
} default => 'default',
};
$class =
$publicationStatus === 'published' $title = match ($publicationStatus) {
? 'text-pine-500 border-pine-500' 'published', 'scheduled' => (string) $publicationDate,
: 'text-red-600 border-red-600'; 'with_podcast' => lang('Episode.with_podcast_hint'),
'not_published' => '',
$langOptions = [ default => '',
'<time pubdate datetime="' . };
$publicationDate->format(DateTime::ATOM) .
'" title="' . $label = lang('Episode.publication_status.' . $publicationStatus);
$publicationDate .
'">' . // @icon("error-warning-fill")
lang('Common.mediumDate', [$publicationDate]) . return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
'</time>', '">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
]; 'class' => 'flex-shrink-0 ml-1 text-lg',
]) : '') .
$label = lang( '</x-Pill>';
'Episode.publication_status.' . $publicationStatus,
$langOptions,
);
return '<span class="px-1 font-semibold border ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
} }
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
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.
* *
* @param boolean $publicationStatus the episode's publication status * * Displays the appropriate publication button depending on the publication post.
* @return string
*/ */
function publication_button( function publication_button(int $podcastId, int $episodeId, string $publicationStatus): string
int $podcastId, {
int $episodeId,
bool $publicationStatus
): string {
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( $route = route_to('episode-publish_edit', $podcastId, $episodeId);
'episode-publish_edit', $variant = 'warning';
$podcastId, $iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
$episodeId,
);
$variant = 'accent';
$iconLeft = 'upload-cloud';
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 = '';
...@@ -341,16 +152,112 @@ if (!function_exists('publication_button')) { ...@@ -341,16 +152,112 @@ 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;
} }
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (!function_exists('episode_numbering')) { if (! function_exists('episode_numbering')) {
/** /**
* Returns relevant translated episode numbering. * Returns relevant translated episode numbering.
* *
...@@ -360,20 +267,20 @@ if (!function_exists('episode_numbering')) { ...@@ -360,20 +267,20 @@ 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 '';
} }
$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) {
...@@ -385,11 +292,11 @@ if (!function_exists('episode_numbering')) { ...@@ -385,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>';
} }
...@@ -402,28 +309,199 @@ if (!function_exists('episode_numbering')) { ...@@ -402,28 +309,199 @@ 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}"),
(empty($class) ? '' : " {$class}"),
'target' => '_blank', 'target' => '_blank',
'rel' => 'noreferrer noopener', 'rel' => 'noreferrer noopener',
], ],
); );
} }
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
if (! function_exists('audio_player')) {
/**
* Returns audio player
*/
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
{
$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 '';
}
$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;
}
}
// ------------------------------------------------------------------------
if (! function_exists('category_label')) {
function category_label(Category $category): string
{
$categoryLabel = '';
if ($category->parent_id !== null) {
$categoryLabel .= lang('Podcast.category_options.' . $category->parent->code) . ' › ';
}
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;
}
}
<?php <?php
/** declare(strict_types=1);
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (!function_exists('form_section')) { if (! function_exists('form_textarea')) {
/** /**
* 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 $attributes Additional attributes
*
* @return string
*/ */
function form_section( function form_textarea(mixed $data = '', string $value = '', mixed $extra = ''): string
string $title = '', {
string $subtitle = '', $defaults = [
array $attributes = [], 'name' => is_array($data) ? '' : $data,
string $customSubtitleClass = '' 'cols' => '40',
): string { 'rows' => '10',
$subtitleClass = 'text-sm text-gray-600'; ];
if ($customSubtitleClass !== '') { if (! is_array($data) || ! isset($data['value'])) {
$subtitleClass = $customSubtitleClass; $val = $value;
} else {
$val = $data['value'];
unset($data['value']); // textareas don't use the value attribute
} }
$section = // Unsets default rows and cols if defined in extra field as array or string.
'<div class="flex flex-wrap w-full gap-6 mb-8"' . if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
stringify_attributes($attributes) . (string) preg_replace('~\s+~', '', $extra),
">\n"; 'rows=',
) !== false)) {
unset($defaults['rows']);
}
$info = if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
'<div class="w-full max-w-xs"><h2 class="text-lg font-semibold">' . (string) preg_replace('~\s+~', '', $extra),
$title . 'cols=',
'</h2><p class="' . ) !== false)) {
$subtitleClass . unset($defaults['cols']);
'">' . }
$subtitle .
'</p></div>';
return $section . $info . '<div class="flex flex-col w-full max-w-lg">'; return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra,
) . '>' . $val . "</textarea>\n";
} }
} }
//-------------------------------------------------------------------- if (! function_exists('parse_form_attributes')) {
if (!function_exists('form_section_close')) {
/** /**
* Form Section close Tag * Parse the form attributes
* *
* @param string $extra * Helper function used by some of the form helpers
* *
* @return string * @param array<string, string>|string $attributes List of attributes
* @param array<string, mixed> $default Default values
*/ */
function form_section_close(string $extra = ''): string function parse_form_attributes(array|string $attributes, array $default): string
{ {
return '</div></div>' . $extra; if (is_array($attributes)) {
} foreach (array_keys($default) as $key) {
} if (isset($attributes[$key])) {
$default[$key] = $attributes[$key];
//-------------------------------------------------------------------- unset($attributes[$key]);
}
if (!function_exists('form_switch')) {
/**
* Form Checkbox Switch
*
* Abstracts form_label to stylize it as a switch toggle
*
* @return string
*/
function form_switch(
$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 $label_text The text to appear onscreen
* @param string $id The id the label applies to
* @param array $attributes Additional attributes
* @param string $hintText Hint text to add next to the label
* @param boolean $isOptional adds an optional text if true
*
* @return string
*/
function form_label(
string $label_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 . '"';
} }
}
$label_content = $label_text;
if ($isOptional) {
$label_content .=
'<small class="ml-1 lowercase">(' .
lang('Common.optional') .
')</small>';
}
if ($hintText !== '') { if ($attributes !== []) {
$label_content .= hint_tooltip($hintText, 'ml-1'); $default = array_merge($default, $attributes);
}
} }
return $label . '>' . $label_content . '</label>'; $att = '';
}
}
//-------------------------------------------------------------------- foreach ($default as $key => $val) {
if (! is_bool($val)) {
if (!function_exists('form_multiselect')) { if ($key === 'name' && ! strlen((string) $default['name'])) {
/** continue;
* Multi-select menu }
*
* @return string
*/
function form_multiselect(
string $name = '',
array $options = [],
array $selected = [],
array $customExtra = []
): string {
$defaultExtra = [
'data-class' => $customExtra['class'],
'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 = stringify_attributes(array_merge($defaultExtra, $customExtra));
if (stripos($extra, 'multiple') === false) { $att .= $key . '="' . $val . '"' . ($key === array_key_last($default) ? '' : ' ');
$extra .= ' multiple="multiple"'; } else {
$att .= $key . ' ';
}
} }
return form_dropdown($name, $options, $selected, $extra); return $att;
} }
} }
//--------------------------------------------------------------------