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 1055 additions and 1112 deletions
......@@ -3,19 +3,20 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities\Clip;
use App\Entities\Media\Video;
use App\Models\MediaModel;
use CodeIgniter\Files\File;
use Modules\Media\Entities\Video;
use Modules\Media\Models\MediaModel;
use Override;
/**
* @property array $theme
* @property array{name:string,preview:string} $theme
* @property string $format
*/
class VideoClip extends BaseClip
......@@ -25,7 +26,7 @@ class VideoClip extends BaseClip
/**
* @param array<string, mixed>|null $data
*/
public function __construct(array $data = null)
public function __construct(?array $data = null)
{
parent::__construct($data);
......@@ -36,7 +37,7 @@ class VideoClip extends BaseClip
}
/**
* @param array<string, string> $theme
* @param array{name:string,preview:string} $theme
*/
public function setTheme(array $theme): self
{
......@@ -53,7 +54,7 @@ class VideoClip extends BaseClip
public function setFormat(string $format): self
{
$this->attributes['metadata'] = json_decode($this->attributes['metadata'], true);
$this->attributes['metadata'] = json_decode((string) $this->attributes['metadata'], true);
$this->attributes['format'] = $format;
$this->attributes['metadata']['format'] = $format;
......@@ -63,25 +64,24 @@ class VideoClip extends BaseClip
return $this;
}
public function setMedia(string $filePath = null): static
#[Override]
public function setMedia(File $file, string $fileKey): static
{
if ($filePath === null) {
if ($this->attributes['media_id'] !== null) {
// media is already set, do nothing
return $this;
}
helper('media');
$file = new File(media_path($filePath));
$video = new Video([
'file_path' => $filePath,
'file_key' => $fileKey,
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => $this->attributes['created_by'],
'updated_by' => $this->attributes['created_by'],
'updated_by' => $this->attributes['created_by'],
]);
$video->setFile($file);
$this->attributes['media_id'] = (new MediaModel())->saveMedia($video);
$this->attributes['media_id'] = (new MediaModel('video'))->saveMedia($video);
return $this;
}
......
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -45,12 +45,12 @@ class Credit extends Entity
* @var array<string, string>
*/
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => '?integer',
'person_id' => 'integer',
'full_name' => 'string',
'podcast_id' => 'integer',
'episode_id' => '?integer',
'person_id' => 'integer',
'full_name' => 'string',
'person_group' => 'string',
'person_role' => 'string',
'person_role' => 'string',
];
public function getPerson(): ?Person
......@@ -98,6 +98,7 @@ class Credit extends Entity
return '';
}
/** @var string */
return lang("PersonsTaxonomy.persons.{$this->person_group}.label");
}
......@@ -111,6 +112,7 @@ class Credit extends Entity
return '';
}
/** @var string */
return lang("PersonsTaxonomy.persons.{$this->person_group}.roles.{$this->person_role}.label");
}
}
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -11,14 +11,9 @@ declare(strict_types=1);
namespace App\Entities;
use App\Entities\Clip\Soundbite;
use App\Entities\Media\Audio;
use App\Entities\Media\Chapters;
use App\Entities\Media\Image;
use App\Entities\Media\Transcript;
use App\Libraries\SimpleRSSElement;
use App\Models\ClipModel;
use App\Models\EpisodeCommentModel;
use App\Models\MediaModel;
use App\Models\EpisodeModel;
use App\Models\PersonModel;
use App\Models\PodcastModel;
use App\Models\PostModel;
......@@ -26,27 +21,41 @@ use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
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;
/**
* @property int $id
* @property int $podcast_id
* @property Podcast $podcast
* @property ?string $preview_id
* @property string $preview_link
* @property string $link
* @property string $guid
* @property string $slug
* @property string $title
* @property int $audio_id
* @property Audio $audio
* @property string $audio_file_analytics_url
* @property string $audio_file_web_url
* @property string $audio_file_opengraph_url
* @property ?Audio $audio
* @property string $audio_url
* @property string $audio_web_url
* @property string $audio_opengraph_url
* @property string|null $description Holds text only description, striped of any markdown or html special characters
* @property string $description_markdown
* @property string $description_html
* @property int $cover_id
* @property Image $cover
* @property ?int $cover_id
* @property ?Image $cover
* @property int|null $transcript_id
* @property Transcript|null $transcript
* @property string|null $transcript_remote_url
......@@ -62,39 +71,38 @@ use RuntimeException;
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @property array|null $custom_rss
* @property string $custom_rss_string
* @property bool $is_published_on_hubs
* @property int $downloads_count
* @property int $posts_count
* @property int $comments_count
* @property EpisodeComment[]|null $comments
* @property bool $is_premium
* @property int $created_by
* @property int $updated_by
* @property string $publication_status;
* @property Time|null $published_at;
* @property Time $created_at;
* @property Time $updated_at;
* @property Time|null $deleted_at;
* @property string $publication_status
* @property Time|null $published_at
* @property Time $created_at
* @property Time $updated_at
*
* @property Person[] $persons;
* @property Soundbite[] $soundbites;
* @property string $embed_url;
* @property Person[] $persons
* @property Soundbite[] $soundbites
* @property string $embed_url
*/
class Episode extends Entity
{
protected Podcast $podcast;
protected string $link;
public string $link = '';
protected ?Audio $audio = null;
public string $audio_url = '';
protected string $audio_url;
public string $audio_web_url = '';
protected string $audio_analytics_url;
public string $audio_opengraph_url = '';
protected string $audio_web_url;
protected Podcast $podcast;
protected string $audio_opengraph_url;
protected ?Audio $audio = null;
protected string $embed_url;
protected string $embed_url = '';
protected ?Image $cover = null;
......@@ -126,50 +134,77 @@ class Episode extends Entity
protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null;
/**
* @var string[]
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $dates = ['published_at', 'created_at', 'updated_at', 'deleted_at'];
protected $dates = ['published_at', 'created_at', 'updated_at'];
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'podcast_id' => 'integer',
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
'audio_id' => 'integer',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => '?integer',
'transcript_id' => '?integer',
'id' => 'integer',
'podcast_id' => 'integer',
'preview_id' => '?string',
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
'audio_id' => 'integer',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => '?integer',
'transcript_id' => '?integer',
'transcript_remote_url' => '?string',
'chapters_id' => '?integer',
'chapters_remote_url' => '?string',
'parental_advisory' => '?string',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',
'is_blocked' => 'boolean',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'custom_rss' => '?json-array',
'posts_count' => 'integer',
'comments_count' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
'chapters_id' => '?integer',
'chapters_remote_url' => '?string',
'parental_advisory' => '?string',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',
'is_blocked' => 'boolean',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'is_published_on_hubs' => 'boolean',
'downloads_count' => 'integer',
'posts_count' => 'integer',
'comments_count' => 'integer',
'is_premium' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function setCover(UploadedFile | File $file = null): self
/**
* @param array<string, mixed> $data
*/
#[Override]
public function injectRawData(array $data): static
{
parent::injectRawData($data);
$this->link = url_to('episode', esc($this->getPodcast()->handle, 'url'), esc($this->attributes['slug'], 'url'));
$this->audio_url = url_to(
'episode-audio',
$this->getPodcast()
->handle,
$this->slug,
$this->getAudio()
->file_extension,
);
$this->audio_opengraph_url = $this->audio_url . '?_from=-+Open+Graph+-';
$this->audio_web_url = $this->audio_url . '?_from=-+Website+-';
return $this;
}
public function setCover(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
......@@ -177,16 +212,15 @@ class Episode extends Entity
$this->getCover()
->setFile($file);
$this->getCover()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover());
} else {
$cover = new Image([
'file_name' => $this->attributes['slug'],
'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
'sizes' => config('Images')
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->podcastCoverSizes,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$cover->setFile($file);
......@@ -214,9 +248,9 @@ class Episode extends Entity
return $this->cover;
}
public function setAudio(UploadedFile | File $file = null): self
public function setAudio(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
......@@ -224,16 +258,15 @@ class Episode extends Entity
$this->getAudio()
->setFile($file);
$this->getAudio()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('audio'))->updateMedia($this->getAudio());
} else {
$audio = new Audio([
'file_name' => $this->attributes['slug'],
'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $file->getRandomName(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$audio->setFile($file);
......@@ -252,30 +285,29 @@ class Episode extends Entity
return $this->audio;
}
public function setTranscript(UploadedFile | File $file = null): self
public function setTranscript(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if ($this->getTranscript() !== null) {
if ($this->getTranscript() instanceof Transcript) {
$this->getTranscript()
->setFile($file);
$this->getTranscript()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('transcript'))->updateMedia($this->getTranscript());
} else {
$transcript = new Transcript([
'file_name' => $this->attributes['slug'] . '-transcript',
'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$transcript->setFile($file);
$this->attributes['transcript_id'] = (new MediaModel())->saveMedia($transcript);
$this->attributes['transcript_id'] = (new MediaModel('transcript'))->saveMedia($transcript);
}
return $this;
......@@ -283,37 +315,36 @@ class Episode extends Entity
public function getTranscript(): ?Transcript
{
if ($this->transcript_id !== null && $this->transcript === null) {
if ($this->transcript_id !== null && ! $this->transcript instanceof Transcript) {
$this->transcript = (new MediaModel('transcript'))->getMediaById($this->transcript_id);
}
return $this->transcript;
}
public function setChapters(UploadedFile | File $file = null): self
public function setChapters(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if ($this->getChapters() !== null) {
if ($this->getChapters() instanceof Chapters) {
$this->getChapters()
->setFile($file);
$this->getChapters()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('chapters'))->updateMedia($this->getChapters());
} else {
$chapters = new Chapters([
'file_name' => $this->attributes['slug'] . '-chapters',
'file_directory' => 'podcasts/' . $this->getPodcast()->handle,
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$chapters->setFile($file);
$this->attributes['chapters_id'] = (new MediaModel())->saveMedia($chapters);
$this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters);
}
return $this;
......@@ -321,56 +352,22 @@ class Episode extends Entity
public function getChapters(): ?Chapters
{
if ($this->chapters_id !== null && $this->chapters === null) {
if ($this->chapters_id !== null && ! $this->chapters instanceof Chapters) {
$this->chapters = (new MediaModel('chapters'))->getMediaById($this->chapters_id);
}
return $this->chapters;
}
public function getAudioFileUrl(): string
{
helper('media');
return media_base_url($this->audio->file_path);
}
public function getAudioFileAnalyticsUrl(): string
{
helper('analytics');
// remove 'podcasts/' from audio file path
$strippedAudioFilePath = substr($this->getAudio()->file_path, 9);
return generate_episode_analytics_url(
$this->podcast_id,
$this->id,
$strippedAudioFilePath,
$this->audio->duration,
$this->audio->file_size,
$this->audio->header_size,
$this->published_at,
);
}
public function getAudioFileWebUrl(): string
{
return $this->getAudioFileAnalyticsUrl() . '?_from=-+Website+-';
}
public function getAudioFileOpengraphUrl(): string
{
return $this->getAudioFileAnalyticsUrl() . '?_from=-+Open+Graph+-';
}
/**
* Gets transcript url from transcript file uri if it exists or returns the transcript_remote_url which can be null.
*/
public function getTranscriptUrl(): ?string
{
if ($this->transcript !== null) {
if ($this->transcript instanceof Transcript) {
return $this->transcript->file_url;
}
return $this->transcript_remote_url;
}
......@@ -379,7 +376,7 @@ class Episode extends Entity
*/
public function getChaptersFileUrl(): ?string
{
if ($this->chapters !== null) {
if ($this->chapters instanceof Chapters) {
return $this->chapters->file_url;
}
......@@ -454,23 +451,16 @@ class Episode extends Entity
return $this->comments;
}
public function getLink(): string
public function getEmbedUrl(?string $theme = null): string
{
return url_to('episode', $this->getPodcast()->handle, $this->attributes['slug']);
}
public function getEmbedUrl(string $theme = null): string
{
return base_url(
$theme
? route_to('embed-theme', $this->getPodcast() ->handle, $this->attributes['slug'], $theme,)
: route_to('embed', $this->getPodcast()->handle, $this->attributes['slug']),
);
return $theme
? url_to('embed-theme', esc($this->getPodcast()->handle), esc($this->attributes['slug']), $theme)
: url_to('embed', esc($this->getPodcast()->handle), esc($this->attributes['slug']));
}
public function setGuid(?string $guid = null): static
{
$this->attributes['guid'] = $guid === null ? $this->getLink() : $guid;
$this->attributes['guid'] = $guid ?? $this->link;
return $this;
}
......@@ -482,50 +472,30 @@ class Episode extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
];
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml($descriptionMarkdown);
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$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
{
$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>";
}
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
return $descriptionHtml;
return $this;
}
public function getDescription(): string
{
if ($this->description === null) {
$this->description = trim(
preg_replace('~\s+~', ' ', strip_tags($this->attributes['description_html'])),
(string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
);
}
......@@ -535,8 +505,10 @@ class Episode extends Entity
public function getPublicationStatus(): string
{
if ($this->publication_status === null) {
if ($this->published_at === null) {
if (! $this->published_at instanceof Time) {
$this->publication_status = 'not_published';
} elseif ($this->getPodcast()->publication_status !== 'published') {
$this->publication_status = 'with_podcast';
} elseif ($this->published_at->isBefore(Time::now())) {
$this->publication_status = 'published';
} else {
......@@ -552,7 +524,7 @@ class Episode extends Entity
*/
public function setLocation(?Location $location = null): static
{
if ($location === null) {
if (! $location instanceof Location) {
$this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null;
$this->attributes['location_osm'] = null;
......@@ -580,89 +552,36 @@ class Episode extends Entity
return null;
}
if ($this->location === null) {
if (! $this->location instanceof Location) {
$this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
}
return $this->location;
}
/**
* Get custom rss tag as XML String
*/
public function getCustomRssString(): string
public function getPreviewLink(): string
{
if ($this->custom_rss === null) {
return '';
}
helper('rss');
if ($this->preview_id === null) {
// generate preview id
if (! $previewUUID = (new EpisodeModel())->setEpisodePreviewId($this->id)) {
throw new Exception('Could not set episode preview id');
}
$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);
$this->preview_id = $previewUUID;
}
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
*/
public function setCustomRssString(?string $customRssString = null): static
public function getClipCount(): int|string
{
if ($customRssString === '') {
$this->attributes['custom_rss'] = 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;
}
public 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;
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting number of video clips.');
}
return $partnerLink;
}
public function getPartnerImageUrl(string $serviceSlug = null): string
{
return rtrim($this->getPodcast()->partner_image_url, '/') .
'?pid=' .
$this->getPodcast()
->partner_id .
'&guid=' .
urlencode($this->attributes['guid']) .
($serviceSlug !== null ? '&_from=' . $serviceSlug : '');
return (new ClipModel())->getClipCount($this->podcast_id, $this->id);
}
}
......@@ -3,13 +3,14 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @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;
......@@ -50,7 +51,8 @@ class EpisodeComment extends UuidEntity
protected bool $has_replies = false;
/**
* @var string[]
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $dates = ['created_at'];
......@@ -58,17 +60,17 @@ class EpisodeComment extends UuidEntity
* @var array<string, string>
*/
protected $casts = [
'id' => 'string',
'uri' => 'string',
'episode_id' => 'integer',
'actor_id' => 'integer',
'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',
'message' => 'string',
'message_html' => 'string',
'likes_count' => 'integer',
'replies_count' => 'integer',
'created_by' => 'integer',
'is_from_post' => 'boolean',
];
public function getEpisode(): ?Episode
......@@ -87,14 +89,14 @@ class EpisodeComment extends UuidEntity
/**
* Returns the comment's actor
*/
public function getActor(): 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 === null) {
$this->actor = model('ActorModel', false)
if (! $this->actor instanceof Actor) {
$this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id);
}
......@@ -128,8 +130,8 @@ class EpisodeComment extends UuidEntity
throw new RuntimeException('Comment is not a reply.');
}
if ($this->reply_to_comment === null) {
$this->reply_to_comment = model('EpisodeCommentModel', false)
if (! $this->reply_to_comment instanceof self) {
$this->reply_to_comment = model(EpisodeCommentModel::class, false)
->getCommentById($this->in_reply_to_id);
}
......
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -22,7 +22,7 @@ class Language extends Entity
* @var array<string, string>
*/
protected $casts = [
'code' => 'string',
'code' => 'string',
'native_name' => 'string',
];
}
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -27,7 +27,7 @@ class Like extends UuidEntity
* @var array<string, string>
*/
protected $casts = [
'actor_id' => 'integer',
'actor_id' => 'integer',
'comment_id' => 'string',
];
}
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -11,7 +11,6 @@ declare(strict_types=1);
namespace App\Entities;
use CodeIgniter\Entity\Entity;
use Config\Services;
/**
* @property string $url
......@@ -23,15 +22,9 @@ use Config\Services;
*/
class Location extends Entity
{
/**
* @var string
*/
private const OSM_URL = 'https://www.openstreetmap.org/';
private const string OSM_URL = 'https://www.openstreetmap.org/';
/**
* @var string
*/
private const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
private const string NOMINATIM_URL = 'https://nominatim.openstreetmap.org/';
public function __construct(
protected string $name,
......@@ -42,14 +35,18 @@ class Location extends Entity
$longitude = null;
if ($geo !== null) {
$geoArray = explode(',', substr($geo, 4));
$latitude = floatval($geoArray[0]);
$longitude = floatval($geoArray[1]);
if (count($geoArray) === 2) {
$latitude = (float) $geoArray[0];
$longitude = (float) $geoArray[1];
}
}
parent::__construct([
'name' => $name,
'geo' => $geo,
'osm' => $osm,
'latitude' => $latitude,
'name' => $name,
'geo' => $geo,
'osm' => $osm,
'latitude' => $latitude,
'longitude' => $longitude,
]);
}
......@@ -81,7 +78,7 @@ class Location extends Entity
*/
public function fetchOsmLocation(): static
{
$client = Services::curlrequest();
$client = service('curlrequest');
$response = $client->request(
'GET',
......@@ -92,12 +89,12 @@ class Location extends Entity
[
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
'Accept' => 'application/json',
'Accept' => 'application/json',
],
],
);
$places = json_decode($response->getBody(), false, 512, JSON_THROW_ON_ERROR);
$places = json_decode((string) $response->getBody(), false, 512, JSON_THROW_ON_ERROR);
if ($places === []) {
return $this;
......@@ -105,16 +102,16 @@ class Location extends Entity
if (property_exists($places[0], 'lat') && $places[0]->lat !== null && (property_exists(
$places[0],
'lon'
'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'
'osm_id',
) && $places[0]->osm_id !== null)) {
$this->attributes['osm'] = strtoupper(substr($places[0]->osm_type, 0, 1)) . $places[0]->osm_id;
$this->attributes['osm'] = strtoupper(substr((string) $places[0]->osm_type, 0, 1)) . $places[0]->osm_id;
}
return $this;
......
<?php
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities\Media;
use CodeIgniter\Files\File;
/**
* @property array $sizes
*/
class Image extends BaseMedia
{
protected string $type = 'image';
public function initFileProperties(): void
{
parent::initFileProperties();
if ($this->file_path && $this->file_metadata) {
$this->sizes = $this->file_metadata['sizes'];
$this->initSizeProperties();
}
}
public function initSizeProperties(): bool
{
helper('media');
$extension = $this->file_extension;
$mimetype = $this->file_mimetype;
foreach ($this->sizes as $name => $size) {
if (array_key_exists('extension', $size)) {
$extension = $size['extension'];
}
if (array_key_exists('mimetype', $size)) {
$mimetype = $size['mimetype'];
}
$this->{$name . '_path'} = $this->file_directory . '/' . $this->file_name . '_' . $name . '.' . $extension;
$this->{$name . '_url'} = media_base_url($this->{$name . '_path'});
$this->{$name . '_mimetype'} = $mimetype;
}
return true;
}
public function setFile(File $file): self
{
parent::setFile($file);
if ($this->file_mimetype === 'image/jpeg' && $metadata = exif_read_data(
media_path($this->file_path),
null,
true
)) {
$metadata['sizes'] = $this->sizes;
$this->attributes['file_size'] = $metadata['FILE']['FileSize'];
} else {
$metadata = [
'sizes' => $this->sizes,
];
}
$this->attributes['file_metadata'] = json_encode($metadata);
$this->initFileProperties();
$this->saveSizes();
return $this;
}
public function deleteFile(): void
{
parent::deleteFile();
$this->deleteSizes();
}
private function saveSizes(): void
{
// save derived sizes
$imageService = service('image');
foreach ($this->sizes as $name => $size) {
$pathProperty = $name . '_path';
$imageService
->withFile(media_path($this->file_path))
->resize($size['width'], $size['height']);
$imageService->save(media_path($this->{$pathProperty}));
}
}
private function deleteSizes(): void
{
// delete all derived sizes
foreach (array_keys($this->sizes) as $name) {
$pathProperty = $name . '_path';
unlink(media_path($this->{$pathProperty}));
}
}
}
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -12,7 +12,12 @@ namespace App\Entities;
use CodeIgniter\Entity\Entity;
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
......@@ -35,11 +40,11 @@ class Page extends Entity
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'title' => 'string',
'slug' => 'string',
'id' => 'integer',
'title' => 'string',
'slug' => 'string',
'content_markdown' => 'string',
'content_html' => 'string',
'content_html' => 'string',
];
public function getLink(): string
......@@ -49,13 +54,20 @@ class Page extends Entity
public function setContentMarkdown(string $contentMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'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_html'] = $converter->convertToHtml($contentMarkdown);
$this->attributes['content_html'] = $converter->convert($contentMarkdown);
return $this;
}
......
......@@ -3,19 +3,19 @@
declare(strict_types=1);
/**
* @copyright 2021 Podlibre
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use App\Entities\Media\Image;
use App\Models\MediaModel;
use App\Models\PersonModel;
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;
/**
......@@ -24,7 +24,7 @@ use RuntimeException;
* @property string $unique_name
* @property string|null $information_url
* @property int $avatar_id
* @property Image $avatar
* @property ?Image $avatar
* @property int $created_by
* @property int $updated_by
* @property object[]|null $roles
......@@ -42,23 +42,23 @@ class Person extends Entity
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'full_name' => 'string',
'unique_name' => 'string',
'id' => 'integer',
'full_name' => 'string',
'unique_name' => 'string',
'information_url' => '?string',
'avatar_id' => '?int',
'podcast_id' => '?integer',
'episode_id' => '?integer',
'created_by' => 'integer',
'updated_by' => 'integer',
'avatar_id' => '?int',
'podcast_id' => '?integer',
'episode_id' => '?integer',
'created_by' => 'integer',
'updated_by' => 'integer',
];
/**
* Saves the person avatar in `public/media/persons/`
*/
public function setAvatar(UploadedFile | File $file = null): static
public function setAvatar(UploadedFile | File|null $file = null): static
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
......@@ -66,16 +66,15 @@ class Person extends Entity
$this->getAvatar()
->setFile($file);
$this->getAvatar()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getAvatar());
} else {
$avatar = new Image([
'file_name' => $this->attributes['unique_name'],
'file_directory' => 'persons',
'sizes' => config('Images')
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->personAvatarSizes,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$avatar->setFile($file);
......@@ -85,24 +84,13 @@ class Person extends Entity
return $this;
}
public function getAvatar(): Image
public function getAvatar(): ?Image
{
if ($this->attributes['avatar_id'] === null) {
helper('media');
return new Image([
'file_path' => config('Images')
->avatarDefaultPath,
'file_mimetype' => config('Images')
->avatarDefaultMimeType,
'file_size' => 0,
'file_metadata' => [
'sizes' => config('Images')
->personAvatarSizes,
],
]);
if ($this->avatar_id === null) {
return null;
}
if ($this->avatar === null) {
if (! $this->avatar instanceof Image) {
$this->avatar = (new MediaModel('image'))->getMediaById($this->avatar_id);
}
......@@ -122,7 +110,7 @@ class Person extends Entity
$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
array_key_exists('episode_id', $this->attributes) ? (int) $this->attributes['episode_id'] : null,
);
}
......
......@@ -3,27 +3,36 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @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\Entities\Media\Image;
use App\Libraries\SimpleRSSElement;
use App\Models\ActorModel;
use App\Models\CategoryModel;
use App\Models\EpisodeModel;
use App\Models\MediaModel;
use App\Models\PersonModel;
use App\Models\PlatformModel;
use App\Models\UserModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
use Modules\Auth\Entities\User;
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;
/**
......@@ -32,6 +41,7 @@ use RuntimeException;
* @property int $actor_id
* @property Actor|null $actor
* @property string $handle
* @property string $at_handle
* @property string $link
* @property string $feed_url
* @property string $title
......@@ -39,9 +49,9 @@ use RuntimeException;
* @property string $description_markdown
* @property string $description_html
* @property int $cover_id
* @property Image $cover
* @property ?Image $cover
* @property int|null $banner_id
* @property Image|null $banner
* @property ?Image $banner
* @property string $language_code
* @property int $category_id
* @property Category|null $category
......@@ -53,8 +63,6 @@ use RuntimeException;
* @property string $owner_email
* @property string $type
* @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_completed
* @property bool $is_locked
......@@ -64,21 +72,20 @@ use RuntimeException;
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @property string|null $payment_pointer
* @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 bool $is_published_on_hubs
* @property int $created_by
* @property int $updated_by
* @property Time $created_at;
* @property Time $updated_at;
* @property Time|null $deleted_at;
* @property string $publication_status
* @property bool $is_premium_by_default
* @property bool $is_premium
* @property Time|null $published_at
* @property Time $created_at
* @property Time $updated_at
*
* @property Episode[] $episodes
* @property Person[] $persons
* @property User[] $contributors
* @property Subscription[] $subscriptions
* @property Platform[] $podcasting_platforms
* @property Platform[] $social_platforms
* @property Platform[] $funding_platforms
......@@ -87,6 +94,8 @@ class Podcast extends Entity
{
protected string $link;
protected string $at_handle;
protected ?Actor $actor = null;
protected ?Image $cover = null;
......@@ -103,9 +112,9 @@ class Podcast extends Entity
protected ?array $other_categories = null;
/**
* @var string[]|null
* @var int[]
*/
protected ?array $other_categories_ids = null;
protected array $other_categories_ids = [];
/**
* @var Episode[]|null
......@@ -122,6 +131,11 @@ class Podcast extends Entity
*/
protected ?array $contributors = null;
/**
* @var Subscription[]|null
*/
protected ?array $subscriptions = null;
/**
* @var Platform[]|null
*/
......@@ -139,65 +153,71 @@ class Podcast extends Entity
protected ?Location $location = null;
protected string $custom_rss_string;
protected ?string $publication_status = null;
/**
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $dates = ['published_at', 'created_at', 'updated_at'];
/**
* @var array<string, string>
*/
protected $casts = [
'id' => 'integer',
'guid' => 'string',
'actor_id' => 'integer',
'handle' => 'string',
'title' => 'string',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => 'int',
'banner_id' => '?int',
'language_code' => 'string',
'category_id' => 'integer',
'parental_advisory' => '?string',
'publisher' => '?string',
'owner_name' => 'string',
'owner_email' => 'string',
'type' => 'string',
'copyright' => '?string',
'episode_description_footer_markdown' => '?string',
'episode_description_footer_html' => '?string',
'is_blocked' => 'boolean',
'is_completed' => 'boolean',
'is_locked' => 'boolean',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'payment_pointer' => '?string',
'custom_rss' => '?json-array',
'partner_id' => '?string',
'partner_link_url' => '?string',
'partner_image_url' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
'id' => 'integer',
'guid' => 'string',
'actor_id' => 'integer',
'handle' => 'string',
'title' => 'string',
'description_markdown' => 'string',
'description_html' => 'string',
'cover_id' => 'int',
'banner_id' => '?int',
'language_code' => 'string',
'category_id' => 'integer',
'parental_advisory' => '?string',
'publisher' => '?string',
'owner_name' => 'string',
'owner_email' => 'string',
'type' => 'string',
'copyright' => '?string',
'is_blocked' => 'boolean',
'is_completed' => 'boolean',
'is_locked' => 'boolean',
'is_premium_by_default' => 'boolean',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
'location_name' => '?string',
'location_geo' => '?string',
'location_osm' => '?string',
'is_published_on_hubs' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
public function getActor(): Actor
public function getAtHandle(): string
{
return '@' . $this->handle;
}
public function getActor(): ?Actor
{
if ($this->actor_id === 0) {
throw new RuntimeException('Podcast must have an actor_id before getting actor.');
}
if ($this->actor === null) {
$this->actor = model('ActorModel')
if (! $this->actor instanceof Actor) {
$this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id);
}
return $this->actor;
}
public function setCover(UploadedFile | File $file = null): self
public function setCover(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
......@@ -205,16 +225,15 @@ class Podcast extends Entity
$this->getCover()
->setFile($file);
$this->getCover()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getCover());
} else {
$cover = new Image([
'file_name' => 'cover',
'file_directory' => 'podcasts/' . $this->attributes['handle'],
'sizes' => config('Images')
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
'sizes' => config('Images')
->podcastCoverSizes,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$cover->setFile($file);
......@@ -227,15 +246,21 @@ class Podcast extends Entity
public function getCover(): Image
{
if (! $this->cover instanceof Image) {
$this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
$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 $file = null): self
public function setBanner(UploadedFile | File|null $file = null): self
{
if ($file === null || ($file instanceof UploadedFile && ! $file->isValid())) {
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
......@@ -243,16 +268,15 @@ class Podcast extends Entity
$this->getBanner()
->setFile($file);
$this->getBanner()
->updated_by = (int) user_id();
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getBanner());
} else {
$banner = new Image([
'file_name' => 'banner',
'file_directory' => 'podcasts/' . $this->attributes['handle'],
'sizes' => config('Images')
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
'sizes' => config('Images')
->podcastBannerSizes,
'uploaded_by' => user_id(),
'updated_by' => user_id(),
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$banner->setFile($file);
......@@ -262,20 +286,10 @@ class Podcast extends Entity
return $this;
}
public function getBanner(): Image
public function getBanner(): ?Image
{
if ($this->banner_id === null) {
return new Image([
'file_path' => config('Images')
->podcastBannerDefaultPath,
'file_mimetype' => config('Images')
->podcastBannerDefaultMimeType,
'file_size' => 0,
'file_metadata' => [
'sizes' => config('Images')
->podcastBannerSizes,
],
]);
return null;
}
if (! $this->banner instanceof Image) {
......@@ -292,7 +306,7 @@ class Podcast extends Entity
public function getFeedUrl(): string
{
return url_to('podcast_feed', $this->attributes['handle']);
return url_to('podcast-rss-feed', $this->attributes['handle']);
}
/**
......@@ -313,6 +327,18 @@ class Podcast extends Entity
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
*
......@@ -347,6 +373,24 @@ class Podcast extends Entity
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
*
......@@ -367,41 +411,21 @@ class Podcast extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'allow_unsafe_links' => false,
]);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convertToHtml($descriptionMarkdown);
];
return $this;
}
public function setEpisodeDescriptionFooterMarkdown(?string $episodeDescriptionFooterMarkdown = null): static
{
if ($episodeDescriptionFooterMarkdown === null || $episodeDescriptionFooterMarkdown === '') {
$this->attributes[
'episode_description_footer_markdown'
] = null;
$this->attributes[
'episode_description_footer_html'
] = null;
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new DisallowedRawHtmlExtension());
return $this;
}
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$converter = new MarkdownConverter($environment);
$this->attributes[
'episode_description_footer_markdown'
] = $episodeDescriptionFooterMarkdown;
$this->attributes[
'episode_description_footer_html'
] = $converter->convertToHtml($episodeDescriptionFooterMarkdown);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
return $this;
}
......@@ -410,13 +434,28 @@ class Podcast extends Entity
{
if ($this->description === null) {
$this->description = trim(
(string) preg_replace('~\s+~', ' ', strip_tags($this->attributes['description_html'])),
(string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
);
}
return $this->description;
}
public function getPublicationStatus(): string
{
if ($this->publication_status === null) {
if (! $this->published_at instanceof Time) {
$this->publication_status = 'not_published';
} elseif ($this->published_at->isBefore(Time::now())) {
$this->publication_status = 'published';
} else {
$this->publication_status = 'scheduled';
}
}
return $this->publication_status;
}
/**
* Returns the podcast's podcasting platform links
*
......@@ -429,7 +468,7 @@ class Podcast extends Entity
}
if ($this->podcasting_platforms === null) {
$this->podcasting_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'podcasting');
$this->podcasting_platforms = (new PlatformModel())->getPlatforms($this->id, 'podcasting');
}
return $this->podcasting_platforms;
......@@ -447,7 +486,7 @@ class Podcast extends Entity
}
if ($this->social_platforms === null) {
$this->social_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'social');
$this->social_platforms = (new PlatformModel())->getPlatforms($this->id, 'social');
}
return $this->social_platforms;
......@@ -465,7 +504,7 @@ class Podcast extends Entity
}
if ($this->funding_platforms === null) {
$this->funding_platforms = (new PlatformModel())->getPodcastPlatforms($this->id, 'funding');
$this->funding_platforms = (new PlatformModel())->getPlatforms($this->id, 'funding');
}
return $this->funding_platforms;
......@@ -492,7 +531,7 @@ class Podcast extends Entity
*/
public function getOtherCategoriesIds(): array
{
if ($this->other_categories_ids === null) {
if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
}
......@@ -504,7 +543,7 @@ class Podcast extends Entity
*/
public function setLocation(?Location $location = null): static
{
if ($location === null) {
if (! $location instanceof Location) {
$this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null;
$this->attributes['location_osm'] = null;
......@@ -532,59 +571,16 @@ class Podcast extends Entity
return null;
}
if ($this->location === null) {
if (! $this->location instanceof Location) {
$this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
}
return $this->location;
}
/**
* Get custom rss tag as XML String
*/
public function getCustomRssString(): string
{
if ($this->attributes['custom_rss'] === null) {
return '';
}
helper('rss');
$xmlNode = (new SimpleRSSElement(
'<?xml version="1.0" encoding="utf-8"?><rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"/>',
))->addChild('channel');
array_to_rss([
'elements' => $this->custom_rss,
], $xmlNode);
return str_replace(['<channel>', '</channel>'], '', $xmlNode->asXML());
}
/**
* Saves custom rss tag into json
*/
public function setCustomRssString(string $customRssString): static
public function getIsPremium(): bool
{
if ($customRssString === '') {
$this->attributes['custom_rss'] = 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>' .
$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;
// podcast is premium if at least one of its episodes is set as premium
return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($this->id);
}
}
......@@ -3,7 +3,7 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
......@@ -26,18 +26,18 @@ class Post extends FediversePost
* @var array<string, string>
*/
protected $casts = [
'id' => 'string',
'uri' => 'string',
'actor_id' => 'integer',
'in_reply_to_id' => '?string',
'reblog_of_id' => '?string',
'episode_id' => '?integer',
'message' => 'string',
'message_html' => 'string',
'id' => 'string',
'uri' => 'string',
'actor_id' => 'integer',
'in_reply_to_id' => '?string',
'reblog_of_id' => '?string',
'episode_id' => '?integer',
'message' => 'string',
'message_html' => 'string',
'favourites_count' => 'integer',
'reblogs_count' => 'integer',
'replies_count' => 'integer',
'created_by' => 'integer',
'reblogs_count' => 'integer',
'replies_count' => 'integer',
'created_by' => 'integer',
];
/**
......
<?php
declare(strict_types=1);
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Override;
class AllowCorsFilter implements FilterInterface
{
/**
* @param list<string>|null $arguments
*
* @return RequestInterface|ResponseInterface|string|null
*/
#[Override]
public function before(RequestInterface $request, $arguments = null)
{
return null;
}
/**
* @param list<string>|null $arguments
*
* @return ResponseInterface|null
*/
#[Override]
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
if (! $response->hasHeader('Cache-Control')) {
$response->setHeader('Cache-Control', 'public, max-age=86400');
}
$response->setHeader('Access-Control-Allow-Origin', '*') // for allowing any domain, insecure
->setHeader('Access-Control-Allow-Headers', '*') // for allowing any headers, insecure
->setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') // allows GET and OPTIONS methods only
->setHeader('Access-Control-Max-Age', '86400');
return $response;
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use CodeIgniter\Database\Exceptions\DataException;
use Modules\Auth\Entities\User;
use Modules\Fediverse\Entities\Actor;
if (! function_exists('user')) {
/**
* Returns the User instance for the current logged in user.
*/
function user(): ?User
{
$authenticate = service('authentication');
$authenticate->check();
return $authenticate->user();
}
}
if (! function_exists('set_interact_as_actor')) {
/**
* Sets the actor id of which the user is acting as
*/
function set_interact_as_actor(int $actorId): void
{
$authenticate = service('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 = service('authentication');
$authenticate->check();
$session = session();
return $session->get('interact_as_actor_id');
}
}
if (! function_exists('interact_as_actor')) {
/**
* Get the actor the user is currently interacting as
*/
function interact_as_actor(): Actor | false
{
$authenticate = service('authentication');
$authenticate->check();
$session = session();
if ($session->has('interact_as_actor_id')) {
return model('ActorModel')->getActorById($session->get('interact_as_actor_id'));
}
return false;
}
}
if (! function_exists('can_user_interact')) {
/**
* @throws DataException
*/
function can_user_interact(): bool
{
return (bool) interact_as_actor();
}
}
......@@ -2,35 +2,25 @@
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')) {
/**
* 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
*/
function render_breadcrumb(string $class = null): string
function render_breadcrumb(?string $class = null): string
{
$breadcrumb = Services::breadcrumb();
return $breadcrumb->render($class);
return service('breadcrumb')->render($class);
}
}
if (! function_exists('replace_breadcrumb_params')) {
/**
* @param string[] $newParams
* @param array<string|int,string> $newParams
*/
function replace_breadcrumb_params(array $newParams): void
{
$breadcrumb = Services::breadcrumb();
$breadcrumb->replaceParams($newParams);
service('breadcrumb')->replaceParams($newParams);
}
}
......@@ -3,41 +3,19 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Category;
use App\Entities\Episode;
use App\Entities\Location;
use CodeIgniter\I18n\Time;
use CodeIgniter\View\Table;
// ------------------------------------------------------------------------
if (! function_exists('hint_tooltip')) {
/**
* Hint component
*
* Used to produce tooltip with a question mark icon for hint texts
*
* @param string $hintText The hint text
*/
function hint_tooltip(string $hintText = '', string $class = ''): string
{
$tooltip =
'<span data-tooltip="bottom" tabindex="0" title="' .
$hintText .
'" class="inline-block align-middle opacity-75 focus:ring-accent';
if ($class !== '') {
$tooltip .= ' ' . $class;
}
return $tooltip . '">' . icon('question') . '</span>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('data_table')) {
/**
* Data table component
......@@ -48,21 +26,20 @@ if (! function_exists('data_table')) {
* @param mixed[] $data data to loop through and display in rows
* @param mixed ...$rest Any other argument to pass to the `cell` function
*/
function data_table(array $columns, array $data = [], string $class = '', ...$rest): string
function data_table(array $columns, array $data = [], string $class = '', mixed ...$rest): string
{
$table = new Table();
$template = [
'table_open' => '<table class="w-full whitespace-no-wrap">',
'table_open' => '<table class="w-full whitespace-nowrap">',
'thead_open' =>
'<thead class="text-xs font-semibold text-left uppercase text-skin-muted">',
'thead_open' => '<thead class="text-xs font-semibold text-left uppercase text-skin-muted">',
'heading_cell_start' => '<th class="px-4 py-2">',
'cell_start' => '<td class="px-4 py-2">',
'cell_alt_start' => '<td class="px-4 py-2">',
'cell_start' => '<td class="px-4 py-2">',
'cell_alt_start' => '<td class="px-4 py-2">',
'row_start' => '<tr class="border-t border-subtle hover:bg-base">',
'row_start' => '<tr class="border-t border-subtle hover:bg-base">',
'row_alt_start' => '<tr class="border-t border-subtle hover:bg-base">',
];
......@@ -82,14 +59,15 @@ if (! function_exists('data_table')) {
foreach ($columns as $column) {
$rowData[] = $column['cell']($row, ...$rest);
}
$table->addRow($rowData);
}
} else {
$table->addRow([
[
'colspan' => count($tableHeaders),
'class' => 'px-4 py-2 italic font-semibold text-center',
'data' => lang('Common.no_data'),
'class' => 'px-4 py-2 italic font-semibold text-center',
'data' => lang('Common.no_data'),
],
]);
}
......@@ -110,22 +88,29 @@ if (! function_exists('publication_pill')) {
*/
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{
$class = match ($publicationStatus) {
'published' => 'text-pine-500 border-pine-500 bg-pine-50',
'scheduled' => 'text-red-600 border-red-600 bg-red-50',
'not_published' => 'text-gray-600 border-gray-600 bg-gray-50',
default => 'text-gray-600 border-gray-600 bg-gray-50',
$variant = match ($publicationStatus) {
'published' => 'success',
'scheduled' => 'warning',
'with_podcast' => 'info',
'not_published' => 'default',
default => 'default',
};
$title = match ($publicationStatus) {
'published', 'scheduled' => (string) $publicationDate,
'with_podcast' => lang('Episode.with_podcast_hint'),
'not_published' => '',
default => '',
};
$label = lang('Episode.publication_status.' . $publicationStatus);
return '<span ' . ($publicationDate === null ? '' : 'title="' . $publicationDate . '"') . ' class="px-1 font-semibold border rounded ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
// @icon("error-warning-fill")
return '<x-Pill ' . ($title === '' ? '' : 'title="' . $title . '"') . ' variant="' . $variant . '" class="' . $customClass .
'">' . $label . ($publicationStatus === 'with_podcast' ? icon('error-warning-fill', [
'class' => 'flex-shrink-0 ml-1 text-lg',
]) : '') .
'</x-Pill>';
}
}
......@@ -133,31 +118,31 @@ if (! function_exists('publication_pill')) {
if (! function_exists('publication_button')) {
/**
* Publication button component
* Publication button component for episodes
*
* Displays the appropriate publication button depending on the publication post.
*/
function publication_button(int $podcastId, int $episodeId, string $publicationStatus): string
{
/** @phpstan-ignore-next-line */
switch ($publicationStatus) {
case 'not_published':
$label = lang('Episode.publish');
$route = route_to('episode-publish', $podcastId, $episodeId);
$variant = 'primary';
$iconLeft = 'upload-cloud';
$iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break;
case 'with_podcast':
case 'scheduled':
$label = lang('Episode.publish_edit');
$route = route_to('episode-publish_edit', $podcastId, $episodeId);
$variant = 'warning';
$iconLeft = 'upload-cloud';
$iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break;
case 'published':
$label = lang('Episode.unpublish');
$route = route_to('episode-unpublish', $podcastId, $episodeId);
$variant = 'danger';
$iconLeft = 'cloud-off';
$iconLeft = 'cloud-off-fill'; // @icon("cloud-off-fill")
break;
default:
$label = '';
......@@ -167,9 +152,106 @@ if (! function_exists('publication_button')) {
break;
}
return <<<CODE_SAMPLE
<Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</Button>
CODE_SAMPLE;
return <<<HTML
<x-Button variant="{$variant}" uri="{$route}" iconLeft="{$iconLeft}" >{$label}</x-Button>
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;
}
}
......@@ -185,7 +267,7 @@ if (! function_exists('episode_numbering')) {
?int $episodeNumber = null,
?int $seasonNumber = null,
string $class = '',
bool $isAbbr = false
bool $isAbbr = false,
): string {
if (! $episodeNumber && ! $seasonNumber) {
return '';
......@@ -235,19 +317,20 @@ if (! function_exists('location_link')) {
*/
function location_link(?Location $location, string $class = ''): string
{
if ($location === null) {
if (! $location instanceof Location) {
return '';
}
return anchor(
$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' =>
'inline-flex items-baseline hover:underline focus:ring-accent' .
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"),
'target' => '_blank',
'rel' => 'noreferrer noopener',
'rel' => 'noreferrer noopener',
],
);
}
......@@ -264,12 +347,11 @@ if (! function_exists('audio_player')) {
$language = service('request')
->getLocale();
return <<<CODE_SAMPLE
return <<<HTML
<vm-player
id="castopod-vm-player"
theme="light"
language="{$language}"
icons="castopod-icons"
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));"
>
......@@ -277,7 +359,7 @@ if (! function_exists('audio_player')) {
<source src="{$source}" type="{$mediaType}" />
</vm-audio>
<vm-ui>
<vm-icon-library name="castopod-icons"></vm-icon-library>
<vm-icon-library></vm-icon-library>
<vm-controls full-width>
<vm-playback-control></vm-playback-control>
<vm-volume-control></vm-volume-control>
......@@ -289,7 +371,7 @@ if (! function_exists('audio_player')) {
</vm-controls>
</vm-ui>
</vm-player>
CODE_SAMPLE;
HTML;
}
}
......@@ -299,20 +381,127 @@ if (! function_exists('relative_time')) {
function relative_time(Time $time, string $class = ''): string
{
$formatter = new IntlDateFormatter(service(
'request'
'request',
)->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::NONE);
$translatedDate = $time->toLocalizedString($formatter->getPattern());
$datetime = $time->format(DateTime::ISO8601);
$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;
}
}
// ------------------------------------------------------------------------
return <<<CODE_SAMPLE
<time-ago class="{$class}" datetime="{$datetime}">
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
itemprop="published"
datetime="{$datetime}"
title="{$time}">{$translatedDate}</time>
</time-ago>
CODE_SAMPLE;
</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
declare(strict_types=1);
if (! function_exists('form_textarea')) {
/**
* Adapted textarea field from CI4 core: without value escaping.
*/
function form_textarea(mixed $data = '', string $value = '', mixed $extra = ''): string
{
$defaults = [
'name' => is_array($data) ? '' : $data,
'cols' => '40',
'rows' => '10',
];
if (! is_array($data) || ! isset($data['value'])) {
$val = $value;
} else {
$val = $data['value'];
unset($data['value']); // textareas don't use the value attribute
}
// Unsets default rows and cols if defined in extra field as array or string.
if ((is_array($extra) && array_key_exists('rows', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra),
'rows=',
) !== false)) {
unset($defaults['rows']);
}
if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
(string) preg_replace('~\s+~', '', $extra),
'cols=',
) !== false)) {
unset($defaults['cols']);
}
return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra,
) . '>' . $val . "</textarea>\n";
}
}
if (! function_exists('parse_form_attributes')) {
/**
* Parse the form attributes
*
* Helper function used by some of the form helpers
*
* @param array<string, string>|string $attributes List of attributes
* @param array<string, mixed> $default Default values
*/
function parse_form_attributes(array|string $attributes, array $default): string
{
if (is_array($attributes)) {
foreach (array_keys($default) as $key) {
if (isset($attributes[$key])) {
$default[$key] = $attributes[$key];
unset($attributes[$key]);
}
}
if ($attributes !== []) {
$default = array_merge($default, $attributes);
}
}
$att = '';
foreach ($default as $key => $val) {
if (! is_bool($val)) {
if ($key === 'name' && ! strlen((string) $default['name'])) {
continue;
}
$att .= $key . '="' . $val . '"' . ($key === array_key_last($default) ? '' : ' ');
} else {
$att .= $key . ' ';
}
}
return $att;
}
}
......@@ -3,14 +3,14 @@
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Episode;
use CodeIgniter\Files\File;
use CodeIgniter\I18n\Time;
use JamesHeinrich\GetID3\WriteTags;
use Modules\Media\FileManagers\FileManagerInterface;
if (! function_exists('write_audio_file_tags')) {
/**
......@@ -24,15 +24,16 @@ if (! function_exists('write_audio_file_tags')) {
// Initialize getID3 tag-writing module
$tagwriter = new WriteTags();
$tagwriter->filename = media_path($episode->audio->file_path);
$tagwriter->filename = $episode->audio->file_name;
// set various options (optional)
$tagwriter->tagformats = ['id3v2.4'];
$tagwriter->tag_encoding = $TextEncoding;
$cover = new File(media_path($episode->cover->id3_path));
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$APICdata = file_get_contents($cover->getRealPath());
$APICdata = (string) $fileManager->getFileContents($episode->cover->id3_key);
// TODO: variables used for podcast specific tags
// $podcastUrl = $episode->podcast->link;
......@@ -41,24 +42,16 @@ if (! function_exists('write_audio_file_tags')) {
// populate data array
$TagData = [
'title' => [$episode->title],
'artist' => [
$episode->podcast->publisher === null
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'album' => [$episode->podcast->title],
'year' => [$episode->published_at !== null ? $episode->published_at->format('Y') : ''],
'genre' => ['Podcast'],
'comment' => [$episode->description],
'track_number' => [(string) $episode->number],
'title' => [esc($episode->title)],
'artist' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
'album' => [esc($episode->podcast->title)],
'year' => [$episode->published_at instanceof Time ? $episode->published_at->format('Y') : ''],
'genre' => ['Podcast'],
'comment' => [$episode->description],
'track_number' => [(string) $episode->number],
'copyright_message' => [$episode->podcast->copyright],
'publisher' => [
$episode->podcast->publisher === null
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'encoded_by' => ['Castopod'],
'publisher' => [$episode->podcast->publisher ?? esc($episode->podcast->owner_name)],
'encoded_by' => ['Castopod'],
// TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it
// 'website' => [$podcast_url],
......@@ -71,23 +64,21 @@ if (! function_exists('write_audio_file_tags')) {
$TagData['attached_picture'][] = [
// picturetypeid == Cover. More: module.tag.id3v2.php
'picturetypeid' => 2,
'data' => $APICdata,
'description' => 'cover',
'mime' => $cover->getMimeType(),
'data' => $APICdata,
'description' => 'cover',
'mime' => $episode->cover->file_mimetype,
];
$tagwriter->tag_data = $TagData;
// write tags
if ($tagwriter->WriteTags()) {
echo 'Successfully wrote tags<br>';
// Successfully wrote tags
if ($tagwriter->warnings !== []) {
echo 'There were some warnings:<br>' .
implode('<br><br>', $tagwriter->warnings);
log_message('warning', 'There were some warnings:' . PHP_EOL . implode(PHP_EOL, $tagwriter->warnings));
}
} else {
echo 'Failed to write tags!<br>' .
implode('<br><br>', $tagwriter->errors);
log_message('critical', 'Failed to write tags!' . PHP_EOL . implode(PHP_EOL, $tagwriter->errors));
}
}
}
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\HTTP\ResponseInterface;
use Config\Mimes;
use Config\Services;
if (! function_exists('save_media')) {
/**
* Saves a file to the corresponding podcast folder in `public/media`
*/
function save_media(File | UploadedFile $file, string $folder = '', string $filename = ''): string
{
if (($extension = $file->getExtension()) !== '') {
$filename = $filename . '.' . $extension;
}
$mediaRoot = config('App')
->mediaRoot . '/' . $folder;
if (! file_exists($mediaRoot)) {
mkdir($mediaRoot, 0777, true);
}
if (! file_exists($mediaRoot . '/index.html')) {
touch($mediaRoot . '/index.html');
}
// move to media folder, overwrite file if already existing
$file->move($mediaRoot . '/', $filename, true);
return $folder . '/' . $filename;
}
}
if (! function_exists('download_file')) {
function download_file(string $fileUrl, string $mimetype = ''): File
{
$client = Services::curlrequest();
$response = $client->get($fileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
]);
// redirect to new file location
$newFileUrl = $fileUrl;
while (
in_array(
$response->getStatusCode(),
[
ResponseInterface::HTTP_MOVED_PERMANENTLY,
ResponseInterface::HTTP_FOUND,
ResponseInterface::HTTP_SEE_OTHER,
ResponseInterface::HTTP_NOT_MODIFIED,
ResponseInterface::HTTP_TEMPORARY_REDIRECT,
ResponseInterface::HTTP_PERMANENT_REDIRECT,
],
true,
)
) {
$newFileUrl = trim($response->getHeader('location')->getValue());
$response = $client->get($newFileUrl, [
'headers' => [
'User-Agent' => 'Castopod/' . CP_VERSION,
],
'http_errors' => false,
]);
}
$fileExtension = pathinfo(parse_url($newFileUrl, PHP_URL_PATH), PATHINFO_EXTENSION);
$extension = $fileExtension === '' ? Mimes::guessExtensionFromType($mimetype) : $fileExtension;
$tmpFilename =
time() .
'_' .
bin2hex(random_bytes(10)) .
'.' .
$extension;
$tmpFilePath = WRITEPATH . 'uploads/' . $tmpFilename;
file_put_contents($tmpFilePath, $response->getBody());
return new File($tmpFilePath);
}
}
if (! function_exists('media_path')) {
/**
* Prefixes the root media path to a given uri
*
* @param string|string[] $uri URI string or array of URI segments
*/
function media_path(string | array $uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
return config('App')->mediaRoot . '/' . $uri;
}
}
if (! function_exists('media_base_url')) {
/**
* Return the media base URL to use in views
*
* @param string|string[] $uri URI string or array of URI segments
*/
function media_base_url(string | array $uri = ''): string
{
// convert segment array to string
if (is_array($uri)) {
$uri = implode('/', $uri);
}
$uri = trim($uri, '/');
$appConfig = config('App');
$mediaBaseUrl = $appConfig->mediaBaseURL === '' ? $appConfig->baseURL : $appConfig->mediaBaseURL;
return rtrim($mediaBaseUrl, '/') .
'/' .
$appConfig->mediaRoot .
'/' .
$uri;
}
}
......@@ -2,20 +2,31 @@
declare(strict_types=1);
use App\Entities\Person;
use App\Entities\Podcast;
use Cocur\Slugify\Slugify;
use Config\Images;
use Modules\Media\Entities\Image;
/**
* @copyright 2020 Podlibre
* @copyright 2020 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('get_browser_language')) {
/**
* Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`
* Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`. Returns Castopod's default
* locale if `HTTP_ACCEPT_LANGUAGE` is null.
*
* @return string ISO 639-1 language code
*/
function get_browser_language(string $httpAcceptLanguage): string
function get_browser_language(?string $httpAcceptLanguage = null): string
{
if ($httpAcceptLanguage === null) {
return config('App')->defaultLocale;
}
$langs = explode(',', $httpAcceptLanguage);
return substr($langs[0], 0, 2);
......@@ -30,105 +41,8 @@ if (! function_exists('slugify')) {
$text = substr($text, 0, strrpos(substr($text, 0, $maxLength), ' '));
}
// replace non letter or digits by -
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
$unwanted = [
'Š' => 'S',
'š' => 's',
'Đ' => 'Dj',
'đ' => 'dj',
'Ž' => 'Z',
'ž' => 'z',
'Č' => 'C',
'č' => 'c',
'Ć' => 'C',
'ć' => 'c',
'À' => 'A',
'Á' => 'A',
'Â' => 'A',
'Ã' => 'A',
'Ä' => 'A',
'Å' => 'A',
'Æ' => 'AE',
'Ç' => 'C',
'È' => 'E',
'É' => 'E',
'Ê' => 'E',
'Ë' => 'E',
'Ì' => 'I',
'Í' => 'I',
'Î' => 'I',
'Ï' => 'I',
'Ñ' => 'N',
'Ò' => 'O',
'Ó' => 'O',
'Ô' => 'O',
'Õ' => 'O',
'Ö' => 'O',
'Ø' => 'O',
'Œ' => 'OE',
'Ù' => 'U',
'Ú' => 'U',
'Û' => 'U',
'Ü' => 'U',
'Ý' => 'Y',
'Þ' => 'B',
'ß' => 'Ss',
'à' => 'a',
'á' => 'a',
'â' => 'a',
'ã' => 'a',
'ä' => 'a',
'å' => 'a',
'æ' => 'ae',
'ç' => 'c',
'è' => 'e',
'é' => 'e',
'ê' => 'e',
'ë' => 'e',
'ì' => 'i',
'í' => 'i',
'î' => 'i',
'ï' => 'i',
'ð' => 'o',
'ñ' => 'n',
'ò' => 'o',
'ó' => 'o',
'ô' => 'o',
'õ' => 'o',
'ö' => 'o',
'ø' => 'o',
'œ' => 'OE',
'ù' => 'u',
'ú' => 'u',
'û' => 'u',
'ý' => 'y',
'þ' => 'b',
'ÿ' => 'y',
'Ŕ' => 'R',
'ŕ' => 'r',
'/' => '-',
' ' => '-',
];
$text = strtr($text, $unwanted);
// transliterate
$text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
// remove unwanted characters
$text = preg_replace('~[^\-\w]+~', '', $text);
// trim
$text = trim($text, '-');
// remove duplicate -
$text = preg_replace('~-+~', '-', $text);
// lowercase
$text = strtolower($text);
return $text;
$slugify = new Slugify();
return $slugify->slugify($text);
}
}
......@@ -151,19 +65,21 @@ if (! function_exists('format_duration')) {
if ($seconds < 60) {
return '0:' . sprintf('%02d', $seconds);
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i:s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('H:i:s', $seconds), '0');
}
return gmdate('H:i:s', $seconds);
}
}
if (! function_exists('format_duration_symbol')) {
/**
* Formats duration in seconds to an hh(h) mm(min) ss(s) string. Doesn't show leading zeros if any.
......@@ -177,32 +93,179 @@ if (! function_exists('format_duration_symbol')) {
if ($seconds < 60) {
return $seconds . 's';
}
if ($seconds < 3600) {
// < 1 hour: returns MM:SS
return ltrim(gmdate('i\m\i\n s\s', $seconds), '0');
}
if ($seconds < 36000) {
// < 10 hours: returns H:MM:SS
return ltrim(gmdate('h\h i\min s\s', $seconds), '0');
return ltrim(gmdate('h\h i\m\i\n s\s', $seconds), '0');
}
return gmdate('h\h i\min s\s', $seconds);
return gmdate('h\h i\m\i\n s\s', $seconds);
}
}
//--------------------------------------------------------------------
if (! function_exists('generate_random_salt')) {
function generate_random_salt(int $length = 64): string
{
$salt = '';
while (strlen($salt) < $length) {
$charNumber = random_int(33, 126);
// Exclude " ' \ `
if (! in_array($charNumber, [34, 39, 92, 96], true)) {
$salt .= chr($charNumber);
}
}
return $salt;
}
}
//--------------------------------------------------------------------
if (! function_exists('podcast_uuid')) {
if (! function_exists('file_upload_max_size')) {
/**
* Generate UUIDv5 for podcast. For more information, see
* https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid
* Returns a file size limit in bytes based on the PHP upload_max_filesize and post_max_size Adapted from:
* https://stackoverflow.com/a/25370978
*/
function podcast_uuid(string $feedUrl): string
function file_upload_max_size(): float
{
$uuid = service('uuid');
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
return $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $feedUrl)
->toString();
static $max_size = -1;
if ($max_size < 0) {
// Start with post_max_size.
$post_max_size = parse_size((string) ini_get('post_max_size'));
if ($post_max_size > 0) {
$max_size = $post_max_size;
}
// If upload_max_size is less, then reduce. Except if upload_max_size is
// zero, which indicates no limit.
$upload_max = parse_size((string) ini_get('upload_max_filesize'));
if ($upload_max > 0 && $upload_max < $max_size) {
$max_size = $upload_max;
}
}
return $max_size;
}
}
//--------------------------------------------------------------------
if (! function_exists('parse_size')) {
function parse_size(string $size): float
{
$unit = (string) preg_replace('~[^bkmgtpezy]~i', '', $size); // Remove the non-unit characters from the size.
$size = (float) preg_replace('~[^0-9\.]~', '', $size); // Remove the non-numeric characters from the size.
if ($unit !== '') {
// Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
return round($size * 1024 ** ((float) stripos('bkmgtpezy', $unit[0])));
}
return round($size);
}
}
if (! function_exists('format_bytes')) {
/**
* Adapted from https://stackoverflow.com/a/2510459
*/
function formatBytes(float $bytes, bool $is_binary = false, int $precision = 2): string
{
$units = $is_binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log($is_binary ? 1024 : 1000));
$pow = min($pow, count($units) - 1);
$bytes /= ($is_binary ? 1024 : 1000) ** $pow;
return round($bytes, $precision) . $units[$pow];
}
}
if (! function_exists('get_site_icon_url')) {
function get_site_icon_url(string $size): string
{
if (config('App')->siteIcon['ico'] === service('settings')->get('App.siteIcon')['ico']) {
// return default site icon url
return base_url(service('settings')->get('App.siteIcon')[$size]);
}
return service('file_manager')->getUrl(service('settings')->get('App.siteIcon')[$size]);
}
}
if (! function_exists('get_podcast_banner')) {
function get_podcast_banner_url(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
Images::class,
)->podcastBannerDefaultPaths['default'];
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return base_url(
change_file_path($defaultBanner['path'], '_' . $size, $sizeConfig['extension'] ?? null),
);
}
$sizeKey = $size . '_url';
return $podcast->banner->{$sizeKey};
}
}
if (! function_exists('get_podcast_banner_mimetype')) {
function get_podcast_banner_mimetype(Podcast $podcast, string $size): string
{
if (! $podcast->banner instanceof Image) {
$sizes = config('Images')
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default site icon url
return array_key_exists('mimetype', $sizeConfig) ? $sizeConfig['mimetype'] : config(
Images::class,
)->podcastBannerDefaultMimeType;
}
$mimetype = $size . '_mimetype';
return $podcast->banner->{$mimetype};
}
}
if (! function_exists('get_avatar_url')) {
function get_avatar_url(Person $person, string $size): string
{
if (! $person->avatar instanceof Image) {
$defaultAvatarPath = config('Images')
->avatarDefaultPath;
$sizes = config('Images')
->personAvatarSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
// return default avatar url
return base_url(change_file_path($defaultAvatarPath, '_' . $size, $sizeConfig['extension'] ?? null));
}
$sizeKey = $size . '_url';
return $person->avatar->{$sizeKey};
}
}