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
  • alpha
  • beta
  • develop
  • docs/update-vitepress
  • feat/dashboard
  • feat/op3
  • i18n
  • main
  • 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
108 results
Show changes
Showing
with 902 additions and 1089 deletions
......@@ -40,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
......
......@@ -10,12 +10,12 @@ declare(strict_types=1);
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,
);
}
......
......@@ -10,26 +10,29 @@ declare(strict_types=1);
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 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\Entities\User;
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;
/**
......@@ -38,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
......@@ -45,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
......@@ -59,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
......@@ -70,22 +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 bool $is_published_on_hubs
* @property string|null $partner_id
* @property string|null $partner_link_url
* @property string|null $partner_image_url
* @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
......@@ -94,6 +94,8 @@ class Podcast extends Entity
{
protected string $link;
protected string $at_handle;
protected ?Actor $actor = null;
protected ?Image $cover = null;
......@@ -110,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
......@@ -129,6 +131,11 @@ class Podcast extends Entity
*/
protected ?array $contributors = null;
/**
* @var Subscription[]|null
*/
protected ?array $subscriptions = null;
/**
* @var Platform[]|null
*/
......@@ -146,60 +153,61 @@ 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',
'is_published_on_hubs' => 'boolean',
'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',
];
/**
* @noRector ReturnTypeDeclarationRector
*/
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) {
// @phpstan-ignore-next-line
if (! $this->actor instanceof Actor) {
$this->actor = model(ActorModel::class, false)
->getActorById($this->actor_id);
}
......@@ -207,9 +215,9 @@ class Podcast extends Entity
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;
}
......@@ -217,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);
......@@ -239,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;
}
......@@ -255,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);
......@@ -274,22 +286,10 @@ class Podcast extends Entity
return $this;
}
public function getBanner(): Image
public function getBanner(): ?Image
{
if ($this->banner_id === null) {
$defaultBanner = config('Images')
->podcastBannerDefaultPaths[service('settings')->get('App.theme')] ?? config(
'Images'
)->podcastBannerDefaultPaths['default'];
return new Image([
'file_path' => $defaultBanner['path'],
'file_mimetype' => $defaultBanner['mimetype'],
'file_size' => 0,
'file_metadata' => [
'sizes' => config('Images')
->podcastBannerSizes,
],
]);
return null;
}
if (! $this->banner instanceof Image) {
......@@ -306,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']);
}
/**
......@@ -327,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
*
......@@ -361,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
*
......@@ -382,7 +412,7 @@ class Podcast extends Entity
public function setDescriptionMarkdown(string $descriptionMarkdown): static
{
$config = [
'html_input' => 'escape',
'html_input' => 'escape',
'allow_unsafe_links' => false,
];
......@@ -400,53 +430,32 @@ class Podcast extends Entity
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;
return $this;
}
$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[
'episode_description_footer_markdown'
] = $episodeDescriptionFooterMarkdown;
$this->attributes[
'episode_description_footer_html'
] = $converter->convert($episodeDescriptionFooterMarkdown);
return $this;
}
public function getDescription(): string
{
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
*
......@@ -459,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;
......@@ -477,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;
......@@ -495,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;
......@@ -518,12 +527,11 @@ class Podcast extends Entity
}
/**
* @return int[]|string[]
* @return int[]
*/
public function getOtherCategoriesIds(): array
{
if ($this->other_categories_ids === null) {
// @phpstan-ignore-next-line
if ($this->other_categories_ids === []) {
$this->other_categories_ids = array_column($this->getOtherCategories(), 'id');
}
......@@ -535,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;
......@@ -563,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 (string) 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);
}
}
......@@ -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',
];
/**
......
......@@ -2,26 +2,43 @@
declare(strict_types=1);
namespace Modules\Fediverse\Filters;
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Override;
class AllowCorsFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null): void
/**
* @param list<string>|null $arguments
*
* @return RequestInterface|ResponseInterface|string|null
*/
#[Override]
public function before(RequestInterface $request, $arguments = null)
{
// Do something here
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
/**
* @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')
->setHeader('Cache-Control', 'public, max-age=86400')
->setStatusCode(200);
->setHeader('Access-Control-Max-Age', '86400');
return $response;
}
}
<?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\Models\ActorModel;
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::class, false)->getActorById($session->get('interact_as_actor_id'));
}
return false;
}
}
if (! function_exists('can_user_interact')) {
function can_user_interact(): bool
{
return (bool) interact_as_actor();
}
}
......@@ -2,35 +2,25 @@
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 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(esc($newParams));
service('breadcrumb')->replaceParams($newParams);
}
}
......@@ -9,37 +9,13 @@ declare(strict_types=1);
*/
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
......@@ -50,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">',
];
......@@ -91,8 +66,8 @@ if (! function_exists('data_table')) {
$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'),
],
]);
}
......@@ -113,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>';
}
}
......@@ -136,7 +118,7 @@ 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.
*/
......@@ -147,19 +129,20 @@ if (! function_exists('publication_button')) {
$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 = '';
......@@ -169,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;
}
}
......@@ -187,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 '';
......@@ -237,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 flex-shrink-0') . '<span class="truncate">' . esc($location->name) . '</span>',
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 focus:ring-accent' .
'class' => 'w-full overflow-hidden inline-flex items-baseline hover:underline' .
($class === '' ? '' : " {$class}"),
'target' => '_blank',
'rel' => 'noreferrer noopener',
'rel' => 'noreferrer noopener',
],
);
}
......@@ -266,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));"
>
......@@ -279,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>
......@@ -291,7 +371,7 @@ if (! function_exists('audio_player')) {
</vm-controls>
</vm-ui>
</vm-player>
CODE_SAMPLE;
HTML;
}
}
......@@ -301,18 +381,63 @@ 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
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;
}
}
......@@ -326,25 +451,57 @@ if (! function_exists('explicit_badge')) {
}
$explicitLabel = lang('Common.explicit');
return <<<CODE_SAMPLE
return <<<HTML
<span class="px-1 text-xs font-semibold leading-tight tracking-wider uppercase border md:border-white/50 {$class}">{$explicitLabel}</span>
CODE_SAMPLE;
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, [], null, false) . ' › ';
$categoryLabel .= lang('Podcast.category_options.' . $category->parent->code) . ' › ';
}
return $categoryLabel . lang('Podcast.category_options.' . $category->code, [], null, false);
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;
}
}
......@@ -5,11 +5,8 @@ declare(strict_types=1);
if (! function_exists('form_textarea')) {
/**
* Adapted textarea field from CI4 core: without value escaping.
*
* @param mixed $data
* @param mixed $extra
*/
function form_textarea($data = '', string $value = '', $extra = ''): string
function form_textarea(mixed $data = '', string $value = '', mixed $extra = ''): string
{
$defaults = [
'name' => is_array($data) ? '' : $data,
......@@ -25,26 +22,25 @@ if (! function_exists('form_textarea')) {
// 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(
preg_replace('~\s+~', '', $extra),
'rows='
(string) preg_replace('~\s+~', '', $extra),
'rows=',
) !== false)) {
unset($defaults['rows']);
}
if ((is_array($extra) && array_key_exists('cols', $extra)) || (is_string($extra) && stripos(
preg_replace('~\s+~', '', $extra),
'cols='
(string) preg_replace('~\s+~', '', $extra),
'cols=',
) !== false)) {
unset($defaults['cols']);
}
return '<textarea ' . rtrim(parse_form_attributes($data, $defaults)) . stringify_attributes(
$extra
$extra,
) . '>' . $val . "</textarea>\n";
}
}
if (! function_exists('parse_form_attributes')) {
/**
* Parse the form attributes
......@@ -64,7 +60,7 @@ if (! function_exists('parse_form_attributes')) {
}
}
if (! empty($attributes)) {
if ($attributes !== []) {
$default = array_merge($default, $attributes);
}
}
......@@ -73,7 +69,7 @@ if (! function_exists('parse_form_attributes')) {
foreach ($default as $key => $val) {
if (! is_bool($val)) {
if ($key === 'name' && ! strlen($default['name'])) {
if ($key === 'name' && ! strlen((string) $default['name'])) {
continue;
}
......
......@@ -7,9 +7,10 @@ declare(strict_types=1);
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Episode;
use CodeIgniter\I18n\Time;
use JamesHeinrich\GetID3\WriteTags;
use Modules\Media\FileManagers\FileManagerInterface;
if (! function_exists('write_audio_file_tags')) {
/**
......@@ -23,13 +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;
$APICdata = file_get_contents(media_path($episode->cover->id3_path));
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
$APICdata = (string) $fileManager->getFileContents($episode->cover->id3_key);
// TODO: variables used for podcast specific tags
// $podcastUrl = $episode->podcast->link;
......@@ -38,24 +42,16 @@ if (! function_exists('write_audio_file_tags')) {
// populate data array
$TagData = [
'title' => [esc($episode->title)],
'artist' => [
$episode->podcast->publisher === null
? esc($episode->podcast->owner_name)
: $episode->podcast->publisher,
],
'album' => [esc($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
? esc($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],
......@@ -68,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' => $episode->cover->file_mimetype,
'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 Ad Aures
* @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,6 +2,12 @@
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 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
......@@ -10,12 +16,17 @@ declare(strict_types=1);
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);
}
}
......@@ -166,7 +80,6 @@ if (! function_exists('format_duration')) {
}
}
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.
......@@ -197,22 +110,6 @@ if (! function_exists('format_duration_symbol')) {
//--------------------------------------------------------------------
if (! function_exists('podcast_uuid')) {
/**
* Generate UUIDv5 for podcast. For more information, see
* https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid
*/
function podcast_uuid(string $feedUrl): string
{
$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();
}
}
//--------------------------------------------------------------------
if (! function_exists('generate_random_salt')) {
function generate_random_salt(int $length = 64): string
{
......@@ -231,9 +128,7 @@ if (! function_exists('generate_random_salt')) {
//--------------------------------------------------------------------
if (! function_exists('file_upload_max_size')) {
/**
* Returns a file size limit in bytes based on the PHP upload_max_filesize and post_max_size Adapted from:
* https://stackoverflow.com/a/25370978
......@@ -268,7 +163,7 @@ if (! function_exists('parse_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 * pow(1024, (float) stripos('bkmgtpezy', $unit[0])));
return round($size * 1024 ** ((float) stripos('bkmgtpezy', $unit[0])));
}
return round($size);
......@@ -279,16 +174,98 @@ if (! function_exists('format_bytes')) {
/**
* Adapted from https://stackoverflow.com/a/2510459
*/
function formatBytes(float $bytes, int $precision = 2): string
function formatBytes(float $bytes, bool $is_binary = false, int $precision = 2): string
{
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
$units = $is_binary ? ['B', 'KiB', 'MiB', 'GiB', 'TiB'] : ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = floor(($bytes ? log($bytes) : 0) / log($is_binary ? 1024 : 1000));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
$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};
}
}
......@@ -16,21 +16,36 @@ if (! function_exists('render_page_links')) {
*
* @return string html pages navigation
*/
function render_page_links(string $class = null): string
function render_page_links(?string $class = null, ?string $podcastHandle = null): string
{
$pages = (new PageModel())->findAll();
$links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
if ($podcastHandle !== null) {
$links .= anchor(route_to('podcast-links', $podcastHandle), lang('Podcast.links'), [
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
$links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
$links .= anchor(route_to('map'), lang('Page.map.title'), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
foreach ($pages as $page) {
$links .= anchor($page->link, esc($page->title), [
'class' => 'px-2 py-1 underline hover:no-underline focus:ring-accent',
'class' => 'px-2 py-1 underline hover:no-underline',
]);
}
// if set in .env, add legal notice link at the end of page links
if (config('App')->legalNoticeURL !== null) {
$links .= anchor(config('App')->legalNoticeURL, lang('Common.legal_notice'), [
'class' => 'px-2 py-1 underline hover:no-underline',
'target' => '_blank',
'rel' => 'noopener noreferrer',
]);
}
......
......@@ -7,12 +7,17 @@ declare(strict_types=1);
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Entities\Category;
use App\Entities\Location;
use App\Entities\Podcast;
use App\Libraries\SimpleRSSElement;
use App\Libraries\RssFeed;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
use Config\Mimes;
use Modules\Media\Entities\Chapters;
use Modules\Media\Entities\Transcript;
use Modules\Plugins\Core\Plugins;
use Modules\PremiumPodcasts\Entities\Subscription;
if (! function_exists('get_rss_feed')) {
/**
......@@ -21,24 +26,24 @@ if (! function_exists('get_rss_feed')) {
* @param string $serviceSlug The name of the service that fetches the RSS feed for future reference when the audio file is eventually downloaded
* @return string rss feed as xml
*/
function get_rss_feed(Podcast $podcast, string $serviceSlug = ''): string
{
$episodes = $podcast->episodes;
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
function get_rss_feed(
Podcast $podcast,
string $serviceSlug = '',
?Subscription $subscription = null,
?string $token = null,
): string {
/** @var Plugins $plugins */
$plugins = service('plugins');
$podcastNamespace =
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md';
$episodes = $podcast->episodes;
$atomNamespace = 'http://www.w3.org/2005/Atom';
$rss = new RssFeed();
$rss = new SimpleRSSElement(
"<?xml version='1.0' encoding='utf-8'?><rss version='2.0' xmlns:itunes='{$itunesNamespace}' xmlns:podcast='{$podcastNamespace}' xmlns:atom='{$atomNamespace}' xmlns:content='http://purl.org/rss/1.0/modules/content/'></rss>"
);
$plugins->rssBeforeChannel($podcast);
$channel = $rss->addChild('channel');
$atomLink = $channel->addChild('link', null, $atomNamespace);
$atomLink = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLink->addAttribute('href', $podcast->feed_url);
$atomLink->addAttribute('rel', 'self');
$atomLink->addAttribute('type', 'application/rss+xml');
......@@ -47,14 +52,14 @@ if (! function_exists('get_rss_feed')) {
$websubHubs = config('WebSub')
->hubs;
foreach ($websubHubs as $websubHub) {
$atomLinkHub = $channel->addChild('link', null, $atomNamespace);
$atomLinkHub = $channel->addChild('link', null, RssFeed::ATOM_NAMESPACE);
$atomLinkHub->addAttribute('href', $websubHub);
$atomLinkHub->addAttribute('rel', 'hub');
$atomLinkHub->addAttribute('type', 'application/rss+xml');
}
if ($podcast->new_feed_url !== null) {
$channel->addChild('new-feed-url', $podcast->new_feed_url, $itunesNamespace);
$channel->addChild('new-feed-url', $podcast->new_feed_url, RssFeed::ITUNES_NAMESPACE);
}
// the last build date corresponds to the creation of the feed.xml cache
......@@ -62,17 +67,27 @@ if (! function_exists('get_rss_feed')) {
$channel->addChild('generator', 'Castopod - https://castopod.org/');
$channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html');
$channel->addChild('guid', $podcast->guid, $podcastNamespace);
if ($podcast->guid === '') {
// FIXME: guid shouldn't be empty here as it should be filled upon Podcast creation
$uuid = service('uuid');
// 'ead4c236-bf58-58c6-a2c6-a6b28d128cb6' is the uuid of the podcast namespace
$podcast->guid = $uuid->uuid5('ead4c236-bf58-58c6-a2c6-a6b28d128cb6', $podcast->feed_url)
->toString();
(new PodcastModel())->save($podcast);
}
$channel->addChild('guid', $podcast->guid, RssFeed::PODCAST_NAMESPACE);
$channel->addChild('title', $podcast->title, null, false);
$channel->addChildWithCDATA('description', $podcast->description_html);
$itunesImage = $channel->addChild('image', null, $itunesNamespace);
$itunesImage = $channel->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$itunesImage->addAttribute('href', $podcast->cover->feed_url);
$channel->addChild('language', $podcast->language_code);
if ($podcast->location !== null) {
$locationElement = $channel->addChild('location', $podcast->location->name, $podcastNamespace);
if ($podcast->location instanceof Location) {
$locationElement = $channel->addChild('location', $podcast->location->name, RssFeed::PODCAST_NAMESPACE);
if ($podcast->location->geo !== null) {
$locationElement->addAttribute('geo', $podcast->location->geo);
}
......@@ -82,27 +97,16 @@ if (! function_exists('get_rss_feed')) {
}
}
if ($podcast->payment_pointer !== null) {
$valueElement = $channel->addChild('value', null, $podcastNamespace);
$valueElement->addAttribute('type', 'webmonetization');
$valueElement->addAttribute('method', '');
$valueElement->addAttribute('suggested', '');
$recipientElement = $valueElement->addChild('valueRecipient', null, $podcastNamespace);
$recipientElement->addAttribute('name', $podcast->owner_name);
$recipientElement->addAttribute('type', 'ILP');
$recipientElement->addAttribute('address', $podcast->payment_pointer);
$recipientElement->addAttribute('split', '100');
}
$channel
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', $podcastNamespace)
->addChild('locked', $podcast->is_locked ? 'yes' : 'no', RssFeed::PODCAST_NAMESPACE)
->addAttribute('owner', $podcast->owner_email);
if ($podcast->imported_feed_url !== null) {
$channel->addChild('previousUrl', $podcast->imported_feed_url, $podcastNamespace);
$channel->addChild('previousUrl', $podcast->imported_feed_url, RssFeed::PODCAST_NAMESPACE);
}
foreach ($podcast->podcasting_platforms as $podcastingPlatform) {
$podcastingPlatformElement = $channel->addChild('id', null, $podcastNamespace);
$podcastingPlatformElement = $channel->addChild('id', null, RssFeed::PODCAST_NAMESPACE);
$podcastingPlatformElement->addAttribute('platform', $podcastingPlatform->slug);
if ($podcastingPlatform->account_id !== null) {
$podcastingPlatformElement->addAttribute('id', $podcastingPlatform->account_id);
......@@ -113,7 +117,7 @@ if (! function_exists('get_rss_feed')) {
}
}
$castopodSocialElement = $channel->addChild('social', null, $podcastNamespace);
$castopodSocialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$castopodSocialElement->addAttribute('priority', '1');
$castopodSocialElement->addAttribute('platform', 'castopod');
$castopodSocialElement->addAttribute('protocol', 'activitypub');
......@@ -121,7 +125,7 @@ if (! function_exists('get_rss_feed')) {
$castopodSocialElement->addAttribute('accountUrl', $podcast->link);
foreach ($podcast->social_platforms as $socialPlatform) {
$socialElement = $channel->addChild('social', null, $podcastNamespace,);
$socialElement = $channel->addChild('social', null, RssFeed::PODCAST_NAMESPACE);
$socialElement->addAttribute('priority', '2');
$socialElement->addAttribute('platform', $socialPlatform->slug);
......@@ -129,7 +133,7 @@ if (! function_exists('get_rss_feed')) {
if (in_array(
$socialPlatform->slug,
['mastodon', 'peertube', 'funkwhale', 'misskey', 'mobilizon', 'pixelfed', 'plume', 'writefreely'],
true
true,
)) {
$socialElement->addAttribute('protocol', 'activitypub');
} else {
......@@ -145,41 +149,41 @@ if (! function_exists('get_rss_feed')) {
}
if ($socialPlatform->slug === 'mastodon') {
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, $podcastNamespace);
$socialSignUpelement = $socialElement->addChild('socialSignUp', null, RssFeed::PODCAST_NAMESPACE);
$socialSignUpelement->addAttribute('priority', '1');
$socialSignUpelement->addAttribute(
'homeUrl',
parse_url($socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
$socialPlatform->link_url,
PHP_URL_HOST
) . '/public'
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$socialSignUpelement->addAttribute(
'signUpUrl',
parse_url($socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
$socialPlatform->link_url,
PHP_URL_HOST
) . '/auth/sign_up'
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
$castopodSocialSignUpelement = $castopodSocialElement->addChild(
'socialSignUp',
null,
$podcastNamespace
RssFeed::PODCAST_NAMESPACE,
);
$castopodSocialSignUpelement->addAttribute('priority', '1');
$castopodSocialSignUpelement->addAttribute(
'homeUrl',
parse_url($socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
$socialPlatform->link_url,
PHP_URL_HOST
) . '/public'
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/public',
);
$castopodSocialSignUpelement->addAttribute(
'signUpUrl',
parse_url($socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
$socialPlatform->link_url,
PHP_URL_HOST
) . '/auth/sign_up'
parse_url((string) $socialPlatform->link_url, PHP_URL_SCHEME) . '://' . parse_url(
(string) $socialPlatform->link_url,
PHP_URL_HOST,
) . '/auth/sign_up',
);
}
}
......@@ -188,7 +192,7 @@ if (! function_exists('get_rss_feed')) {
$fundingPlatformElement = $channel->addChild(
'funding',
$fundingPlatform->account_id,
$podcastNamespace,
RssFeed::PODCAST_NAMESPACE,
);
$fundingPlatformElement->addAttribute('platform', $fundingPlatform->slug);
if ($fundingPlatform->link_url !== null) {
......@@ -198,9 +202,9 @@ if (! function_exists('get_rss_feed')) {
foreach ($podcast->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $channel->addChild('person', $person->full_name, $podcastNamespace,);
$personElement = $channel->addChild('person', $person->full_name, RssFeed::PODCAST_NAMESPACE);
$personElement->addAttribute('img', $person->avatar->medium_url);
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
......@@ -227,32 +231,26 @@ if (! function_exists('get_rss_feed')) {
$channel->addChild(
'explicit',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false',
$itunesNamespace,
RssFeed::ITUNES_NAMESPACE,
);
$channel->addChild(
'author',
$podcast->publisher ? $podcast->publisher : $podcast->owner_name,
$itunesNamespace,
false
);
$channel->addChild('author', $podcast->publisher ?: $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$channel->addChild('link', $podcast->link);
$owner = $channel->addChild('owner', null, $itunesNamespace);
$owner = $channel->addChild('owner', null, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('name', $podcast->owner_name, $itunesNamespace, false);
$owner->addChild('name', $podcast->owner_name, RssFeed::ITUNES_NAMESPACE, false);
$owner->addChild('email', $podcast->owner_email, RssFeed::ITUNES_NAMESPACE);
$owner->addChild('email', $podcast->owner_email, $itunesNamespace);
$channel->addChild('type', $podcast->type, $itunesNamespace);
$channel->addChild('type', $podcast->type, RssFeed::ITUNES_NAMESPACE);
$podcast->copyright &&
$channel->addChild('copyright', $podcast->copyright);
if ($podcast->is_blocked) {
$channel->addChild('block', 'Yes', $itunesNamespace);
if ($podcast->is_blocked || $subscription instanceof Subscription) {
$channel->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
if ($podcast->is_completed) {
$channel->addChild('complete', 'Yes', $itunesNamespace);
$channel->addChild('complete', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
$image = $channel->addChild('image');
......@@ -260,31 +258,36 @@ if (! function_exists('get_rss_feed')) {
$image->addChild('title', $podcast->title, null, false);
$image->addChild('link', $podcast->link);
if ($podcast->custom_rss !== null) {
array_to_rss([
'elements' => $podcast->custom_rss,
], $channel);
}
// run plugins hook at the end
$plugins->rssAfterChannel($podcast, $channel);
foreach ($episodes as $episode) {
if ($episode->is_premium && ! $subscription instanceof Subscription) {
continue;
}
$plugins->rssBeforeItem($episode);
$item = $channel->addChild('item');
$item->addChild('title', $episode->title, null, false);
$enclosure = $item->addChild('enclosure');
$enclosureParams = implode('&', array_filter([
$episode->is_premium ? 'token=' . $token : null,
$serviceSlug !== '' ? '_from=' . urlencode($serviceSlug) : null,
]));
$enclosure->addAttribute(
'url',
$episode->audio_analytics_url .
($serviceSlug === ''
? ''
: '?_from=' . urlencode($serviceSlug)),
$episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$enclosure->addAttribute('length', (string) $episode->audio->file_size);
$enclosure->addAttribute('type', $episode->audio->file_mimetype);
$item->addChild('guid', $episode->guid);
$item->addChild('pubDate', $episode->published_at->format(DATE_RFC1123));
if ($episode->location !== null) {
$locationElement = $item->addChild('location', $episode->location->name, $podcastNamespace,);
if ($episode->location instanceof Location) {
$locationElement = $item->addChild('location', $episode->location->name, RssFeed::PODCAST_NAMESPACE);
if ($episode->location->geo !== null) {
$locationElement->addAttribute('geo', $episode->location->geo);
}
......@@ -294,10 +297,10 @@ if (! function_exists('get_rss_feed')) {
}
}
$item->addChildWithCDATA('description', $episode->getDescriptionHtml($serviceSlug));
$item->addChild('duration', (string) $episode->audio->duration, $itunesNamespace);
$item->addChildWithCDATA('description', $episode->description_html);
$item->addChild('duration', (string) round($episode->audio->duration), RssFeed::ITUNES_NAMESPACE);
$item->addChild('link', $episode->link);
$episodeItunesImage = $item->addChild('image', null, $itunesNamespace);
$episodeItunesImage = $item->addChild('image', null, RssFeed::ITUNES_NAMESPACE);
$episodeItunesImage->addAttribute('href', $episode->cover->feed_url);
$episode->parental_advisory &&
......@@ -306,71 +309,89 @@ if (! function_exists('get_rss_feed')) {
$episode->parental_advisory === 'explicit'
? 'true'
: 'false',
$itunesNamespace,
RssFeed::ITUNES_NAMESPACE,
);
$episode->number &&
$item->addChild('episode', (string) $episode->number, $itunesNamespace);
$item->addChild('episode', (string) $episode->number, RssFeed::ITUNES_NAMESPACE);
$episode->season_number &&
$item->addChild('season', (string) $episode->season_number, $itunesNamespace);
$item->addChild('episodeType', $episode->type, $itunesNamespace);
$item->addChild('season', (string) $episode->season_number, RssFeed::ITUNES_NAMESPACE);
$item->addChild('episodeType', $episode->type, RssFeed::ITUNES_NAMESPACE);
// If episode is of type trailer, add podcast:trailer tag on channel level
if ($episode->type === 'trailer') {
$trailer = $channel->addChild('trailer', $episode->title, RssFeed::PODCAST_NAMESPACE);
$trailer->addAttribute('pubdate', $episode->published_at->format(DATE_RFC2822));
$trailer->addAttribute(
'url',
$episode->audio_url . ($enclosureParams === '' ? '' : '?' . $enclosureParams),
);
$trailer->addAttribute('length', (string) $episode->audio->file_size);
$trailer->addAttribute('type', $episode->audio->file_mimetype);
if ($episode->season_number !== null) {
$trailer->addAttribute('season', (string) $episode->season_number);
}
}
// add link to episode comments as podcast-activity format
$comments = $item->addChild('comments', null, $podcastNamespace);
$comments = $item->addChild('comments', null, RssFeed::PODCAST_NAMESPACE);
$comments->addAttribute('uri', url_to('episode-comments', $podcast->handle, $episode->slug));
$comments->addAttribute('contentType', 'application/podcast-activity+json');
if ($episode->getPosts()) {
$socialInteractUri = $episode->getPosts()[0]
->uri;
$socialInteractElement = $item->addChild('socialInteract', null, $podcastNamespace);
$socialInteractElement = $item->addChild('socialInteract', null, RssFeed::PODCAST_NAMESPACE);
$socialInteractElement->addAttribute('uri', $socialInteractUri);
$socialInteractElement->addAttribute('priority', '1');
$socialInteractElement->addAttribute('platform', 'castopod');
$socialInteractElement->addAttribute('protocol', 'activitypub');
$socialInteractElement->addAttribute(
'accountId',
"@{$podcast->actor->username}@{$podcast->actor->domain}"
"@{$podcast->actor->username}@{$podcast->actor->domain}",
);
$socialInteractElement->addAttribute(
'pubDate',
$episode->getPosts()[0]
->published_at->format(DateTime::ISO8601)
->published_at->format(DateTime::ISO8601),
);
}
if ($episode->transcript !== null) {
$transcriptElement = $item->addChild('transcript', null, $podcastNamespace);
if ($episode->transcript instanceof Transcript) {
$transcriptElement = $item->addChild('transcript', null, RssFeed::PODCAST_NAMESPACE);
$transcriptElement->addAttribute('url', $episode->transcript->file_url);
$transcriptElement->addAttribute(
'type',
Mimes::guessTypeFromExtension(
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION)
pathinfo($episode->transcript->file_url, PATHINFO_EXTENSION),
) ?? 'text/html',
);
// Castopod only allows for captions (SubRip files)
$transcriptElement->addAttribute('rel', 'captions');
// TODO: allow for multiple languages
$transcriptElement->addAttribute('language', $podcast->language_code);
}
if ($episode->getChapters() !== null) {
$chaptersElement = $item->addChild('chapters', null, $podcastNamespace);
if ($episode->getChapters() instanceof Chapters) {
$chaptersElement = $item->addChild('chapters', null, RssFeed::PODCAST_NAMESPACE);
$chaptersElement->addAttribute('url', $episode->chapters->file_url);
$chaptersElement->addAttribute('type', 'application/json+chapters');
}
foreach ($episode->soundbites as $soundbite) {
// TODO: differentiate video from soundbites?
$soundbiteElement = $item->addChild('soundbite', $soundbite->title, $podcastNamespace);
$soundbiteElement->addAttribute('start_time', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) $soundbite->duration);
$soundbiteElement = $item->addChild('soundbite', $soundbite->title, RssFeed::PODCAST_NAMESPACE);
$soundbiteElement->addAttribute('startTime', (string) $soundbite->start_time);
$soundbiteElement->addAttribute('duration', (string) round($soundbite->duration, 3));
}
foreach ($episode->persons as $person) {
foreach ($person->roles as $role) {
$personElement = $item->addChild('person', esc($person->full_name), $podcastNamespace,);
$personElement = $item->addChild('person', esc($person->full_name), RssFeed::PODCAST_NAMESPACE);
$personElement->addAttribute(
'role',
esc(lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en'),),
esc(lang("PersonsTaxonomy.persons.{$role->group}.roles.{$role->role}.label", [], 'en')),
);
$personElement->addAttribute(
......@@ -378,7 +399,7 @@ if (! function_exists('get_rss_feed')) {
esc(lang("PersonsTaxonomy.persons.{$role->group}.label", [], 'en')),
);
$personElement->addAttribute('img', $person->avatar->medium_url);
$personElement->addAttribute('img', get_avatar_url($person, 'medium'));
if ($person->information_url !== null) {
$personElement->addAttribute('href', $person->information_url);
......@@ -387,14 +408,10 @@ if (! function_exists('get_rss_feed')) {
}
if ($episode->is_blocked) {
$item->addChild('block', 'Yes', $itunesNamespace);
$item->addChild('block', 'Yes', RssFeed::ITUNES_NAMESPACE);
}
if ($episode->custom_rss !== null) {
array_to_rss([
'elements' => $episode->custom_rss,
], $item);
}
$plugins->rssAfterItem($episode, $item);
}
return $rss->asXML();
......@@ -405,20 +422,18 @@ if (! function_exists('add_category_tag')) {
/**
* Adds <itunes:category> and <category> tags to node for a given category
*/
function add_category_tag(SimpleXMLElement $node, Category $category): void
function add_category_tag(RssFeed $node, Category $category): void
{
$itunesNamespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$itunesCategory = $node->addChild('category', null, $itunesNamespace);
$itunesCategory = $node->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategory->addAttribute(
'text',
$category->parent !== null
$category->parent instanceof Category
? $category->parent->apple_category
: $category->apple_category,
);
if ($category->parent !== null) {
$itunesCategoryChild = $itunesCategory->addChild('category', null, $itunesNamespace);
if ($category->parent instanceof Category) {
$itunesCategoryChild = $itunesCategory->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategoryChild->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category);
}
......@@ -426,75 +441,3 @@ if (! function_exists('add_category_tag')) {
$node->addChild('category', $category->apple_category);
}
}
if (! function_exists('rss_to_array')) {
/**
* Converts XML to array
*
* FIXME: param should be SimpleRSSElement
*
* @return array<string, mixed>
*/
function rss_to_array(SimpleXMLElement $rssNode): array
{
$nameSpaces = [
'',
'http://www.itunes.com/dtds/podcast-1.0.dtd',
'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md',
];
$arrayNode = [];
$arrayNode['name'] = $rssNode->getName();
$arrayNode['namespace'] = $rssNode->getNamespaces(false);
foreach ($rssNode->attributes() as $key => $value) {
$arrayNode['attributes'][$key] = (string) $value;
}
$textcontent = trim((string) $rssNode);
if (strlen($textcontent) > 0) {
$arrayNode['content'] = $textcontent;
}
foreach ($nameSpaces as $currentNameSpace) {
foreach ($rssNode->children($currentNameSpace) as $childXmlNode) {
$arrayNode['elements'][] = rss_to_array($childXmlNode);
}
}
return $arrayNode;
}
}
if (! function_exists('array_to_rss')) {
/**
* Inserts array (converted to XML node) in XML node
*
* @param array<string, mixed> $arrayNode
* @param SimpleRSSElement $xmlNode The XML parent node where this arrayNode should be attached
*/
function array_to_rss(array $arrayNode, SimpleRSSElement &$xmlNode): SimpleRSSElement
{
if (array_key_exists('elements', $arrayNode)) {
foreach ($arrayNode['elements'] as $childArrayNode) {
$childXmlNode = $xmlNode->addChild(
$childArrayNode['name'],
$childArrayNode['content'] ?? null,
$childArrayNode['namespace'] === []
? null
: current($childArrayNode['namespace'])
);
if (array_key_exists('attributes', $childArrayNode)) {
foreach (
$childArrayNode['attributes']
as $attributeKey => $attributeValue
) {
$childXmlNode->addAttribute($attributeKey, $attributeValue);
}
}
array_to_rss($childArrayNode, $childXmlNode);
}
}
return $xmlNode;
}
}
......@@ -8,18 +8,19 @@ use App\Entities\EpisodeComment;
use App\Entities\Page;
use App\Entities\Podcast;
use App\Entities\Post;
use Melbahja\Seo\MetaTags;
use App\Libraries\HtmlHead;
use Melbahja\Seo\Schema;
use Melbahja\Seo\Schema\Thing;
use Modules\Fediverse\Entities\PreviewCard;
/**
* @copyright 2021 Ad Aures
* @copyright 2024 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (! function_exists('get_podcast_metatags')) {
function get_podcast_metatags(Podcast $podcast, string $page): string
if (! function_exists('set_podcast_metatags')) {
function set_podcast_metatags(Podcast $podcast, string $page): void
{
$category = '';
if ($podcast->category->parent_id !== null) {
......@@ -30,27 +31,28 @@ if (! function_exists('get_podcast_metatags')) {
$schema = new Schema(
new Thing('PodcastSeries', [
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'name' => $podcast->title,
'headline' => $podcast->title,
'url' => current_url(),
'sameAs' => $podcast->link,
'identifier' => $podcast->guid,
'image' => $podcast->cover->feed_url,
'description' => $podcast->description,
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
])
'webFeed' => $podcast->feed_url,
'accessMode' => 'auditory',
'author' => $podcast->owner_name,
'creator' => $podcast->owner_name,
'publisher' => $podcast->publisher,
'inLanguage' => $podcast->language_code,
'genre' => $category,
]),
);
$metatags = new MetaTags();
/** @var HtmlHead $head */
$head = service('html_head');
$metatags
->title($podcast->title . ' (@' . $podcast->handle . ') • ' . lang('Podcast.' . $page))
$head
->title(sprintf('%s (@%s) • %s', $podcast->title, $podcast->handle, lang('Podcast.' . $page)))
->description(esc($podcast->description))
->image((string) $podcast->cover->og_url)
->canonical((string) current_url())
......@@ -58,47 +60,42 @@ if (! function_exists('get_podcast_metatags')) {
->og('image:height', (string) config('Images')->podcastCoverSizes['og']['height'])
->og('locale', $podcast->language_code)
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
'rel' => 'alternate',
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)),
]);
if ($podcast->payment_pointer) {
$metatags->meta('monetization', $podcast->payment_pointer);
}
return '<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title
) . '" href="' . $podcast->feed_url . '" />' . PHP_EOL . $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent('<link type="application/rss+xml" rel="alternate" title="' . esc(
$podcast->title,
) . '" href="' . $podcast->feed_url . '" />' . $schema);
}
}
if (! function_exists('get_episode_metatags')) {
function get_episode_metatags(Episode $episode): string
if (! function_exists('set_episode_metatags')) {
function set_episode_metatags(Episode $episode): void
{
$schema = new Schema(
new Thing('PodcastEpisode', [
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ISO8601),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'url' => url_to('episode', esc($episode->podcast->handle), $episode->slug),
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [
'contentUrl' => $episode->audio->file_url,
'contentUrl' => $episode->audio_url,
]),
'partOfSeries' => new Thing('PodcastSeries', [
'name' => $episode->podcast->title,
'url' => $episode->podcast->link,
'url' => $episode->podcast->link,
]),
])
]),
);
$metatags = new MetaTags();
/** @var HtmlHead $head */
$head = service('html_head');
$metatags
$head
->title($episode->title)
->description(esc($episode->description))
->image((string) $episode->cover->og_url, 'player')
......@@ -109,42 +106,37 @@ if (! function_exists('get_episode_metatags')) {
->og('locale', $episode->podcast->language_code)
->og('audio', $episode->audio_opengraph_url)
->og('audio:type', $episode->audio->file_mimetype)
->meta('article:published_time', $episode->published_at->format(DATE_ISO8601))
->meta('article:modified_time', $episode->updated_at->format(DATE_ISO8601))
->meta('article:published_time', $episode->published_at->format(DATE_ATOM))
->meta('article:modified_time', $episode->updated_at->format(DATE_ATOM))
->twitter('audio:partner', $episode->podcast->publisher ?? '')
->twitter('audio:artist_name', esc($episode->podcast->owner_name))
->twitter('player', $episode->getEmbedUrl('light'))
->twitter('player:width', (string) config('Embed')->width)
->twitter('player:height', (string) config('Embed')->height)
->push('link', [
'rel' => 'alternate',
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('episode', $episode->podcast->handle, $episode->slug),
]);
if ($episode->podcast->payment_pointer) {
$metatags->meta('monetization', $episode->podcast->payment_pointer);
}
return $metatags->__toString() . PHP_EOL . '<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug)
) . '" title="' . esc(
$episode->title
) . ' oEmbed json" />' . PHP_EOL . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug)
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . PHP_EOL . $schema->__toString();
'href' => $episode->link,
])
->appendRawContent('<link rel="alternate" type="application/json+oembed" href="' . base_url(
route_to('episode-oembed-json', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc(
$episode->title,
) . ' oEmbed json" />' . '<link rel="alternate" type="text/xml+oembed" href="' . base_url(
route_to('episode-oembed-xml', $episode->podcast->handle, $episode->slug),
) . '" title="' . esc($episode->title) . ' oEmbed xml" />' . $schema);
}
}
if (! function_exists('get_post_metatags')) {
function get_post_metatags(Post $post): string
if (! function_exists('set_post_metatags')) {
function set_post_metatags(Post $post): void
{
$socialMediaPosting = new Thing('SocialMediaPosting', [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ISO8601),
'author' => new Thing('Person', [
'@id' => url_to('post', esc($post->actor->username), $post->id),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
'url' => $post->actor->uri,
]),
'text' => $post->message,
]);
......@@ -152,16 +144,16 @@ if (! function_exists('get_post_metatags')) {
if ($post->episode_id !== null) {
$socialMediaPosting->__set('sharedContent', new Thing('Audio', [
'headline' => $post->episode->title,
'url' => $post->episode->link,
'author' => new Thing('Person', [
'url' => $post->episode->link,
'author' => new Thing('Person', [
'name' => $post->episode->podcast->owner_name,
]),
]));
} elseif ($post->preview_card !== null) {
} elseif ($post->preview_card instanceof PreviewCard) {
$socialMediaPosting->__set('sharedContent', new Thing('WebPage', [
'headline' => $post->preview_card->title,
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'url' => $post->preview_card->url,
'author' => new Thing('Person', [
'name' => $post->preview_card->author_name,
]),
]));
......@@ -169,8 +161,10 @@ if (! function_exists('get_post_metatags')) {
$schema = new Schema($socialMediaPosting);
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Post.title', [
'actorDisplayName' => $post->actor->display_name,
]))
......@@ -178,65 +172,64 @@ if (! function_exists('get_post_metatags')) {
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
'rel' => 'alternate',
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('post', esc($post->actor->username), $post->id),
]);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent((string) $schema);
}
}
if (! function_exists('get_episode_comment_metatags')) {
function get_episode_comment_metatags(EpisodeComment $episodeComment): string
if (! function_exists('set_episode_comment_metatags')) {
function set_episode_comment_metatags(EpisodeComment $episodeComment): void
{
$schema = new Schema(new Thing('SocialMediaPosting', [
'@id' => url_to(
'episode-comment',
esc($episodeComment->actor->username),
$episodeComment->episode->slug,
$episodeComment->id
$episodeComment->id,
),
'datePublished' => $episodeComment->created_at->format(DATE_ISO8601),
'author' => new Thing('Person', [
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
'url' => $episodeComment->actor->uri,
]),
'text' => $episodeComment->message,
'text' => $episodeComment->message,
'upvoteCount' => $episodeComment->likes_count,
]));
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Comment.title', [
'actorDisplayName' => $episodeComment->actor->display_name,
'episodeTitle' => $episodeComment->episode->title,
'episodeTitle' => $episodeComment->episode->title,
]))
->description($episodeComment->message)
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
'rel' => 'alternate',
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to(
'episode-comment',
$episodeComment->actor->username,
$episodeComment->episode->slug,
$episodeComment->id
$episodeComment->id,
),
]);
return $metatags->__toString() . PHP_EOL . $schema->__toString();
])->appendRawContent((string) $schema);
}
}
if (! function_exists('get_follow_metatags')) {
function get_follow_metatags(Actor $actor): string
if (! function_exists('set_follow_metatags')) {
function set_follow_metatags(Actor $actor): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Podcast.followTitle', [
'actorDisplayName' => $actor->display_name,
]))
......@@ -244,16 +237,15 @@ if (! function_exists('get_follow_metatags')) {
->image($actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_remote_actions_metatags')) {
function get_remote_actions_metatags(Post $post, string $action): string
if (! function_exists('set_remote_actions_metatags')) {
function set_remote_actions_metatags(Post $post, string $action): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(lang('Fediverse.' . $action . '.title', [
'actorDisplayName' => $post->actor->display_name,
],))
......@@ -261,42 +253,40 @@ if (! function_exists('get_remote_actions_metatags')) {
->image($post->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_home_metatags')) {
function get_home_metatags(): string
if (! function_exists('set_home_metatags')) {
function set_home_metatags(): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(service('settings')->get('App.siteName'))
->description(esc(service('settings')->get('App.siteDescription')))
->image(service('settings')->get('App.siteIcon')['512'])
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
if (! function_exists('get_page_metatags')) {
function get_page_metatags(Page $page): string
if (! function_exists('set_page_metatags')) {
function set_page_metatags(Page $page): void
{
$metatags = new MetaTags();
$metatags
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title(
$page->title . service('settings')->get('App.siteTitleSeparator') . service(
'settings'
)->get('App.siteName')
'settings',
)->get('App.siteName'),
)
->description(esc(service('settings')->get('App.siteDescription')))
->image(service('settings')->get('App.siteIcon')['512'])
->image(get_site_icon_url('512'))
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}
......@@ -305,7 +295,7 @@ if (! function_exists('iso8601_duration')) {
function iso8601_duration(float $seconds): string
{
$days = floor($seconds / 86400);
$seconds %= 86400;
$seconds = (int) $seconds % 86400;
$hours = floor($seconds / 3600);
$seconds %= 3600;
......
......@@ -8,52 +8,19 @@ declare(strict_types=1);
* @link https://castopod.org/
*/
if (! function_exists('icon')) {
/**
* Returns the inline svg icon
*
* @param string $name name of the icon file without the .svg extension
* @param string $class to be added to the svg string
* @param 'social'|'podcasting'|'funding' $type type of icon to be added
* @return string svg contents
*/
function icon(string $name, string $class = '', string $type = null): string
{
if ($type !== null) {
$name = $type . '/' . $name;
}
try {
$svgContents = file_get_contents('assets/icons/' . $name . '.svg');
} catch (Exception) {
if ($type !== null) {
return icon('default', $class, $type);
}
return '□';
}
if ($class !== '') {
$svgContents = str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
}
return $svgContents;
}
}
if (! function_exists('svg')) {
/**
* Returns the inline svg image
*
* @param string $name name of the image file without the .svg extension
* @param string $class to be added to the svg string
* @param string|null $class to be added to the svg string
* @return string svg contents
*/
function svg(string $name, ?string $class = null): string
{
$svgContents = file_get_contents('assets/images/' . $name . '.svg');
if ($class) {
$svgContents = str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
return str_replace('<svg', '<svg class="' . $class . '"', $svgContents);
}
return $svgContents;
......
......@@ -16,13 +16,14 @@ if (! function_exists('host_url')) {
*/
function host_url(): ?string
{
if (isset($_SERVER['HTTP_HOST'])) {
$superglobals = service('superglobals');
if ($superglobals->server('HTTP_HOST') !== null) {
$protocol =
(isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ||
$_SERVER['SERVER_PORT'] === 443
($superglobals->server('HTTPS') !== null && $superglobals->server('HTTPS') !== 'off') ||
(int) $superglobals->server('SERVER_PORT') === 443
? 'https://'
: 'http://';
return $protocol . $_SERVER['HTTP_HOST'] . '/';
return $protocol . $superglobals->server('HTTP_HOST') . '/';
}
return null;
......@@ -31,6 +32,24 @@ if (! function_exists('host_url')) {
//--------------------------------------------------------------------
/**
* Return the host URL to use in views
*/
if (! function_exists('current_domain')) {
/**
* Returns instance's domain name
*/
function current_domain(): string
{
/** @var URI $uri */
$uri = current_url(true);
return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : '');
}
}
//--------------------------------------------------------------------
if (! function_exists('extract_params_from_episode_uri')) {
/**
* Returns podcast name and episode slug from episode string
......@@ -58,7 +77,7 @@ if (! function_exists('extract_params_from_episode_uri')) {
return [
'podcastHandle' => $matches['podcastHandle'],
'episodeSlug' => $matches['episodeSlug'],
'episodeSlug' => $matches['episodeSlug'],
];
}
}
......@@ -2,6 +2,11 @@
+ fr/***
+ pl/***
+ de/***
+ pt-BR/***
+ nn-NO/***
+ pt-br/***
+ nn-no/***
+ es/***
+ zh-hans/***
+ ca/***
+ br/***
+ sr-latn/***
- **
......@@ -26,4 +26,5 @@ return [
'read_less' => 'Read less',
'see_more' => 'الاطّلاع على المزيد',
'see_less' => 'See less',
'legal_notice' => 'Legal notice',
];
......@@ -23,6 +23,8 @@ return [
'back_to_episodes' => 'العودة إلى حلقات {podcast}',
'comments' => 'التعليقات',
'activity' => 'النشاط',
'chapters' => 'Chapters',
'transcript' => 'Transcript',
'description' => 'وصف الحلقة',
'number_of_comments' => '{numberOfComments, plural,
one {# comment}
......@@ -30,4 +32,19 @@ return [
}',
'all_podcast_episodes' => 'كافة حلقات البودكاست',
'back_to_podcast' => 'العودة إلى البودكاست',
'preview' => [
'title' => 'Preview',
'not_published' => 'Not published',
'text' => '{publication_status, select,
published {This episode is not yet published.}
scheduled {This episode is scheduled for publication on {publication_date}.}
with_podcast {This episode will be published at the same time as the podcast.}
other {This episode is not yet published.}
}',
'publish' => 'Publish',
'publish_edit' => 'Edit publication',
],
'no_chapters' => 'No chapters are available for this episode.',
'download_transcript' => 'Download transcript ({extension})',
'no_transcript' => 'No transcript available for this episode.',
];