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
Select Git revision
  • alpha
  • beta
  • develop
  • docs/fix-readme
  • docs/update-vitepress
  • draft/rss-feed
  • feat/dashboard
  • feat/episodes-page-ux
  • feat/generator-user-agent
  • feat/headliner
  • feat/new-languages
  • feat/plugins
  • fix/federation
  • fix/forms-ux
  • i18n
  • main
  • next
  • refactor/transcripts
  • v1.0.0
  • v1.0.0-alpha.1
  • v1.0.0-alpha.10
  • v1.0.0-alpha.11
  • v1.0.0-alpha.12
  • v1.0.0-alpha.13
  • v1.0.0-alpha.14
  • v1.0.0-alpha.15
  • v1.0.0-alpha.16
  • v1.0.0-alpha.17
  • v1.0.0-alpha.18
  • v1.0.0-alpha.19
  • v1.0.0-alpha.2
  • v1.0.0-alpha.20
  • v1.0.0-alpha.21
  • v1.0.0-alpha.22
  • v1.0.0-alpha.23
  • v1.0.0-alpha.24
  • v1.0.0-alpha.25
  • v1.0.0-alpha.26
  • v1.0.0-alpha.27
  • v1.0.0-alpha.28
  • v1.0.0-alpha.29
  • v1.0.0-alpha.3
  • v1.0.0-alpha.30
  • v1.0.0-alpha.31
  • v1.0.0-alpha.32
  • v1.0.0-alpha.33
  • v1.0.0-alpha.34
  • v1.0.0-alpha.35
  • v1.0.0-alpha.36
  • v1.0.0-alpha.37
  • v1.0.0-alpha.38
  • v1.0.0-alpha.39
  • v1.0.0-alpha.4
  • v1.0.0-alpha.40
  • v1.0.0-alpha.41
  • v1.0.0-alpha.42
  • v1.0.0-alpha.43
  • v1.0.0-alpha.44
  • v1.0.0-alpha.45
  • v1.0.0-alpha.46
  • v1.0.0-alpha.47
  • v1.0.0-alpha.48
  • v1.0.0-alpha.49
  • v1.0.0-alpha.5
  • v1.0.0-alpha.50
  • v1.0.0-alpha.51
  • v1.0.0-alpha.52
  • v1.0.0-alpha.53
  • v1.0.0-alpha.54
  • v1.0.0-alpha.55
  • v1.0.0-alpha.56
  • v1.0.0-alpha.57
  • v1.0.0-alpha.58
  • v1.0.0-alpha.59
  • v1.0.0-alpha.6
  • v1.0.0-alpha.60
  • v1.0.0-alpha.61
  • v1.0.0-alpha.62
  • v1.0.0-alpha.63
  • v1.0.0-alpha.64
  • v1.0.0-alpha.65
  • v1.0.0-alpha.66
  • v1.0.0-alpha.67
  • v1.0.0-alpha.68
  • v1.0.0-alpha.69
  • v1.0.0-alpha.7
  • v1.0.0-alpha.70
  • v1.0.0-alpha.71
  • v1.0.0-alpha.72
  • v1.0.0-alpha.73
  • v1.0.0-alpha.74
  • v1.0.0-alpha.75
  • v1.0.0-alpha.76
  • v1.0.0-alpha.77
  • v1.0.0-alpha.78
  • v1.0.0-alpha.79
  • v1.0.0-alpha.8
  • v1.0.0-alpha.80
  • v1.0.0-alpha.9
  • v1.0.0-beta.1
  • v1.0.0-beta.10
  • v1.0.0-beta.11
  • v1.0.0-beta.12
  • v1.0.0-beta.13
  • v1.0.0-beta.14
  • v1.0.0-beta.15
  • v1.0.0-beta.16
  • v1.0.0-beta.17
  • v1.0.0-beta.18
  • v1.0.0-beta.19
  • v1.0.0-beta.2
  • v1.0.0-beta.20
  • v1.0.0-beta.21
  • v1.0.0-beta.22
  • v1.0.0-beta.23
  • v1.0.0-beta.24
  • v1.0.0-beta.3
  • v1.0.0-beta.4
118 results

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
Select Git revision
Show changes
Showing
with 2380 additions and 834 deletions
<?php
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\Controllers\Admin;
namespace App\Entities\Clip;
class Home extends BaseController
class Soundbite extends BaseClip
{
public function index()
{
return view('admin/dashboard');
}
protected string $type = 'audio';
}
<?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\Clip;
use CodeIgniter\Files\File;
use Modules\Media\Entities\Video;
use Modules\Media\Models\MediaModel;
use Override;
/**
* @property array{name:string,preview:string} $theme
* @property string $format
*/
class VideoClip extends BaseClip
{
protected string $type = 'video';
/**
* @param array<string, mixed>|null $data
*/
public function __construct(?array $data = null)
{
parent::__construct($data);
if ($this->metadata !== null && $this->metadata !== []) {
$this->theme = $this->metadata['theme'];
$this->format = $this->metadata['format'];
}
}
/**
* @param array{name:string,preview:string} $theme
*/
public function setTheme(array $theme): self
{
// TODO: change?
$this->attributes['metadata'] = json_decode($this->attributes['metadata'] ?? '[]', true);
$this->attributes['theme'] = $theme;
$this->attributes['metadata']['theme'] = $theme;
$this->attributes['metadata'] = json_encode($this->attributes['metadata']);
return $this;
}
public function setFormat(string $format): self
{
$this->attributes['metadata'] = json_decode((string) $this->attributes['metadata'], true);
$this->attributes['format'] = $format;
$this->attributes['metadata']['format'] = $format;
$this->attributes['metadata'] = json_encode($this->attributes['metadata']);
return $this;
}
#[Override]
public function setMedia(File $file, string $fileKey): static
{
if ($this->attributes['media_id'] !== null) {
// media is already set, do nothing
return $this;
}
$video = new Video([
'file_key' => $fileKey,
'language_code' => $this->getPodcast()
->language_code,
'uploaded_by' => $this->attributes['created_by'],
'updated_by' => $this->attributes['created_by'],
]);
$video->setFile($file);
$this->attributes['media_id'] = (new MediaModel('video'))->saveMedia($video);
return $this;
}
}
<?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\EpisodeModel;
use App\Models\PersonModel;
use App\Models\PodcastModel;
use CodeIgniter\Entity\Entity;
use RuntimeException;
/**
* @property int $podcast_id
* @property Podcast|null $podcast
* @property int|null $episode_id
* @property Episode|null $episode
* @property string $full_name
* @property string $person_group
* @property string $group_label
* @property string $person_role
* @property string $role_label
* @property int $person_id
* @property Person|null $person
*/
class Credit extends Entity
{
protected ?Person $person = null;
protected ?Podcast $podcast = null;
protected ?Episode $episode = null;
protected string $group_label;
protected string $role_label;
/**
* @var array<string, string>
*/
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => '?integer',
'person_id' => 'integer',
'full_name' => 'string',
'person_group' => 'string',
'person_role' => 'string',
];
public function getPerson(): ?Person
{
if ($this->person_id === null) {
throw new RuntimeException('Credit must have person_id before getting person.');
}
if (! $this->person instanceof Person) {
$this->person = (new PersonModel())->getPersonById($this->person_id);
}
return $this->person;
}
public function getPodcast(): ?Podcast
{
if ($this->podcast_id === null) {
throw new RuntimeException('Credit must have podcast_id before getting podcast.');
}
if (! $this->podcast instanceof Podcast) {
$this->podcast = (new PodcastModel())->getPodcastById($this->podcast_id);
}
return $this->podcast;
}
public function getEpisode(): ?Episode
{
if ($this->episode_id === null) {
throw new RuntimeException('Credit must have episode_id before getting episode.');
}
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getPublishedEpisodeById($this->podcast_id, $this->episode_id);
}
return $this->episode;
}
public function getGroupLabel(): string
{
if ($this->person_group === null) {
return '';
}
/** @var string */
return lang("PersonsTaxonomy.persons.{$this->person_group}.label");
}
public function getRoleLabel(): string
{
if ($this->person_group === '') {
return '';
}
if ($this->person_role === '') {
return '';
}
/** @var string */
return lang("PersonsTaxonomy.persons.{$this->person_group}.roles.{$this->person_role}.label");
}
}
<?php
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\Clip\Soundbite;
use App\Models\ClipModel;
use App\Models\EpisodeCommentModel;
use App\Models\EpisodeModel;
use App\Models\PersonModel;
use App\Models\PodcastModel;
use CodeIgniter\Entity;
use League\CommonMark\CommonMarkConverter;
use App\Models\PostModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time;
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_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|null $transcript_id
* @property Transcript|null $transcript
* @property string|null $transcript_remote_url
* @property int|null $chapters_id
* @property Chapters|null $chapters
* @property string|null $chapters_remote_url
* @property string|null $parental_advisory
* @property int $number
* @property int $season_number
* @property string $type
* @property bool $is_blocked
* @property Location|null $location
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @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 Person[] $persons
* @property Soundbite[] $soundbites
* @property string $embed_url
*/
class Episode extends Entity
{
/**
* @var \App\Entities\Podcast
*/
protected $podcast;
public string $link = '';
public string $audio_url = '';
public string $audio_web_url = '';
public string $audio_opengraph_url = '';
protected Podcast $podcast;
protected ?Audio $audio = null;
protected string $embed_url = '';
protected ?Image $cover = null;
protected ?string $description = null;
protected ?Transcript $transcript = null;
protected ?Chapters $chapters = null;
/**
* @var string
* @var Person[]|null
*/
protected $link;
protected ?array $persons = null;
/**
* @var \App\Entities\Image
* @var Soundbite[]|null
*/
protected $image;
protected ?array $soundbites = null;
/**
* @var \CodeIgniter\Files\File
* @var Post[]|null
*/
protected $enclosure;
protected ?array $posts = null;
/**
* @var string
* @var EpisodeComment[]|null
*/
protected $enclosure_media_path;
protected ?array $comments = null;
protected ?Location $location = null;
protected ?string $publication_status = null;
/**
* @var string
* @var array<int, string>
* @phpstan-var list<string>
*/
protected $enclosure_url;
protected $dates = ['published_at', 'created_at', 'updated_at'];
/**
* @var string
* @var array<string, string>
*/
protected $description_html;
protected $dates = [
'published_at',
'created_at',
'updated_at',
'deleted_at',
];
protected $casts = [
'guid' => 'string',
'slug' => 'string',
'title' => 'string',
'enclosure_uri' => 'string',
'enclosure_duration' => 'integer',
'enclosure_mimetype' => 'string',
'enclosure_filesize' => 'integer',
'description' => 'string',
'image_uri' => '?string',
'explicit' => 'boolean',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',
'block' => 'boolean',
'created_by' => 'integer',
'updated_by' => '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',
'is_published_on_hubs' => 'boolean',
'downloads_count' => 'integer',
'posts_count' => 'integer',
'comments_count' => 'integer',
'is_premium' => 'boolean',
'created_by' => 'integer',
'updated_by' => 'integer',
];
/**
* Saves an episode image
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image
*
* @param array<string, mixed> $data
*/
public function setImage($image)
#[Override]
public function injectRawData(array $data): static
{
if (
!empty($image) &&
(!($image instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$image->isValid())
) {
helper('media');
parent::injectRawData($data);
// check whether the user has inputted an image and store it
$this->attributes['image_uri'] = save_podcast_media(
$image,
$this->getPodcast()->name,
$this->attributes['slug']
);
$this->link = url_to('episode', esc($this->getPodcast()->handle, 'url'), esc($this->attributes['slug'], 'url'));
$this->image = new \App\Entities\Image(
$this->attributes['image_uri']
);
$this->image->saveSizes();
$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 instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
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->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $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) {
return $this->cover;
}
if ($this->cover_id === null) {
$this->cover = $this->getPodcast()
->getCover();
return $this->cover;
}
$this->cover = (new MediaModel('image'))->getMediaById($this->cover_id);
return $this->cover;
}
public function setAudio(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if ($this->audio_id !== 0) {
$this->getAudio()
->setFile($file);
$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;
}
public function getAudio(): Audio
{
if (! $this->audio instanceof Audio) {
$this->audio = (new MediaModel('audio'))->getMediaById($this->audio_id);
}
return $this->audio;
}
public function setTranscript(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if ($this->getTranscript() instanceof Transcript) {
$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 $this;
}
public function getImage(): \App\Entities\Image
public function getTranscript(): ?Transcript
{
if ($image_uri = $this->attributes['image_uri']) {
return new \App\Entities\Image($image_uri);
if ($this->transcript_id !== null && ! $this->transcript instanceof Transcript) {
$this->transcript = (new MediaModel('transcript'))->getMediaById($this->transcript_id);
}
return $this->getPodcast()->image;
return $this->transcript;
}
public function setChapters(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
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);
$this->attributes['chapters_id'] = (new MediaModel('chapters'))->saveMedia($chapters);
}
return $this;
}
public function getChapters(): ?Chapters
{
if ($this->chapters_id !== null && ! $this->chapters instanceof Chapters) {
$this->chapters = (new MediaModel('chapters'))->getMediaById($this->chapters_id);
}
return $this->chapters;
}
/**
* Saves an enclosure
* 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 instanceof Transcript) {
return $this->transcript->file_url;
}
return $this->transcript_remote_url;
}
/**
* Gets chapters file url from chapters file uri if it exists or returns the chapters_remote_url which can be null.
*/
public function getChaptersFileUrl(): ?string
{
if ($this->chapters instanceof Chapters) {
return $this->chapters->file_url;
}
return $this->chapters_remote_url;
}
/**
* Returns the episode's persons
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $enclosure
* @return Person[]
*/
public function getPersons(): array
{
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting persons.');
}
if ($this->persons === null) {
$this->persons = (new PersonModel())->getEpisodePersons($this->podcast_id, $this->id);
}
return $this->persons;
}
/**
* Returns the episode’s clips
*
* @return Soundbite[]
*/
public function setEnclosure($enclosure = null)
public function getSoundbites(): array
{
if (
!empty($enclosure) &&
(!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$enclosure->isValid())
) {
helper(['media', 'id3']);
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting soundbites.');
}
$enclosure_metadata = get_file_tags($enclosure);
if ($this->soundbites === null) {
$this->soundbites = (new ClipModel())->getEpisodeSoundbites($this->getPodcast()->id, $this->id);
}
$this->attributes['enclosure_uri'] = save_podcast_media(
$enclosure,
$this->getPodcast()->name,
$this->attributes['slug']
);
$this->attributes['enclosure_duration'] = round(
$enclosure_metadata['playtime_seconds']
);
$this->attributes['enclosure_mimetype'] =
$enclosure_metadata['mime_type'];
$this->attributes['enclosure_filesize'] =
$enclosure_metadata['filesize'];
return $this->soundbites;
}
return $this;
/**
* @return Post[]
*/
public function getPosts(): array
{
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting posts.');
}
if ($this->posts === null) {
$this->posts = (new PostModel())->getEpisodePosts($this->id);
}
return $this->posts;
}
public function getEnclosure()
/**
* @return EpisodeComment[]
*/
public function getComments(): array
{
return new \CodeIgniter\Files\File($this->getEnclosureMediaPath());
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting comments.');
}
if ($this->comments === null) {
$this->comments = (new EpisodeCommentModel())->getEpisodeComments($this->id);
}
return $this->comments;
}
public function getEnclosureMediaPath()
public function getEmbedUrl(?string $theme = null): string
{
helper('media');
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']));
}
return media_path($this->attributes['enclosure_uri']);
public function setGuid(?string $guid = null): static
{
$this->attributes['guid'] = $guid ?? $this->link;
return $this;
}
public function getEnclosureUrl()
public function getPodcast(): ?Podcast
{
return base_url(
route_to(
'analytics_hit',
$this->attributes['podcast_id'],
$this->attributes['id'],
$this->attributes['enclosure_uri']
)
);
return (new PodcastModel())->getPodcastById($this->podcast_id);
}
public function getLink()
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
return base_url(
route_to(
'episode',
$this->getPodcast()->name,
$this->attributes['slug']
)
);
$config = [
'html_input' => 'escape',
'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_html'] = $converter->convert($descriptionMarkdown);
return $this;
}
public function setGuid($guid = null)
public function getDescription(): string
{
return $this->attributes['guid'] = empty($guid)
? $this->getLink()
: $guid;
if ($this->description === null) {
$this->description = trim(
(string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
);
}
return $this->description;
}
public function getPodcast()
public function getPublicationStatus(): string
{
return (new PodcastModel())->getPodcastById(
$this->attributes['podcast_id']
);
if ($this->publication_status === 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 {
$this->publication_status = 'scheduled';
}
}
return $this->publication_status;
}
public function getDescriptionHtml()
/**
* Saves the location name and fetches OpenStreetMap info
*/
public function setLocation(?Location $location = null): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
if (! $location instanceof Location) {
$this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null;
$this->attributes['location_osm'] = null;
return $this;
}
if (
$descriptionFooter = $this->getPodcast()->episode_description_footer
! isset($this->attributes['location_name']) ||
$this->attributes['location_name'] !== $location->name
) {
return $converter->convertToHtml($this->attributes['description']) .
'<footer>' .
$converter->convertToHtml($descriptionFooter) .
'</footer>';
$location->fetchOsmLocation();
$this->attributes['location_name'] = $location->name;
$this->attributes['location_geo'] = $location->geo;
$this->attributes['location_osm'] = $location->osm;
}
return $converter->convertToHtml($this->attributes['description']);
return $this;
}
public function setPublishedAt($date, $time)
public function getLocation(): ?Location
{
if (empty($date)) {
$this->attributes['published_at'] = null;
} else {
$this->attributes['published_at'] = $date . ' ' . $time;
if ($this->location_name === null) {
return null;
}
return $this;
if (! $this->location instanceof Location) {
$this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
}
return $this->location;
}
public function setCreatedBy(\App\Entities\User $user)
public function getPreviewLink(): string
{
$this->attributes['created_by'] = $user->id;
if ($this->preview_id === null) {
// generate preview id
if (! $previewUUID = (new EpisodeModel())->setEpisodePreviewId($this->id)) {
throw new Exception('Could not set episode preview id');
}
return $this;
$this->preview_id = $previewUUID;
}
return url_to('episode-preview', (string) $this->preview_id);
}
public function setUpdatedBy(\App\Entities\User $user)
/**
* Returns the episode's clip count
*/
public function getClipCount(): int|string
{
$this->attributes['updated_by'] = $user->id;
if ($this->id === null) {
throw new RuntimeException('Episode must be created before getting number of video clips.');
}
return $this;
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;
class Image extends Entity
{
/**
* @var string
*/
protected $original_path;
/**
* @var string
*/
protected $original_url;
/**
* @var string
*/
protected $thumbnail_path;
/**
* @var string
*/
protected $thumbnail_url;
/**
* @var string
*/
protected $medium_path;
/**
* @var string
*/
protected $medium_url;
/**
* @var string
*/
protected $large_path;
/**
* @var string
*/
protected $large_url;
/**
* @var string
*/
protected $feed_path;
/**
* @var string
*/
protected $feed_url;
/**
* @var string
*/
protected $id3_path;
public function __construct($originalUri)
{
helper('media');
$originalPath = media_path($originalUri);
[
'filename' => $filename,
'dirname' => $dirname,
'extension' => $extension,
] = pathinfo($originalPath);
// load images extensions from config
$imageConfig = config('Images');
$thumbnailExtension = $imageConfig->thumbnailExtension;
$mediumExtension = $imageConfig->mediumExtension;
$largeExtension = $imageConfig->largeExtension;
$feedExtension = $imageConfig->feedExtension;
$id3Extension = $imageConfig->id3Extension;
$thumbnail =
$dirname . '/' . $filename . $thumbnailExtension . '.' . $extension;
$medium =
$dirname . '/' . $filename . $mediumExtension . '.' . $extension;
$large =
$dirname . '/' . $filename . $largeExtension . '.' . $extension;
$feed = $dirname . '/' . $filename . $feedExtension . '.' . $extension;
$id3 = $dirname . '/' . $filename . $id3Extension . '.' . $extension;
parent::__construct([
'original_path' => $originalPath,
'original_url' => media_url($originalUri),
'thumbnail_path' => $thumbnail,
'thumbnail_url' => base_url($thumbnail),
'medium_path' => $medium,
'medium_url' => base_url($medium),
'large_path' => $large,
'large_url' => base_url($large),
'feed_path' => $feed,
'feed_url' => base_url($feed),
'id3_path' => $id3,
]);
}
public function saveSizes()
{
// load images sizes from config
$imageConfig = config('Images');
$thumbnailSize = $imageConfig->thumbnailSize;
$mediumSize = $imageConfig->mediumSize;
$largeSize = $imageConfig->largeSize;
$feedSize = $imageConfig->feedSize;
$id3Size = $imageConfig->id3Size;
$imageService = \Config\Services::image();
$imageService
->withFile($this->attributes['original_path'])
->resize($thumbnailSize, $thumbnailSize)
->save($this->attributes['thumbnail_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($mediumSize, $mediumSize)
->save($this->attributes['medium_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($largeSize, $largeSize)
->save($this->attributes['large_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($feedSize, $feedSize)
->save($this->attributes['feed_path']);
$imageService
->withFile($this->attributes['original_path'])
->resize($id3Size, $id3Size)
->save($this->attributes['id3_path']);
}
}
<?php
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 CodeIgniter\Entity;
use CodeIgniter\Entity\Entity;
/**
* @property string $code
* @property string $native_name
*/
class Language extends Entity
{
/**
* @var array<string, string>
*/
protected $casts = [
'code' => 'string',
'code' => 'string',
'native_name' => 'string',
];
}
<?php
declare(strict_types=1);
/**
* Class AnalyticsEpisodesByCountry
* Entity for AnalyticsEpisodesByCountry
* @copyright 2020 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 CodeIgniter\Entity;
use Michalsn\Uuid\UuidEntity;
class AnalyticsEpisodesByCountry extends Entity
/**
* @property int $actor_id
* @property string $comment_id
*/
class Like extends UuidEntity
{
/**
* @var string[]
*/
protected $uuids = ['comment_id'];
/**
* @var array<string, string>
*/
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => 'integer',
'country_code' => 'string',
'date' => 'datetime',
'hits' => 'integer',
'actor_id' => 'integer',
'comment_id' => '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 CodeIgniter\Entity\Entity;
/**
* @property string $url
* @property string $name
* @property string|null $geo
* @property string|null $osm
* @property double|null $latitude
* @property double|null $longitude
*/
class Location extends Entity
{
private const string OSM_URL = 'https://www.openstreetmap.org/';
private const string NOMINATIM_URL = 'https://nominatim.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
{
if ($this->osm !== null) {
return self::OSM_URL .
[
'N' => 'node',
'W' => 'way',
'R' => 'relation',
][substr($this->osm, 0, 1)] .
'/' .
substr($this->osm, 1);
}
if ($this->geo !== null) {
return self::OSM_URL .
'#map=17/' .
str_replace(',', '/', substr($this->geo, 4));
}
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
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 CodeIgniter\Entity;
use League\CommonMark\CommonMarkConverter;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
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 string $title
* @property string $link
* @property string $slug
* @property string $content_markdown
* @property string $content_html
* @property Time $created_at
* @property Time $updated_at
* @property Time|null $delete_at
*/
class Page extends Entity
{
/**
* @var string
*/
protected $link;
protected string $link;
protected string $content_html;
/**
* @var string
* @var array<string, string>
*/
protected $content_html;
protected $casts = [
'id' => 'integer',
'title' => 'string',
'slug' => 'string',
'content' => 'string',
'id' => 'integer',
'title' => 'string',
'slug' => 'string',
'content_markdown' => 'string',
'content_html' => 'string',
];
public function getLink()
public function getLink(): string
{
return base_url($this->attributes['slug']);
return url_to('page', $this->attributes['slug']);
}
public function getContentHtml()
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->convert($contentMarkdown);
return $converter->convertToHtml($this->attributes['content']);
return $this;
}
}
<?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 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;
/**
* @property int $id
* @property string $full_name
* @property string $unique_name
* @property string|null $information_url
* @property int $avatar_id
* @property ?Image $avatar
* @property int $created_by
* @property int $updated_by
* @property object[]|null $roles
*/
class Person extends Entity
{
protected ?Image $avatar = null;
/**
* @var object[]|null
*/
protected ?array $roles = null;
/**
* @var array<string, string>
*/
protected $casts = [
'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',
];
/**
* Saves the person avatar in `public/media/persons/`
*/
public function setAvatar(UploadedFile | File|null $file = null): static
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if (array_key_exists('avatar_id', $this->attributes) && $this->attributes['avatar_id'] !== null) {
$this->getAvatar()
->setFile($file);
$this->getAvatar()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getAvatar());
} else {
$avatar = new Image([
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->personAvatarSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$avatar->setFile($file);
$this->attributes['avatar_id'] = (new MediaModel('image'))->saveMedia($avatar);
}
return $this;
}
public function getAvatar(): ?Image
{
if ($this->avatar_id === null) {
return null;
}
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
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\CategoryModel;
use App\Models\EpisodeModel;
use App\Models\PlatformModel;
use CodeIgniter\Entity;
use App\Models\UserModel;
use League\CommonMark\CommonMarkConverter;
use App\Models\PersonModel;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
use CodeIgniter\I18n\Time;
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;
/**
* @property int $id
* @property string $guid
* @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
* @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|null $banner_id
* @property ?Image $banner
* @property string $language_code
* @property int $category_id
* @property Category|null $category
* @property int[] $other_categories_ids
* @property Category[] $other_categories
* @property string|null $parental_advisory
* @property string|null $publisher
* @property string $owner_name
* @property string $owner_email
* @property string $type
* @property string|null $copyright
* @property bool $is_blocked
* @property bool $is_completed
* @property bool $is_locked
* @property string|null $imported_feed_url
* @property string|null $new_feed_url
* @property Location|null $location
* @property string|null $location_name
* @property string|null $location_geo
* @property string|null $location_osm
* @property bool $is_published_on_hubs
* @property int $created_by
* @property int $updated_by
* @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
*/
class Podcast extends Entity
{
protected string $link;
protected string $at_handle;
protected ?Actor $actor = null;
protected ?Image $cover = null;
protected ?Image $banner = null;
protected ?string $description = null;
protected ?Category $category = null;
/**
* @var string
* @var Category[]|null
*/
protected $link;
protected ?array $other_categories = null;
/**
* @var \App\Entities\Image
* @var int[]
*/
protected $image;
protected array $other_categories_ids = [];
/**
* @var \App\Entities\Episode[]
* @var Episode[]|null
*/
protected $episodes;
protected ?array $episodes = null;
/**
* @var \App\Entities\Category
* @var Person[]|null
*/
protected $category;
protected ?array $persons = null;
/**
* @var \App\Entities\User[]
* @var User[]|null
*/
protected $contributors;
protected ?array $contributors = null;
/**
* @var string
* @var Subscription[]|null
*/
protected $description_html;
protected ?array $subscriptions = null;
/**
* @var \App\Entities\Platform
* @var Platform[]|null
*/
protected $platforms;
protected ?array $podcasting_platforms = null;
protected $casts = [
'id' => 'integer',
'title' => 'string',
'name' => 'string',
'description' => 'string',
'image_uri' => 'string',
'language' => 'string',
'category_id' => 'integer',
'explicit' => 'boolean',
'author' => '?string',
'owner_name' => '?string',
'owner_email' => '?string',
'type' => 'string',
'copyright' => '?string',
'block' => 'boolean',
'complete' => 'boolean',
'episode_description_footer' => '?string',
'custom_html_head' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
'imported_feed_url' => '?string',
];
/**
* @var Platform[]|null
*/
protected ?array $social_platforms = null;
/**
* Saves a cover image to the corresponding podcast folder in `public/media/podcast_name/`
*
* @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image
*
* @var Platform[]|null
*/
protected ?array $funding_platforms = null;
protected ?Location $location = null;
protected ?string $publication_status = null;
/**
* @var array<int, string>
* @phpstan-var list<string>
*/
public function setImage($image = null)
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',
'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 getAtHandle(): string
{
if ($image) {
helper('media');
return '@' . $this->handle;
}
$this->attributes['image_uri'] = save_podcast_media(
$image,
$this->attributes['name'],
'cover'
);
$this->image = new \App\Entities\Image(
$this->attributes['image_uri']
);
$this->image->saveSizes();
public function getActor(): ?Actor
{
if ($this->actor_id === 0) {
throw new RuntimeException('Podcast 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;
}
public function setCover(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
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 getImage()
public function getCover(): Image
{
return new \App\Entities\Image($this->attributes['image_uri']);
if (! $this->cover instanceof Image) {
$cover = (new MediaModel('image'))->getMediaById($this->cover_id);
if (! $cover instanceof Image) {
throw new Exception('Could not retrieve podcast cover.');
}
$this->cover = $cover;
}
return $this->cover;
}
public function setBanner(UploadedFile | File|null $file = null): self
{
if (! $file instanceof File || ($file instanceof UploadedFile && ! $file->isValid())) {
return $this;
}
if (array_key_exists('banner_id', $this->attributes) && $this->attributes['banner_id'] !== null) {
$this->getBanner()
->setFile($file);
$this->getBanner()
->updated_by = $this->attributes['updated_by'];
(new MediaModel('image'))->updateMedia($this->getBanner());
} else {
$banner = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
'sizes' => config('Images')
->podcastBannerSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
$banner->setFile($file);
$this->attributes['banner_id'] = (new MediaModel('image'))->saveMedia($banner);
}
return $this;
}
public function getBanner(): ?Image
{
if ($this->banner_id === null) {
return null;
}
if (! $this->banner instanceof Image) {
$this->banner = (new MediaModel('image'))->getMediaById($this->banner_id);
}
return $this->banner;
}
public function getLink()
public function getLink(): string
{
return base_url(route_to('podcast', $this->attributes['name']));
return url_to('podcast-activity', $this->attributes['handle']);
}
public function getFeedUrl()
public function getFeedUrl(): string
{
return base_url(route_to('podcast_feed', $this->attributes['name']));
return url_to('podcast-rss-feed', $this->attributes['handle']);
}
/**
* Returns the podcast's episodes
*
* @return \App\Entities\Episode[]
* @return Episode[]
*/
public function getEpisodes()
public function getEpisodes(): array
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting episodes.'
);
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting episodes.');
}
if (empty($this->episodes)) {
$this->episodes = (new EpisodeModel())->getPodcastEpisodes(
$this->id,
$this->type
);
if ($this->episodes === null) {
$this->episodes = (new EpisodeModel())->getPodcastEpisodes($this->id, $this->type);
}
return $this->episodes;
}
/**
* Returns the podcast category entity
* 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
*
* @return \App\Entities\Category
* @return Person[]
*/
public function getCategory()
public function getPersons(): array
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting category.'
);
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting persons.');
}
if ($this->persons === null) {
$this->persons = (new PersonModel())->getPodcastPersons($this->id);
}
return $this->persons;
}
/**
* Returns the podcast category entity
*/
public function getCategory(): ?Category
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting category.');
}
if (empty($this->category)) {
$this->category = (new CategoryModel())->find($this->category_id);
if (! $this->category instanceof Category) {
$this->category = (new CategoryModel())->getCategoryById($this->category_id);
}
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
*
* @return \App\Entities\User[]
* @return User[]
*/
public function getContributors()
public function getContributors(): array
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcasts must be created before getting contributors.'
);
if ($this->id === null) {
throw new RuntimeException('Podcasts must be created before getting contributors.');
}
if (empty($this->contributors)) {
$this->contributors = (new UserModel())->getPodcastContributors(
$this->id
);
if ($this->contributors === null) {
$this->contributors = (new UserModel())->getPodcastContributors($this->id);
}
return $this->contributors;
}
public function getDescriptionHtml()
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
$config = [
'html_input' => 'escape',
'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);
return $converter->convertToHtml($this->attributes['description']);
$this->attributes['description_markdown'] = $descriptionMarkdown;
$this->attributes['description_html'] = $converter->convert($descriptionMarkdown);
return $this;
}
public function setCreatedBy(\App\Entities\User $user)
public function getDescription(): string
{
$this->attributes['created_by'] = $user->id;
if ($this->description === null) {
$this->description = trim(
(string) preg_replace('~\s+~', ' ', strip_tags((string) $this->attributes['description_html'])),
);
}
return $this;
return $this->description;
}
public function setUpdatedBy(\App\Entities\User $user)
public function getPublicationStatus(): string
{
$this->attributes['updated_by'] = $user->id;
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;
return $this->publication_status;
}
/**
* Returns the podcast's platform links
* Returns the podcast's podcasting platform links
*
* @return \App\Entities\Platform[]
* @return Platform[]
*/
public function getPlatforms()
public function getPodcastingPlatforms(): array
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting platform links.'
);
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting podcasting platform links.');
}
if (empty($this->platforms)) {
$this->platforms = (new PlatformModel())->getPodcastPlatformLinks(
$this->id
);
if ($this->podcasting_platforms === null) {
$this->podcasting_platforms = (new PlatformModel())->getPlatforms($this->id, 'podcasting');
}
return $this->podcasting_platforms;
}
/**
* Returns the podcast's social platform links
*
* @return Platform[]
*/
public function getSocialPlatforms(): array
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting social platform links.');
}
if ($this->social_platforms === null) {
$this->social_platforms = (new PlatformModel())->getPlatforms($this->id, 'social');
}
return $this->social_platforms;
}
/**
* Returns the podcast's funding platform links
*
* @return Platform[]
*/
public function getFundingPlatforms(): array
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting funding platform links.');
}
return $this->platforms;
if ($this->funding_platforms === null) {
$this->funding_platforms = (new PlatformModel())->getPlatforms($this->id, 'funding');
}
return $this->funding_platforms;
}
/**
* @return Category[]
*/
public function getOtherCategories(): array
{
if ($this->id === null) {
throw new RuntimeException('Podcast must be created before getting other categories.');
}
if ($this->other_categories === null) {
$this->other_categories = (new CategoryModel())->getPodcastCategories($this->id);
}
return $this->other_categories;
}
/**
* @return int[]
*/
public function getOtherCategoriesIds(): array
{
if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
}
return $this->other_categories_ids;
}
/**
* Saves the location name and fetches OpenStreetMap info
*/
public function setLocation(?Location $location = null): static
{
if (! $location instanceof Location) {
$this->attributes['location_name'] = null;
$this->attributes['location_geo'] = null;
$this->attributes['location_osm'] = null;
return $this;
}
if (
! isset($this->attributes['location_name']) ||
$this->attributes['location_name'] !== $location->name
) {
$location->fetchOsmLocation();
$this->attributes['location_name'] = $location->name;
$this->attributes['location_geo'] = $location->geo;
$this->attributes['location_osm'] = $location->osm;
}
return $this;
}
public function getLocation(): ?Location
{
if ($this->location_name === null) {
return null;
}
if (! $this->location instanceof Location) {
$this->location = new Location($this->location_name, $this->location_geo, $this->location_osm);
}
return $this->location;
}
public function getIsPremium(): bool
{
// podcast is premium if at least one of its episodes is set as premium
return (new EpisodeModel())->doesPodcastHavePremiumEpisodes($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\EpisodeModel;
use Modules\Fediverse\Entities\Post as FediversePost;
use RuntimeException;
/**
* @property int|null $episode_id
* @property Episode|null $episode
*/
class Post extends FediversePost
{
protected ?Episode $episode = null;
/**
* @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',
'favourites_count' => 'integer',
'reblogs_count' => 'integer',
'replies_count' => 'integer',
'created_by' => 'integer',
];
/**
* Returns the post's attached episode
*/
public function getEpisode(): ?Episode
{
if ($this->episode_id === null) {
throw new RuntimeException('Post must have an episode_id before getting episode.');
}
if (! $this->episode instanceof Episode) {
$this->episode = (new EpisodeModel())->getEpisodeById($this->episode_id);
}
return $this->episode;
}
}
<?php
namespace App\Entities;
use App\Models\PodcastModel;
class User extends \Myth\Auth\Entities\User
{
/**
* Per-user podcasts
* @var \App\Entities\Podcast[]
*/
protected $podcasts = [];
/**
* The podcast the user is contributing to
* @var \App\Entities\Podcast|null
*/
protected $podcast = null;
/**
* Array of field names and the type of value to cast them as
* when they are accessed.
*/
protected $casts = [
'active' => 'boolean',
'force_pass_reset' => 'boolean',
'podcast_role' => '?string',
'podcast_id' => '?integer',
];
/**
* Returns the podcasts the user is contributing to
*
* @return \App\Entities\Podcast[]
*/
public function getPodcasts()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Users must be created before getting podcasts.'
);
}
if (empty($this->podcasts)) {
$this->podcasts = (new PodcastModel())->getUserPodcasts($this->id);
}
return $this->podcasts;
}
/**
* Returns a podcast the user is contributing to
*
* @return \App\Entities\Podcast
*/
public function getPodcast()
{
if (empty($this->podcast_id)) {
throw new \RuntimeException(
'Podcast_id must be set before getting podcast.'
);
}
if (empty($this->podcast)) {
$this->podcast = (new PodcastModel())->getPodcastById(
$this->podcast_id
);
}
return $this->podcast;
}
}
<?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 Permission 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 \CodeIgniter\HTTP\RequestInterface $request
* @param array|null $params
*
* @return mixed
*/
public function before(RequestInterface $request, $params = null)
{
if (!function_exists('logged_in')) {
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]
)
) {
if ($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'));
} else {
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 \CodeIgniter\HTTP\RequestInterface $request
* @param \CodeIgniter\HTTP\ResponseInterface $response
* @param array|null $arguments
*
* @return void
*/
public function after(
RequestInterface $request,
ResponseInterface $response,
$arguments = null
) {
}
//--------------------------------------------------------------------
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
/**
* Set user country in session variable, for analytics purpose
*/
function set_user_session_country()
{
$session = \Config\Services::session();
$session->start();
$country = 'N/A';
// Finds country:
if (!$session->has('country')) {
try {
$reader = new \GeoIp2\Database\Reader(
WRITEPATH . 'uploads/GeoLite2-Country/GeoLite2-Country.mmdb'
);
$geoip = $reader->country($_SERVER['REMOTE_ADDR']);
$country = $geoip->country->isoCode;
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
$session->set('country', $country);
}
}
/**
* Set user player in session variable, for analytics purpose
*/
function set_user_session_player()
{
$session = \Config\Services::session();
$session->start();
if (!$session->has('player')) {
$session = \Config\Services::session();
$session->start();
$playerName = '- Unknown Player -';
$useragent = $_SERVER['HTTP_USER_AGENT'];
try {
$jsonUserAgents = json_decode(
file_get_contents(
WRITEPATH . 'uploads/user-agents/src/user-agents.json'
),
true
);
//Search for current HTTP_USER_AGENT in json file:
foreach ($jsonUserAgents as $player) {
foreach ($player['user_agents'] as $useragentsRegexp) {
//Does the HTTP_USER_AGENT match this regexp:
if (preg_match("#{$useragentsRegexp}#", $useragent)) {
if (isset($player['bot'])) {
//It’s a bot!
$playerName = '- Bot -';
} else {
//It isn’t a bot, we store device/os/app:
$playerName =
(isset($player['device'])
? $player['device'] . '/'
: '') .
(isset($player['os'])
? $player['os'] . '/'
: '') .
(isset($player['app']) ? $player['app'] : '?');
}
//We found it!
break 2;
}
}
}
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
if ($playerName == '- Unknown Player -') {
// Add to unknown list
try {
$db = \Config\Database::connect();
$procedureNameAUU = $db->prefixTable(
'analytics_unknown_useragents'
);
$db->query("CALL $procedureNameAUU(?)", [$useragent]);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
}
$session->set('player', $playerName);
}
}
/**
* Set user browser in session variable, for analytics purpose
*/
function set_user_session_browser()
{
$session = \Config\Services::session();
$session->start();
if (!$session->has('browser')) {
$browserName = '- Other -';
try {
$whichbrowser = new \WhichBrowser\Parser(getallheaders());
$browserName = $whichbrowser->browser->name;
} catch (\Exception $e) {
$browserName = '- Could not get browser name -';
}
if ($browserName == null) {
$browserName = '- Could not get browser name -';
}
$session->set('browser', $browserName);
}
}
/**
* Set user referer in session variable, for analytics purpose
*/
function set_user_session_referer()
{
$session = \Config\Services::session();
$session->start();
$newreferer = isset($_SERVER['HTTP_REFERER'])
? parse_url($_SERVER['HTTP_REFERER'], PHP_URL_HOST)
: '- Direct -';
$newreferer =
$newreferer == parse_url(current_url(false), PHP_URL_HOST)
? '- Direct -'
: $newreferer;
if (!$session->has('referer') or $newreferer != '- Direct -') {
$session->set('referer', $newreferer);
}
}
function webpage_hit($podcast_id)
{
$session = \Config\Services::session();
$session->start();
$db = \Config\Database::connect();
$procedureName = $db->prefixTable('analytics_website');
$db->query("call $procedureName(?,?,?,?)", [
$podcast_id,
$session->get('country'),
$session->get('browser'),
$session->get('referer'),
]);
}
function podcast_hit($p_podcast_id, $p_episode_id)
{
$session = \Config\Services::session();
$session->start();
$first_time_for_this_episode = true;
if ($session->has('episodes')) {
if (in_array($p_episode_id, $session->get('episodes'))) {
$first_time_for_this_episode = false;
} else {
$session->push('episodes', [$p_episode_id]);
}
} else {
$session->set('episodes', [$p_episode_id]);
}
if ($first_time_for_this_episode) {
$db = \Config\Database::connect();
$procedureName = $db->prefixTable('analytics_podcasts');
try {
$db->query("CALL $procedureName(?,?,?,?);", [
$p_podcast_id,
$p_episode_id,
$session->get('country'),
$session->get('player'),
]);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
declare(strict_types=1);
use Config\Services;
/**
* Returns the inline svg icon
*
* @param string $name name of the icon file without the .svg extension
* @param string $class to be added to the svg string
* @return string html breadcrumb
*/
function render_breadcrumb()
{
$breadcrumb = Services::breadcrumb();
return $breadcrumb->render();
if (! function_exists('render_breadcrumb')) {
/**
* Renders the breadcrumb navigation through the Breadcrumb service
*
* @param string|null $class to be added to the breadcrumb nav
* @return string html breadcrumb
*/
function render_breadcrumb(?string $class = null): string
{
return service('breadcrumb')->render($class);
}
}
function replace_breadcrumb_params($newParams)
{
$breadcrumb = Services::breadcrumb();
$breadcrumb->replaceParams($newParams);
if (! function_exists('replace_breadcrumb_params')) {
/**
* @param array<string|int,string> $newParams
*/
function replace_breadcrumb_params(array $newParams): void
{
service('breadcrumb')->replaceParams($newParams);
}
}
<?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/
*/
use App\Entities\Category;
use App\Entities\Episode;
use App\Entities\Location;
use CodeIgniter\I18n\Time;
use CodeIgniter\View\Table;
// ------------------------------------------------------------------------
if (! function_exists('data_table')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @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 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 = '', mixed ...$rest): string
{
$table = new Table();
$template = [
'table_open' => '<table class="w-full whitespace-nowrap">',
'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">',
'row_start' => '<tr class="border-t border-subtle hover:bg-base">',
'row_alt_start' => '<tr class="border-t border-subtle hover:bg-base">',
];
$table->setTemplate($template);
$tableHeaders = [];
foreach ($columns as $column) {
$tableHeaders[] = $column['header'];
}
$table->setHeading($tableHeaders);
if (($dataCount = count($data)) !== 0) {
for ($i = 0; $i < $dataCount; ++$i) {
$row = $data[$i];
$rowData = [];
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'),
],
]);
}
return '<div class="overflow-x-auto rounded-lg bg-elevated border-3 border-subtle ' . $class . '" >' .
$table->generate() .
'</div>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('publication_pill')) {
/**
* Publication pill component
*
* Shows the stylized publication datetime in regards to current datetime.
*/
function publication_pill(?Time $publicationDate, string $publicationStatus, string $customClass = ''): string
{
$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);
// @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>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('publication_button')) {
/**
* 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
{
switch ($publicationStatus) {
case 'not_published':
$label = lang('Episode.publish');
$route = route_to('episode-publish', $podcastId, $episodeId);
$variant = 'primary';
$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-fill'; // @icon("upload-cloud-fill")
break;
case 'published':
$label = lang('Episode.unpublish');
$route = route_to('episode-unpublish', $podcastId, $episodeId);
$variant = 'danger';
$iconLeft = 'cloud-off-fill'; // @icon("cloud-off-fill")
break;
default:
$label = '';
$route = '';
$variant = '';
$iconLeft = '';
break;
}
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;
}
}
// ------------------------------------------------------------------------
if (! function_exists('episode_numbering')) {
/**
* Returns relevant translated episode numbering.
*
* @param bool $isAbbr component will show abbreviated numbering if true
*/
function episode_numbering(
?int $episodeNumber = null,
?int $seasonNumber = null,
string $class = '',
bool $isAbbr = false,
): string {
if (! $episodeNumber && ! $seasonNumber) {
return '';
}
$transKey = '';
$args = [];
if ($episodeNumber !== null) {
$args['episodeNumber'] = sprintf('%02d', $episodeNumber);
}
if ($seasonNumber !== null) {
$args['seasonNumber'] = sprintf('%02d', $seasonNumber);
}
if ($episodeNumber !== null && $seasonNumber !== null) {
$transKey = 'Episode.season_episode';
} elseif ($episodeNumber !== null && $seasonNumber === null) {
$transKey = 'Episode.number';
} elseif ($episodeNumber === null && $seasonNumber !== null) {
$transKey = 'Episode.season';
}
if ($isAbbr) {
return '<abbr class="tracking-wider ' .
$class .
'" title="' .
lang($transKey, $args) .
'" data-tooltip="bottom">' .
lang($transKey . '_abbr', $args) .
'</abbr>';
}
return '<span class="' .
$class .
'">' .
lang($transKey, $args) .
'</span>';
}
}
// ------------------------------------------------------------------------
if (! function_exists('location_link')) {
/**
* Returns link to display from location info
*/
function location_link(?Location $location, string $class = ''): string
{
if (! $location instanceof Location) {
return '';
}
return anchor(
$location->url,
icon('map-pin-2-fill', [
'class' => 'mr-2 flex-shrink-0',
]) . '<span class="truncate">' . esc($location->name) . '</span>',
[
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"),
'target' => '_blank',
'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
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;
}
}