feat(plugins): add html field type + CodeEditor component + rework html head generation

update php and js packages to latest
This commit is contained in:
Yassine Doghri 2024-12-17 15:06:08 +00:00
parent b869acb3a9
commit 8cf9c6dc83
227 changed files with 3011 additions and 3008 deletions

View File

@ -37,7 +37,7 @@ if (! function_exists('view')) {
$renderer = single_service('renderer', $path);
$saveData = config('View')
->saveData;
->saveData;
if (array_key_exists('saveData', $options)) {
$saveData = (bool) $options['saveData'];

View File

@ -67,7 +67,7 @@ class Filters extends BaseConfig
/**
* List of filter aliases that are always applied before and after every request.
*
* @var array<string, array<string, array<string, string|array<string>>>>>|array<string, list<string>>
* @var array<string, array<string, array<string, string|array<string>>>>|array<string, list<string>>
*/
public array $globals = [
'before' => [

View File

@ -25,7 +25,7 @@ class Generators extends BaseConfig
*
* YOU HAVE BEEN WARNED!
*
* @var array<string, string>
* @var array<string, string|array<string,string>>
*/
public array $views = [
'make:cell' => [

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Config;
use App\Libraries\Breadcrumb;
use App\Libraries\HtmlHead;
use App\Libraries\Negotiate;
use App\Libraries\Router;
use CodeIgniter\Config\BaseService;
@ -66,4 +67,13 @@ class Services extends BaseService
return new Breadcrumb();
}
public static function html_head(bool $getShared = true): HtmlHead
{
if ($getShared) {
return self::getSharedInstance('html_head');
}
return new HtmlHead();
}
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Config;
use App\Views\Decorators\SiteHead;
use CodeIgniter\Config\View as BaseView;
use CodeIgniter\View\ViewDecoratorInterface;
use ViewComponents\Decorator;
@ -54,5 +53,5 @@ class View extends BaseView
*
* @var list<class-string<ViewDecoratorInterface>>
*/
public array $decorators = [Decorator::class, SiteHead::class];
public array $decorators = [Decorator::class];
}

View File

@ -31,10 +31,10 @@ class ActorController extends FediverseActorController
}
helper(['form', 'components', 'svg']);
// @phpstan-ignore-next-line
set_follow_metatags($this->actor);
$data = [
// @phpstan-ignore-next-line
'metatags' => get_follow_metatags($this->actor),
'actor' => $this->actor,
'actor' => $this->actor,
];
return view('podcast/follow', $data);

View File

@ -164,10 +164,10 @@ class CreditsController extends BaseController
}
}
set_page_metatags($page);
$data = [
'metatags' => get_page_metatags($page),
'page' => $page,
'credits' => $credits,
'page' => $page,
'credits' => $credits,
];
$found = view('pages/credits', $data);

View File

@ -96,12 +96,12 @@ class EpisodeCommentController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_comment_metatags($this->comment);
$data = [
'metatags' => get_episode_comment_metatags($this->comment),
'podcast' => $this->podcast,
'actor' => $this->actor,
'episode' => $this->episode,
'comment' => $this->comment,
'podcast' => $this->podcast,
'actor' => $this->actor,
'episode' => $this->episode,
'comment' => $this->comment,
];
// if user is logged in then send to the authenticated activity view

View File

@ -86,10 +86,10 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
@ -135,10 +135,10 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
@ -184,13 +184,13 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
// get chapters from json file
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
// get chapters from json file
if (isset($this->episode->chapters->file_key)) {
/** @var FileManagerInterface $fileManager */
$fileManager = service('file_manager');
@ -243,13 +243,13 @@ class EpisodeController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
// get transcript from json file
set_episode_metatags($this->episode);
$data = [
'metatags' => get_episode_metatags($this->episode),
'podcast' => $this->podcast,
'episode' => $this->episode,
'podcast' => $this->podcast,
'episode' => $this->episode,
];
// get transcript from json file
if ($this->episode->transcript !== null) {
$data['transcript'] = $this->episode->transcript;

View File

@ -32,9 +32,9 @@ class HomeController extends BaseController
return redirect()->route('podcast-activity', [$allPodcasts[0]->handle]);
}
set_home_metatags();
// default behavior: list all podcasts on home page
$data = [
'metatags' => get_home_metatags(),
'podcasts' => $allPodcasts,
'sortBy' => $sortBy,
];

View File

@ -49,9 +49,9 @@ class PageController extends BaseController
);
if (! ($found = cache($cacheName))) {
set_page_metatags($this->page);
$data = [
'metatags' => get_page_metatags($this->page),
'page' => $this->page,
'page' => $this->page,
];
$found = view('pages/page', $data);

View File

@ -78,10 +78,10 @@ class PodcastController extends BaseController
);
if (! ($cachedView = cache($cacheName))) {
set_podcast_metatags($this->podcast, 'activity');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'activity'),
'podcast' => $this->podcast,
'posts' => (new PostModel())->getActorPublishedPosts($this->podcast->actor_id),
'podcast' => $this->podcast,
'posts' => (new PostModel())->getActorPublishedPosts($this->podcast->actor_id),
];
// if user is logged in then send to the authenticated activity view
@ -128,10 +128,10 @@ class PodcastController extends BaseController
if (! ($cachedView = cache($cacheName))) {
$stats = (new EpisodeModel())->getPodcastStats($this->podcast->id);
set_podcast_metatags($this->podcast, 'about');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'about'),
'podcast' => $this->podcast,
'stats' => $stats,
'podcast' => $this->podcast,
'stats' => $stats,
];
// // if user is logged in then send to the authenticated activity view
@ -245,8 +245,8 @@ class PodcastController extends BaseController
];
}
set_podcast_metatags($this->podcast, 'episodes');
$data = [
'metatags' => get_podcast_metatags($this->podcast, 'episodes'),
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
@ -315,9 +315,9 @@ class PodcastController extends BaseController
public function links(): string
{
set_podcast_metatags($this->podcast, 'links');
return view('podcast/links', [
'metatags' => get_podcast_metatags($this->podcast, 'links'),
'podcast' => $this->podcast,
'podcast' => $this->podcast,
]);
}
}

View File

@ -45,6 +45,7 @@ class PostController extends FediversePostController
#[Override]
public function _remap(string $method, string ...$params): mixed
{
if (
! ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) instanceof Podcast
) {
@ -54,16 +55,21 @@ class PostController extends FediversePostController
$this->podcast = $podcast;
$this->actor = $this->podcast->actor;
if (
count($params) > 1 &&
($post = (new PostModel())->getPostById($params[1])) instanceof CastopodPost
) {
$this->post = $post;
unset($params[0]);
unset($params[1]);
if (count($params) <= 1) {
throw PageNotFoundException::forPageNotFound();
}
if (
! ($post = (new PostModel())->getPostById($params[1])) instanceof CastopodPost
) {
throw PageNotFoundException::forPageNotFound();
}
$this->post = $post;
unset($params[0]);
unset($params[1]);
return $this->{$method}(...$params);
}
@ -74,10 +80,6 @@ class PostController extends FediversePostController
$this->registerPodcastWebpageHit($this->podcast->id);
}
if (! $this->post instanceof CastopodPost) {
throw PageNotFoundException::forPageNotFound();
}
$cacheName = implode(
'_',
array_filter([
@ -91,10 +93,10 @@ class PostController extends FediversePostController
);
if (! ($cachedView = cache($cacheName))) {
set_post_metatags($this->post);
$data = [
'metatags' => get_post_metatags($this->post),
'post' => $this->post,
'podcast' => $this->podcast,
'post' => $this->post,
'podcast' => $this->podcast,
];
// if user is logged in then send to the authenticated activity view
@ -254,12 +256,12 @@ class PostController extends FediversePostController
$this->registerPodcastWebpageHit($this->podcast->id);
}
set_remote_actions_metatags($this->post, $action);
$data = [
'metatags' => get_remote_actions_metatags($this->post, $action),
'podcast' => $this->podcast,
'actor' => $this->actor,
'post' => $this->post,
'action' => $action,
'podcast' => $this->podcast,
'actor' => $this->actor,
'post' => $this->post,
'action' => $action,
];
helper('form');

View File

@ -36,7 +36,7 @@ use Modules\Media\Models\MediaModel;
* @property string $type
* @property int|null $media_id
* @property Video|Audio|null $media
* @property array|null $metadata
* @property array<mixed>|null $metadata
* @property string $status
* @property string $logs
* @property User $user
@ -136,7 +136,7 @@ class BaseClip extends Entity
$media = new Audio([
'file_key' => $fileKey,
'language_code' => $this->getPodcast()
->language_code,
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);

View File

@ -16,7 +16,7 @@ use Modules\Media\Models\MediaModel;
use Override;
/**
* @property array $theme
* @property array{name:string,preview:string} $theme
* @property string $format
*/
class VideoClip extends BaseClip
@ -37,7 +37,7 @@ class VideoClip extends BaseClip
}
/**
* @param array<string, string> $theme
* @param array{name:string,preview:string} $theme
*/
public function setTheme(array $theme): self
{
@ -75,7 +75,7 @@ class VideoClip extends BaseClip
$video = new Video([
'file_key' => $fileKey,
'language_code' => $this->getPodcast()
->language_code,
->language_code,
'uploaded_by' => $this->attributes['created_by'],
'updated_by' => $this->attributes['created_by'],
]);

View File

@ -191,10 +191,10 @@ class Episode extends Entity
$this->audio_url = url_to(
'episode-audio',
$this->getPodcast()
->handle,
->handle,
$this->slug,
$this->getAudio()
->file_extension
->file_extension
);
$this->audio_opengraph_url = $this->audio_url . '?_from=-+Open+Graph+-';
@ -219,7 +219,7 @@ class Episode extends Entity
$cover = new Image([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->podcastCoverSizes,
->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
@ -302,7 +302,7 @@ class Episode extends Entity
$transcript = new Transcript([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-transcript.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
@ -339,7 +339,7 @@ class Episode extends Entity
$chapters = new Chapters([
'file_key' => 'podcasts/' . $this->getPodcast()->handle . '/' . $this->attributes['slug'] . '-chapters' . '.' . $file->getExtension(),
'language_code' => $this->getPodcast()
->language_code,
->language_code,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);

View File

@ -72,7 +72,7 @@ class Person extends Entity
$avatar = new Image([
'file_key' => 'persons/' . $this->attributes['unique_name'] . '.' . $file->getExtension(),
'sizes' => config('Images')
->personAvatarSizes,
->personAvatarSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);

View File

@ -231,7 +231,7 @@ class Podcast extends Entity
$cover = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/cover.' . $file->getExtension(),
'sizes' => config('Images')
->podcastCoverSizes,
->podcastCoverSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);
@ -274,7 +274,7 @@ class Podcast extends Entity
$banner = new Image([
'file_key' => 'podcasts/' . $this->attributes['handle'] . '/banner.' . $file->getExtension(),
'sizes' => config('Images')
->podcastBannerSizes,
->podcastBannerSizes,
'uploaded_by' => $this->attributes['updated_by'],
'updated_by' => $this->attributes['updated_by'],
]);

View File

@ -105,7 +105,7 @@ if (! function_exists('publication_pill')) {
$label = lang('Episode.publication_status.' . $publicationStatus);
// @icon('error-warning-fill')
// @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',
@ -129,20 +129,20 @@ if (! function_exists('publication_button')) {
$label = lang('Episode.publish');
$route = route_to('episode-publish', $podcastId, $episodeId);
$variant = 'primary';
$iconLeft = 'upload-cloud-fill'; // @icon('upload-cloud-fill')
$iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break;
case 'with_podcast':
case 'scheduled':
$label = lang('Episode.publish_edit');
$route = route_to('episode-publish_edit', $podcastId, $episodeId);
$variant = 'warning';
$iconLeft = 'upload-cloud-fill'; // @icon('upload-cloud-fill')
$iconLeft = 'upload-cloud-fill'; // @icon("upload-cloud-fill")
break;
case 'published':
$label = lang('Episode.unpublish');
$route = route_to('episode-unpublish', $podcastId, $episodeId);
$variant = 'danger';
$iconLeft = 'cloud-off-fill'; // @icon('cloud-off-fill')
$iconLeft = 'cloud-off-fill'; // @icon("cloud-off-fill")
break;
default:
$label = '';

View File

@ -210,7 +210,7 @@ if (! function_exists('get_podcast_banner')) {
)->podcastBannerDefaultPaths['default'];
$sizes = config('Images')
->podcastBannerSizes;
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
@ -231,7 +231,7 @@ if (! function_exists('get_podcast_banner_mimetype')) {
{
if (! $podcast->banner instanceof Image) {
$sizes = config('Images')
->podcastBannerSizes;
->podcastBannerSizes;
$sizeConfig = $sizes[$size];
helper('filesystem');
@ -252,10 +252,10 @@ if (! function_exists('get_avatar_url')) {
{
if (! $person->avatar instanceof Image) {
$defaultAvatarPath = config('Images')
->avatarDefaultPath;
->avatarDefaultPath;
$sizes = config('Images')
->personAvatarSizes;
->personAvatarSizes;
$sizeConfig = $sizes[$size];

View File

@ -422,7 +422,7 @@ 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
{
$itunesCategory = $node->addChild('category', null, RssFeed::ITUNES_NAMESPACE);
$itunesCategory->addAttribute(
@ -441,70 +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://podcastindex.org/namespace/1.0'];
$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 RssFeed $xmlNode The XML parent node where this arrayNode should be attached
*/
function array_to_rss(array $arrayNode, RssFeed &$xmlNode): RssFeed
{
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;
}
}

View File

@ -8,19 +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) {
@ -48,10 +48,11 @@ if (! function_exists('get_podcast_metatags')) {
])
);
$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())
@ -59,20 +60,18 @@ 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', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('podcast-activity', esc($podcast->handle)),
]);
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', [
@ -80,7 +79,7 @@ if (! function_exists('get_episode_metatags')) {
'name' => $episode->title,
'image' => $episode->cover->feed_url,
'description' => $episode->description,
'datePublished' => $episode->published_at->format(DATE_ISO8601),
'datePublished' => $episode->published_at->format(DATE_ATOM),
'timeRequired' => iso8601_duration($episode->audio->duration),
'duration' => iso8601_duration($episode->audio->duration),
'associatedMedia' => new Thing('MediaObject', [
@ -93,9 +92,10 @@ if (! function_exists('get_episode_metatags')) {
])
);
$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')
@ -106,35 +106,34 @@ 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', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to('episode', $episode->podcast->handle, $episode->slug),
]);
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),
'datePublished' => $post->published_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $post->actor->display_name,
'url' => $post->actor->uri,
@ -162,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,
]))
@ -171,18 +172,16 @@ 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', [
->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(
@ -191,7 +190,7 @@ if (! function_exists('get_episode_comment_metatags')) {
$episodeComment->episode->slug,
$episodeComment->id
),
'datePublished' => $episodeComment->created_at->format(DATE_ISO8601),
'datePublished' => $episodeComment->created_at->format(DATE_ATOM),
'author' => new Thing('Person', [
'name' => $episodeComment->actor->display_name,
'url' => $episodeComment->actor->uri,
@ -200,8 +199,10 @@ if (! function_exists('get_episode_comment_metatags')) {
'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,
@ -210,7 +211,7 @@ if (! function_exists('get_episode_comment_metatags')) {
->image($episodeComment->actor->avatar_image_url)
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')))
->push('link', [
->tag('link', null, [
'rel' => 'alternate',
'type' => 'application/activity+json',
'href' => url_to(
@ -219,17 +220,16 @@ if (! function_exists('get_episode_comment_metatags')) {
$episodeComment->episode->slug,
$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,
]))
@ -237,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,
],))
@ -254,31 +253,30 @@ 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(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'
@ -289,7 +287,6 @@ if (! function_exists('get_page_metatags')) {
->canonical((string) current_url())
->og('site_name', esc(service('settings')->get('App.siteName')));
return $metatags->__toString();
}
}

188
app/Libraries/HtmlHead.php Normal file
View File

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use App\Controllers\WebmanifestController;
use Override;
use Stringable;
/**
* Inspired by https://github.com/melbahja/seo
*/
class HtmlHead implements Stringable
{
protected ?string $title = null;
/**
* @var array{name:string,value:string|null,attributes:array<string,string|null>}[]
*/
protected array $tags = [];
protected string $rawContent = '';
#[Override]
public function __toString(): string
{
helper('misc');
$this
->tag('meta', null, [
'charset' => 'UTF-8',
])
->meta('viewport', 'width=device-width, initial-scale=1.0')
->tag('link', null, [
'rel' => 'icon',
'type' => 'image/x-icon',
'href' => get_site_icon_url('ico'),
])
->tag('link', null, [
'rel' => 'apple-touch-icon',
'href' => get_site_icon_url('180'),
])
->tag('link', null, [
'rel' => 'manifest',
// @phpstan-ignore-next-line
'href' => isset($podcast) ? route_to('podcast-webmanifest', esc($podcast->handle)) : route_to(
'webmanifest'
),
])
->meta(
'theme-color',
WebmanifestController::THEME_COLORS[service('settings')->get('App.theme')]['theme']
)
->tag('link', null, [
'rel' => 'stylesheet',
'type' => 'text/css',
'href' => route_to('themes-colors-css'),
])
->appendRawContent(<<<HTML
<script>
// Check that service workers are supported
if ('serviceWorker' in navigator) {
// Use the window load event to keep the page load performant
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
HTML);
if ($this->title) {
$this->tag('title', esc($this->title));
}
if (url_is(route_to('admin') . '*') || url_is(base_url(config('Auth')->gateway) . '*')) {
// restricted admin and auth areas, do not index
$this->meta('robots', 'noindex');
} else {
// public website, set siteHead hook only there
service('plugins')
->siteHead($this);
}
$head = '<head>';
foreach ($this->tags as $tag) {
if ($tag['value'] === null) {
$head .= <<<HTML
<{$tag['name']}{$this->stringify_attributes($tag['attributes'])}/>
HTML;
} else {
$head .= <<<HTML
<{$tag['name']} {$this->stringify_attributes($tag['attributes'])}>{$tag['value']}</{$tag['name']}>
HTML;
}
}
$head .= $this->rawContent . '</head>';
// reset head for next render
$this->title = null;
$this->tags = [];
$this->rawContent = '';
return $head;
}
public function title(string $title): self
{
$this->title = $title;
return $this->meta('title', $title)
->og('title', $title)
->twitter('title', $title);
}
public function description(string $desc): self
{
return $this->meta('description', $desc)
->og('description', $desc)
->twitter('description', $desc);
}
public function image(string $url, string $card = 'summary_large_image'): self
{
return $this->og('image', $url)
->twitter('card', $card)
->twitter('image', $url);
}
public function canonical(string $url): self
{
return $this->tag('link', null, [
'rel' => 'canonical',
'href' => $url,
]);
}
public function twitter(string $name, string $value): self
{
$this->meta("twitter:{$name}", $value);
return $this;
}
/**
* @param array<string,string|null> $attributes
*/
public function tag(string $name, ?string $value = null, array $attributes = []): self
{
$this->tags[] = [
'name' => $name,
'value' => $value,
'attributes' => $attributes,
];
return $this;
}
public function meta(string $name, string $content): self
{
$this->tag('meta', null, [
'name' => $name,
'content' => $content,
]);
return $this;
}
public function og(string $name, string $content): self
{
$this->meta('og:' . $name, $content);
return $this;
}
public function appendRawContent(string $content): self
{
$this->rawContent .= $content;
return $this;
}
/**
* @param array<string, string|null> $attributes
*/
private function stringify_attributes(array $attributes): string
{
return stringify_attributes($attributes);
}
}

View File

@ -229,8 +229,6 @@ class EpisodeCommentModel extends UuidModel
$episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC'
);
// FIXME:?
// @phpstan-ignore-next-line
return $this->convertUuidFieldsToStrings(
$allEpisodeComments->getCustomResultObject($this->tempReturnType),
$this->tempReturnType

View File

@ -3,17 +3,17 @@
declare(strict_types=1);
/**
* @icon('funding:buymeacoffee')
* @icon('funding:donorbox')
* @icon('funding:gofundme')
* @icon('funding:helloasso')
* @icon('funding:indiegogo')
* @icon('funding:kickstarter')
* @icon('funding:kisskissbankbank')
* @icon('funding:kofi')
* @icon('funding:liberapay')
* @icon('funding:patreon')
* @icon('funding:paypal')
* @icon('funding:tipeee')
* @icon('funding:ulule')
* @icon("funding:buymeacoffee")
* @icon("funding:donorbox")
* @icon("funding:gofundme")
* @icon("funding:helloasso")
* @icon("funding:indiegogo")
* @icon("funding:kickstarter")
* @icon("funding:kisskissbankbank")
* @icon("funding:kofi")
* @icon("funding:liberapay")
* @icon("funding:patreon")
* @icon("funding:paypal")
* @icon("funding:tipeee")
* @icon("funding:ulule")
*/

View File

@ -3,46 +3,46 @@
declare(strict_types=1);
/**
* @icon('podcasting:amazon')
* @icon('podcasting:antennapod')
* @icon('podcasting:anytime')
* @icon('podcasting:apple')
* @icon('podcasting:blubrry')
* @icon('podcasting:breez')
* @icon('podcasting:castamatic')
* @icon('podcasting:castbox')
* @icon('podcasting:castopod')
* @icon('podcasting:castro')
* @icon('podcasting:deezer')
* @icon('podcasting:episodes-fm')
* @icon('podcasting:fountain')
* @icon('podcasting:fyyd')
* @icon('podcasting:gpodder')
* @icon('podcasting:ivoox')
* @icon('podcasting:listennotes')
* @icon('podcasting:overcast')
* @icon('podcasting:playerfm')
* @icon('podcasting:plink')
* @icon('podcasting:pocketcasts')
* @icon('podcasting:podbean')
* @icon('podcasting:podcastaddict')
* @icon('podcasting:podcastguru')
* @icon('podcasting:podcastindex')
* @icon('podcasting:podchaser')
* @icon('podcasting:podcloud')
* @icon('podcasting:podfriend')
* @icon('podcasting:podinstall')
* @icon('podcasting:podlink')
* @icon('podcasting:podlp')
* @icon('podcasting:podnews')
* @icon('podcasting:podtail')
* @icon('podcasting:podverse')
* @icon('podcasting:radiopublic')
* @icon('podcasting:sphinxchat')
* @icon('podcasting:spotify')
* @icon('podcasting:spreaker')
* @icon('podcasting:truefans')
* @icon('podcasting:tsacdop')
* @icon('podcasting:tunein')
* @icon('podcasting:youtube-music')
* @icon("podcasting:amazon")
* @icon("podcasting:antennapod")
* @icon("podcasting:anytime")
* @icon("podcasting:apple")
* @icon("podcasting:blubrry")
* @icon("podcasting:breez")
* @icon("podcasting:castamatic")
* @icon("podcasting:castbox")
* @icon("podcasting:castopod")
* @icon("podcasting:castro")
* @icon("podcasting:deezer")
* @icon("podcasting:episodes-fm")
* @icon("podcasting:fountain")
* @icon("podcasting:fyyd")
* @icon("podcasting:gpodder")
* @icon("podcasting:ivoox")
* @icon("podcasting:listennotes")
* @icon("podcasting:overcast")
* @icon("podcasting:playerfm")
* @icon("podcasting:plink")
* @icon("podcasting:pocketcasts")
* @icon("podcasting:podbean")
* @icon("podcasting:podcastaddict")
* @icon("podcasting:podcastguru")
* @icon("podcasting:podcastindex")
* @icon("podcasting:podchaser")
* @icon("podcasting:podcloud")
* @icon("podcasting:podfriend")
* @icon("podcasting:podinstall")
* @icon("podcasting:podlink")
* @icon("podcasting:podlp")
* @icon("podcasting:podnews")
* @icon("podcasting:podtail")
* @icon("podcasting:podverse")
* @icon("podcasting:radiopublic")
* @icon("podcasting:sphinxchat")
* @icon("podcasting:spotify")
* @icon("podcasting:spreaker")
* @icon("podcasting:truefans")
* @icon("podcasting:tsacdop")
* @icon("podcasting:tunein")
* @icon("podcasting:youtube-music")
*/

View File

@ -3,26 +3,26 @@
declare(strict_types=1);
/**
* @icon('social:bluesky')
* @icon('social:discord')
* @icon('social:facebook')
* @icon('social:funkwhale')
* @icon('social:instagram')
* @icon('social:linkedin')
* @icon('social:mastodon')
* @icon('social:matrix')
* @icon('social:misskey')
* @icon('social:mobilizon')
* @icon('social:peertube')
* @icon('social:pixelfed')
* @icon('social:pleroma')
* @icon('social:plume')
* @icon('social:slack')
* @icon('social:telegram')
* @icon('social:threads')
* @icon('social:tiktok')
* @icon('social:twitch')
* @icon('social:writefreely')
* @icon('social:x')
* @icon('social:youtube')
* @icon("social:bluesky")
* @icon("social:discord")
* @icon("social:facebook")
* @icon("social:funkwhale")
* @icon("social:instagram")
* @icon("social:linkedin")
* @icon("social:mastodon")
* @icon("social:matrix")
* @icon("social:misskey")
* @icon("social:mobilizon")
* @icon("social:peertube")
* @icon("social:pixelfed")
* @icon("social:pleroma")
* @icon("social:plume")
* @icon("social:slack")
* @icon("social:telegram")
* @icon("social:threads")
* @icon("social:tiktok")
* @icon("social:twitch")
* @icon("social:writefreely")
* @icon("social:x")
* @icon("social:youtube")
*/

View File

@ -21,7 +21,7 @@ import Tooltip from "./modules/Tooltip";
import ValidateFileSize from "./modules/ValidateFileSize";
import "./modules/video-clip-previewer";
import VideoClipBuilder from "./modules/VideoClipBuilder";
import "./modules/xml-editor";
import "./modules/code-editor";
import "@patternfly/elements/pf-tabs/pf-tabs.js";
import FieldArray from "./modules/FieldArray";

View File

@ -0,0 +1,222 @@
import { indentWithTab } from "@codemirror/commands";
import { html as htmlLang } from "@codemirror/lang-html";
import { xml } from "@codemirror/lang-xml";
import {
defaultHighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { Compartment, EditorState, Extension } from "@codemirror/state";
import { keymap, ViewUpdate } from "@codemirror/view";
import { basicSetup, EditorView } from "codemirror";
import { prettify as prettifyHTML, minify as minifyHTML } from "htmlfy";
import { css, html, LitElement, TemplateResult } from "lit";
import {
customElement,
property,
queryAssignedNodes,
state,
} from "lit/decorators.js";
import xmlFormat from "xml-formatter";
const language = new Compartment();
@customElement("code-editor")
export class XMLEditor extends LitElement {
@queryAssignedNodes({ slot: "textarea" })
_textarea!: NodeListOf<HTMLTextAreaElement>;
@property()
lang = "html";
@state()
editorState!: EditorState;
@state()
editorView!: EditorView;
_textareaEvents = [
{
events: ["focus", "invalid"],
onEvent: (_: Event, editor: EditorView) => {
// focus editor when textarea is focused or invalid
editor.focus();
},
},
];
firstUpdated(): void {
const minHeightEditor = EditorView.baseTheme({
".cm-content, .cm-gutter": {
minHeight: this._textarea[0].clientHeight + "px",
},
});
const extensions: Extension[] = [
basicSetup,
keymap.of([indentWithTab]),
minHeightEditor,
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
// Document changed, minify and update textarea value
switch (this.lang) {
case "xml":
this._textarea[0].value = minifyXML(
viewUpdate.state.doc.toString()
);
break;
case "html":
this._textarea[0].value = minifyHTML(
viewUpdate.state.doc.toString()
);
break;
default:
this._textarea[0].value = viewUpdate.state.doc.toString();
break;
}
}
}),
];
let editorContents = "";
switch (this.lang) {
case "xml":
editorContents = formatXML(this._textarea[0].value);
extensions.push(language.of(xml()));
break;
case "html":
editorContents = prettifyHTML(this._textarea[0].value);
extensions.push(language.of(htmlLang()));
break;
default:
break;
}
this.editorState = EditorState.create({
doc: editorContents,
extensions: extensions,
});
this.editorView = new EditorView({
state: this.editorState,
root: this.shadowRoot as ShadowRoot,
parent: this.shadowRoot as ShadowRoot,
});
// hide textarea
this._textarea[0].style.position = "absolute";
this._textarea[0].style.opacity = "0";
this._textarea[0].style.zIndex = "-9999";
this._textarea[0].style.pointerEvents = "none";
for (const event of this._textareaEvents) {
event.events.forEach((name) => {
this._textarea[0].addEventListener(name, (e) =>
event.onEvent(e, this.editorView)
);
});
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
for (const event of this._textareaEvents) {
event.events.forEach((name) => {
this._textarea[0].removeEventListener(name, (e) =>
event.onEvent(e, this.editorView)
);
});
}
}
static styles = css`
.cm-editor {
border-radius: 0.5rem;
overflow: hidden;
border: 3px solid hsl(var(--color-border-contrast));
background-color: hsl(var(--color-background-elevated));
}
.cm-editor.cm-focused {
outline: 2px solid transparent;
box-shadow:
0 0 0 2px hsl(var(--color-background-elevated)),
0 0 0 calc(4px) hsl(var(--color-accent-base));
}
.cm-gutters {
background-color: hsl(var(--color-background-elevated)) !important;
}
.cm-activeLine {
background-color: hsla(
var(--color-background-highlight) / 0.25
) !important;
}
.cm-activeLineGutter {
background-color: hsl(var(--color-background-highlight)) !important;
}
.ͼ4 .cm-line {
caret-color: hsl(var(--color-text-base)) !important;
}
.ͼ1 .cm-cursor {
border: none;
}
`;
render(): TemplateResult<1> {
return html`<slot name="textarea"></slot>`;
}
}
function formatXML(contents: string) {
if (contents === "") {
return contents;
}
let editorContents = "";
try {
editorContents = xmlFormat(contents, {
indentation: " ",
});
} catch {
// xml doesn't have a root node
editorContents = xmlFormat("<root>" + contents + "</root>", {
indentation: " ",
});
// remove root, unnecessary lines and indents
editorContents = editorContents
.replace(/^<root>/, "")
.replace(/<\/root>$/, "")
.replace(/^\s*[\r\n]/gm, "")
.replace(/[\r\n] {2}/gm, "\r\n")
.trim();
}
return editorContents;
}
function minifyXML(contents: string) {
if (contents === "") {
return contents;
}
let minifiedContent = "";
try {
minifiedContent = xmlFormat.minify(contents, {
collapseContent: true,
});
} catch {
minifiedContent = xmlFormat.minify(`<root>${contents}</root>`, {
collapseContent: true,
});
// remove root
minifiedContent = minifiedContent
.replace(/^<root>/, "")
.replace(/<\/root>$/, "");
}
return minifiedContent;
}

View File

@ -1,125 +0,0 @@
import { indentWithTab } from "@codemirror/commands";
import { xml } from "@codemirror/lang-xml";
import {
defaultHighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { Compartment, EditorState } from "@codemirror/state";
import { keymap, ViewUpdate } from "@codemirror/view";
import { basicSetup, EditorView } from "codemirror";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, queryAssignedNodes, state } from "lit/decorators.js";
import prettifyXML from "xml-formatter";
const language = new Compartment();
@customElement("xml-editor")
export class XMLEditor extends LitElement {
@queryAssignedNodes({ slot: "textarea" })
_textarea!: NodeListOf<HTMLTextAreaElement>;
@state()
editorState!: EditorState;
@state()
editorView!: EditorView;
firstUpdated(): void {
const minHeightEditor = EditorView.theme({
".cm-content, .cm-gutter": {
minHeight: this._textarea[0].clientHeight + "px",
},
});
let editorContents = "";
if (this._textarea[0].value) {
try {
editorContents = prettifyXML(this._textarea[0].value, {
indentation: " ",
});
} catch {
// xml doesn't have a root node
editorContents = prettifyXML(
"<root>" + this._textarea[0].value + "</root>",
{
indentation: " ",
}
);
// remove root, unnecessary lines and indents
editorContents = editorContents
.replace(/^<root>/, "")
.replace(/<\/root>$/, "")
.replace(/^\s*[\r\n]/gm, "")
.replace(/[\r\n] {2}/gm, "\r\n")
.trim();
}
}
this.editorState = EditorState.create({
doc: editorContents,
extensions: [
basicSetup,
keymap.of([indentWithTab]),
language.of(xml()),
minHeightEditor,
syntaxHighlighting(defaultHighlightStyle),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (viewUpdate.docChanged) {
// Document changed, update textarea value
this._textarea[0].value = viewUpdate.state.doc.toString();
}
}),
],
});
this.editorView = new EditorView({
state: this.editorState,
root: this.shadowRoot as ShadowRoot,
parent: this.shadowRoot as ShadowRoot,
});
// hide textarea
this._textarea[0].style.position = "absolute";
this._textarea[0].style.opacity = "0";
this._textarea[0].style.zIndex = "-9999";
this._textarea[0].style.pointerEvents = "none";
}
static styles = css`
.cm-editor {
border-radius: 0.5rem;
overflow: hidden;
border: 3px solid hsl(var(--color-border-contrast));
background-color: hsl(var(--color-background-elevated));
}
.cm-editor.cm-focused {
outline: 2px solid transparent;
box-shadow:
0 0 0 2px hsl(var(--color-background-elevated)),
0 0 0 calc(4px) hsl(var(--color-accent-base));
}
.cm-gutters {
background-color: hsl(var(--color-background-elevated)) !important;
}
.cm-activeLine {
background-color: hsl(var(--color-background-highlight)) !important;
}
.cm-activeLineGutter {
background-color: hsl(var(--color-background-highlight)) !important;
}
.ͼ4 .cm-line {
caret-color: hsl(var(--color-text-base)) !important;
}
.ͼ1 .cm-cursor {
border: none;
}
`;
render(): TemplateResult<1> {
return html`<slot name="textarea"></slot>`;
}
}

View File

@ -30,19 +30,19 @@ class Alert extends Component
$variantData = match ($this->variant) {
'success' => [
'class' => 'text-pine-900 bg-pine-100 border-pine-300',
'glyph' => 'check-fill', // @icon('check-fill')
'glyph' => 'check-fill', // @icon("check-fill")
],
'danger' => [
'class' => 'text-red-900 bg-red-100 border-red-300',
'glyph' => 'close-fill', // @icon('close-fill')
'glyph' => 'close-fill', // @icon("close-fill")
],
'warning' => [
'class' => 'text-yellow-900 bg-yellow-100 border-yellow-300',
'glyph' => 'alert-fill', // @icon('alert-fill')
'glyph' => 'alert-fill', // @icon("alert-fill")
],
default => [
'class' => 'text-blue-900 bg-blue-100 border-blue-300',
'glyph' => 'error-warning-fill', // @icon('error-warning-fill')
'glyph' => 'error-warning-fill', // @icon("error-warning-fill")
],
};

View File

@ -6,20 +6,22 @@ namespace App\Views\Components\Forms;
use Override;
class XMLEditor extends FormComponent
class CodeEditor extends FormComponent
{
protected array $props = ['content'];
protected array $props = ['content', 'lang'];
/**
* @var array<string, string>
*/
protected array $attributes = [
'rows' => '5',
'rows' => '6',
'class' => 'textarea',
];
protected string $content = '';
protected string $lang = '';
public function setContent(string $value): void
{
$this->content = htmlspecialchars_decode($value);
@ -32,7 +34,7 @@ class XMLEditor extends FormComponent
$textarea = form_textarea($this->attributes, $this->content);
return <<<HTML
<xml-editor>{$textarea}</xml-editor>
<code-editor lang="{$this->lang}">{$textarea}</code-editor>
HTML;
}
}

View File

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Views\Decorators;
use CodeIgniter\View\ViewDecoratorInterface;
use Override;
class SiteHead implements ViewDecoratorInterface
{
private static int $renderedCount = 0;
#[Override]
public static function decorate(string $html): string
{
if (url_is(config('Admin')->gateway . '*') || url_is(config('Install')->gateway)) {
return $html;
}
if (static::$renderedCount > 0) {
return $html;
}
ob_start(); // Start output buffering
// run hook to add tags to <head>
service('plugins')->siteHead();
$metaTags = ob_get_contents(); // Store buffer in variable
ob_end_clean();
if (str_contains($html, '</head>')) {
$html = str_replace('</head>', "\n\t{$metaTags}\n</head>", $html);
++static::$renderedCount;
}
return $html;
}
}

View File

@ -9,37 +9,37 @@
"php": "^8.3",
"adaures/ipcat-php": "^v1.0.0",
"adaures/podcast-persons-taxonomy": "^v1.0.1",
"aws/aws-sdk-php": "^3.325.2",
"aws/aws-sdk-php": "^3.334.7",
"chrisjean/php-ico": "^1.0.4",
"cocur/slugify": "^v4.6.0",
"codeigniter4/framework": "v4.5.5",
"codeigniter4/settings": "v2.2.0",
"codeigniter4/shield": "v1.1.0",
"codeigniter4/tasks": "dev-develop",
"geoip2/geoip2": "v3.0.0",
"geoip2/geoip2": "v3.1.0",
"james-heinrich/getid3": "^2.0.0-beta6",
"league/commonmark": "^2.5.3",
"league/commonmark": "^2.6.0",
"league/html-to-markdown": "5.1.1",
"melbahja/seo": "^v2.1.1",
"michalsn/codeigniter4-uuid": "v1.1.0",
"mpratt/embera": "^2.0.41",
"opawg/user-agents-v2-php": "dev-main",
"phpseclib/phpseclib": "~2.0.47",
"phpseclib/phpseclib": "~2.0.48",
"vlucas/phpdotenv": "v5.6.1",
"whichbrowser/parser": "^v2.1.8",
"yassinedoghri/php-icons": "^v1.2.0",
"yassinedoghri/podcast-feed": "dev-main"
},
"require-dev": {
"captainhook/captainhook": "^5.23.6",
"codeigniter/phpstan-codeigniter": "v1.4.3",
"captainhook/captainhook": "^5.24.1",
"codeigniter/phpstan-codeigniter": "v1.5.1",
"mikey179/vfsstream": "^v1.6.12",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^1.12.7",
"phpunit/phpunit": "^10.5.38",
"rector/rector": "^1.2.9",
"phpstan/phpstan": "^2.0.3",
"phpunit/phpunit": "^11.5.1",
"rector/rector": "^2.0.3",
"symplify/coding-standard": "^12.2.3",
"symplify/easy-coding-standard": "^12.3.6"
"symplify/easy-coding-standard": "^12.5.4"
},
"autoload": {
"psr-4": {

961
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@ RSS feed.
Here is a good place to add new tags to the generated channel.
```php
public function rssAfterChannel(Podcast $podcast, SimpleRSSElement $channel): void
public function rssAfterChannel(Podcast $podcast, RssFeed $channel): void
{
// …
}
@ -65,7 +65,7 @@ feed.
Here is a good place to add new tags to the generated item.
```php
public function rssAfterItem(Epsiode $episode, SimpleRSSElement $item): void
public function rssAfterItem(Epsiode $episode, RssFeed $item): void
{
// …
}
@ -75,11 +75,11 @@ public function rssAfterItem(Epsiode $episode, SimpleRSSElement $item): void
This hook is executed in the public pages' `<head>` tag.
This is a good place to add meta tags and third-party scripts to Castopod's
public pages.
This is a good place to add meta tags, custom styles, and third-party scripts to
Castopod's public pages.
```php
public function siteHead(): void
public function siteHead(HtmlHead $head): void
{
// …
}

View File

@ -101,16 +101,16 @@ each property being a field key and the value being a `Field` object.
A field is a form element:
| Property | Type | Note |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `type` | `checkbox` \| `datetime` \| `email` \| `group` \| `markdown` \| `number` \| `radio-group` \| `select-multiple` \| `select` \| `text` \| `textarea` \| `toggler` \| `url` | Default is `text` |
| `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) |
| `helper` | `string` | Can be translated (see i18n) |
| `optional` | `boolean` | Default is `false` |
| `options` | `Options` | Required for `radio-group`, `select-multiple`, and `select` types. |
| `multiple` | `boolean` | Default is `false` |
| `fields` | `Array<string, Field>` | Required for `group` type |
| Property | Type | Note |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `type` | `checkbox` \| `datetime` \| `email` \| `group` \| `html` \| `markdown` \| `number` \| `radio-group` \| `rss` \| `select-multiple` \| `select` \| `text` \| `textarea` \| `toggler` \| `url` | Default is `text` |
| `label` (required) | `string` | Can be translated (see i18n) |
| `hint` | `string` | Can be translated (see i18n) |
| `helper` | `string` | Can be translated (see i18n) |
| `optional` | `boolean` | Default is `false` |
| `options` | `Options` | Required for `radio-group`, `select-multiple`, and `select` types. |
| `multiple` | `boolean` | Default is `false` |
| `fields` | `Array<string, Field>` | Required for `group` type |
#### Options object

View File

@ -2,8 +2,9 @@
title: 验证 & 授权
---
Castopod 使用 `codeigniter/shield` 处理身份验证和授权 与自定义规则。 角色和权限
在两个级别上定义: Roles and permissions are defined at two levels:
Castopod 使用 `codeigniter/shield`
处理身份验证和授权 与自定义规则。 角色和权限在两个级别上定义: Roles and
permissions are defined at two levels:
1. [实例范围](#1-instance-wide-roles-and-permissions)
2. [每个播客](#2-per-podcast-roles-and-permissions)

View File

@ -4,15 +4,12 @@ title: 官方 Docker 镜像
Castopod 在其自动构建期间会将 3 个 Docker 映像推送到 Docker Hub
- [**`castopod/castopod`**](https://hub.docker.com/r/castopod/castopod);一个使
用 nginx 单元的整合 Castopod 镜像
- [**`castopod/app`**](https://hub.docker.com/r/castopod/app):应用程序包,包含
所有 Castopod 依赖关系
- [**`castopod/web-server`**](https://hub.docker.com/r/castopod/web-server)Castopod
的 Nginx 配置
- [**`castopod/castopod`**](https://hub.docker.com/r/castopod/castopod);一个使用 nginx 单元的整合 Castopod 镜像
- [**`castopod/app`**](https://hub.docker.com/r/castopod/app):应用程序包,包含所有 Castopod 依赖关系
- [**`castopod/web-server`**](https://hub.docker.com/r/castopod/web-server)Castopod 的 Nginx 配置
此外Castopod 需要一个与 MySQL 兼容的数据库。 Redis 数据库 可以添加为缓存处理
器。 A Redis database can be added as a cache handler.
此外Castopod 需要一个与 MySQL 兼容的数据库。 Redis 数据库 可以添加为缓存处理器。 A
Redis database can be added as a cache handler.
## 目前支持的标签

View File

@ -35,9 +35,8 @@ PHP version 8.3 or higher is required, with the following extensions installed:
> 我们建议使用 [MariaDB](https://mariadb.org)。
你需要填写服务器主机名、数据库名称、用户名和密码才能完成安装过程。 如果没有这
些,请与你的服务器管理员联系。 If you do not have these, please contact your
server administrator.
你需要填写服务器主机名、数据库名称、用户名和密码才能完成安装过程。 如果没有这些,请与你的服务器管理员联系。 If
you do not have these, please contact your server administrator.
#### 权限
@ -46,8 +45,8 @@ server administrator.
### 可选FFmpeg v4.1.8 或更高版本,用于视频素材
如果你需要视频素材,则需要 [FFFmpeg](https://www.ffmpeg.org/) 4.1.8 或更高版本。
必须安装以下扩展: The following extensions must be installed:
如果你需要视频素材,则需要 [FFFmpeg](https://www.ffmpeg.org/)
4.1.8 或更高版本。必须安装以下扩展: The following extensions must be installed:
- **FreeType 2** 来自库
[gd](https://www.php.net/manual/en/image.installation.php)
@ -63,11 +62,11 @@ server administrator.
### Pre-requisites
0. 需要一台已经实现 [环境要求](#requirements)的 Web 服务器
1. 为 Castopod 创建一个 MySQL 数据库,其中用户具有访问和修改权限(有关详细信息,
请参阅 [MySQL 兼容数据库](#mysql-compatible-database))。
1. 为 Castopod 创建一个 MySQL 数据库,其中用户具有访问和修改权限(有关详细信息,请参阅
[MySQL 兼容数据库](#mysql-compatible-database))。
2. 使用 _SSL 证书_ 在您的域激活 HTTPS。
3. 下载最新的 [Castopod](https://castopod.org/) 到 web 服务器并解压(如果尚未下
载)。
3. 下载最新的 [Castopod](https://castopod.org/)
到 web 服务器并解压(如果尚未下载)。
- ⚠️ 将 web 服务器根目录设置为 `castopod` 文件夹中的 `public/` 子文件夹。
4. 在 Web 服务器上为各种后台进程添加 **cron 任务** (相应地替换路径):
@ -85,16 +84,16 @@ server administrator.
### (推荐) 安装向导
1. 前往你最喜欢的浏览器并跳转至安装向导页面
(`https://your_domain_name.com/cp-install`)运行 Castopod 安装脚本。
1. 前往你最喜欢的浏览器并跳转至安装向导页面 (`https://your_domain_name.com/cp-install`)运行 Castopod 安装脚本。
2. 请按照屏幕上的说明进行操作。
3. 开始播客!
<Aside>
The install script writes a `.env` file in the package root. 安装脚本将会在根目
录中创建一个 `.env` 文件并写入数据。 如果你不能执行安装向导,那么可以基于
`.env.example` 文件手动创建和编辑 `.env` 文件。
The install script writes a `.env` file in the package
root. 安装脚本将会在根目录中创建一个 `.env`
文件并写入数据。 如果你不能执行安装向导,那么可以基于 `.env.example`
文件手动创建和编辑 `.env` 文件。
</Aside>
@ -147,9 +146,9 @@ email.SMTPPass="你的邮件密码"
### 媒体存储
By default, files are saved to the `public/media` folder using the file system.
默认情况下,文件使用文件系统保存到 `公共/媒体` 文件夹中。 如果您需要将 `media`
文件夹重新定位到其他位置,您可以在您的 `.env` 文件中指定它,如下所示:
By default, files are saved to the `public/media` folder using the file
system. 默认情况下,文件使用文件系统保存到 `公共/媒体` 文件夹中。 如果您需要将
`media` 文件夹重新定位到其他位置,您可以在您的 `.env` 文件中指定它,如下所示:
```ini
# […]
@ -194,9 +193,9 @@ media.s3.region="your_s3_region"
### 使用 YunoHost 安装
[YunoHost](https://yunohost.org/) 是一个基于 Debian GNU/Linux 的发行版,由免费和
开源软件包组成。 它可以为你解决自托管的困难。 It manages the hardships of
self-hosting for you.
[YunoHost](https://yunohost.org/) 是一个基于 Debian
GNU/Linux 的发行版,由免费和开源软件包组成。 它可以为你解决自托管的困难。 It
manages the hardships of self-hosting for you.
<div class="flex flex-wrap items-center gap-4">

View File

@ -5,8 +5,7 @@ title: 安全问题
Castopod 构建于 [CodeIgniter4](https://codeigniter.com/), PHP 框架上,鼓励
[更好的安全实践](https://codeigniter.com/user_guide/concepts/security.html)。
为了最大限度地提高你实例的安全性并防止任何恶意攻击。 我们 建议你在安装或更新后检
查所有的 Castopod 文件权限(避免任何之前的权限错误)
为了最大限度地提高你实例的安全性并防止任何恶意攻击。 我们 建议你在安装或更新后检查所有的 Castopod 文件权限(避免任何之前的权限错误)
- `writable/` 文件夹权限为 **可读** 和 **可写**。
- `public/media/` 文件夹权限为 **可读** 和 **可写**。

View File

@ -4,8 +4,7 @@ title: 如何更新 Castopod
import { Aside } from "@astrojs/starlight/components";
安装 Castopod 后,你可能希望将实例更新到最新版本 版本以享受最新功能 ✨, 修复错误
🐛 和性能提升 ⚡。
安装 Castopod 后,你可能希望将实例更新到最新版本 版本以享受最新功能 ✨, 修复错误 🐛 和性能提升 ⚡。
## 更新说明
@ -13,14 +12,14 @@ import { Aside } from "@astrojs/starlight/components";
- 参看. [我应该在更新前进行备份吗?](#should-i-make-a-backup-before-updating)
1. 前往 [发布页面](https://code.castopod.org/adaures/castopod/-/releases) 和 查
看您的实例是否是最新的 Castopod 版本
1. 前往 [发布页面](https://code.castopod.org/adaures/castopod/-/releases)
和 查看您的实例是否是最新的 Castopod 版本
- 参看
[我在哪里可以找到我的 Castopod 版本?](#where-can-i-find-my-castopod-version)
2. 下载名为`Castopod Package`的最新发布包,你可以在 `zip` 或 `tar.gz` 压缩包之间
选择
2. 下载名为`Castopod Package`的最新发布包,你可以在 `zip` 或 `tar.gz`
压缩包之间选择
- ⚠️ 请确保你下载的是 Castopod 软件包而 **不是** 源代码
- 请注意,你还可以从 [castopod.org](https://castopod.org/)
@ -83,8 +82,9 @@ them sequentially, from the oldest to the newest.
1. 下载最新版本,覆盖您的文件,同时保留 `.env` 文件和 `public/media` 文件夹。
2. 从 `v1.0.0-alpha.43` 开始,按顺序执行每个版本更新指令(从老版本到 最新版本)
然后是 `v1.0.0-alpha.44``v1.0.0-alpha.45`,…,直到 `v1.0.0-beta.1`。
2. 从 `v1.0.0-alpha.43`
开始,按顺序执行每个版本更新指令(从老版本到 最新版本),然后是
`v1.0.0-alpha.44``v1.0.0-alpha.45`,…,直到 `v1.0.0-beta.1`。
3. ✨ 享受你的新实例, 你已经更新完毕!

View File

@ -4,11 +4,10 @@ title: 欢迎 👋
import { LinkCard } from "@astrojs/starlight/components";
Castopod 是一个免费的开源播客托管平台,为那些想要和听众接触与互动的播客们制作
的。
Castopod 是一个免费的开源播客托管平台,为那些想要和听众接触与互动的播客们制作的。
Castopod 易于安装,并使用 [CodeIgniter4](https://codeigniter.com/) 构建, 这是一
个强大的 PHP 框架,并且占用极小。
Castopod 易于安装,并使用 [CodeIgniter4](https://codeigniter.com/)
构建, 这是一个强大的 PHP 框架,并且占用极小。
<LinkCard title="安装" href="./getting-started/install" />
@ -44,28 +43,24 @@ Castopod 易于安装,并使用 [CodeIgniter4](https://codeigniter.com/) 构
- 📤  也支持将播客移出 Castopod
- 🔀  多租户:根据需要托管任意数量的播客
- 👥  多用户:添加贡献者并设置角色
- 🌎  i18n 支持:翻译成英语,法语,波兰语,德语,巴西葡萄牙语和西班牙语
...[还有更多](https://translate.castopod.org) and
- 🌎
 i18n 支持:翻译成英语,法语,波兰语,德语,巴西葡萄牙语和西班牙语 ...[还有更多](https://translate.castopod.org) and
[many more](https://translate.castopod.org)!
## 创作动机
播客生态系统本质上是去中心化的:你可以创建自己的播客 RSS 文件,将其发布到网络上
并在线共享。
播客生态系统本质上是去中心化的:你可以创建自己的播客 RSS 文件,将其发布到网络上并在线共享。
事实上,它是唯一长期保持这种状态的媒体之一。
随着习惯的发展,越来越多的人开始接触播客:允许创作者寻找新的方式来分享他们的想
法,或是让听众获得更好的内容。
随着习惯的发展,越来越多的人开始接触播客:允许创作者寻找新的方式来分享他们的想法,或是让听众获得更好的内容。
随着播客的使用越来越广泛,一些公司正试图控制播客与集中化。
Castopod 的创建旨在提供一种开放且可持续的替代方案来托管你的播客,促进权力下放,
确保播客可以用创造力表达自己。
Castopod 的创建旨在提供一种开放且可持续的替代方案来托管你的播客,促进权力下放,确保播客可以用创造力表达自己。
此项目由开源社区推动的,特别是
由[联邦宇宙](https://fediverse.party/en/fediverse/) 和
[播客 2.0](https://podcastindex.org/) 推动。
此项目由开源社区推动的,特别是由[联邦宇宙](https://fediverse.party/en/fediverse/)
和 [播客 2.0](https://podcastindex.org/) 推动。
## 与其他解决方案的对比
@ -75,51 +70,42 @@ gauge whether Castopod is the right fit for you.
### Castopod 对比 Wordpress
Castopod 经常被称为 “播客中的 Wordpress”因为两者有很多相似之处。 在某些方面,
确实如此。 实际上Castopod 受到 WordPress 生态的极大启发,看到了采用社区的易用
性以及运行它的网站数量。 In some ways this is true. And actually, Castopod was
greatly inspired by the Wordpress ecosystem, seeing the ease of adoption from
the community and the number of websites running it.
Castopod 经常被称为 “播客中的 Wordpress”因为两者有很多相似之处。 在某些方面,确实如此。 实际上Castopod 受到 WordPress 生态的极大启发,看到了采用社区的易用性以及运行它的网站数量。 In
some ways this is true. And actually, Castopod was greatly inspired by the
Wordpress ecosystem, seeing the ease of adoption from the community and the
number of websites running it.
就像 Wordpress 一样Castopod 是免费 & 开源的PHP 构建并使用 MySQL 数据库,可以
在大多数 Web 服务器上轻松安装。
就像 Wordpress 一样Castopod 是免费 & 开源的PHP 构建并使用 MySQL 数据库,可以在大多数 Web 服务器上轻松安装。
Wordpress 是创建你的网站,并使用插件扩展以获得想要内容的好办法。 这是一个成熟的
CMS可以帮助你在线访问任何类型的网站。 It is a full fledged CMS that helps you
get any type of website online.
Wordpress 是创建你的网站,并使用插件扩展以获得想要内容的好办法。 这是一个成熟的 CMS可以帮助你在线访问任何类型的网站。 It
is a full fledged CMS that helps you get any type of website online.
另一方面Castopod 旨在专门满足播客的需求,专注于播客,而不是其他。 你不需要任何
插件即可轻松开始播客之旅。 You don't need any plugin to get you started on your
podcasting journey.
另一方面Castopod 旨在专门满足播客的需求,专注于播客,而不是其他。 你不需要任何插件即可轻松开始播客之旅。 You
don't need any plugin to get you started on your podcasting journey.
还拥有对播客的独特优化:从播客的创建和新剧集的发布一直到广播,营销和分析。
最后根据你的需要Wordpress 和 Castopod 甚至可以共存,因为他们有相同的配置环
境!
最后根据你的需要Wordpress 和 Castopod 甚至可以共存,因为他们有相同的配置环境!
### Castopod 对比 Funkwhale
Funkwhale 是一个自托管、现代界面、免费开源的音乐服务器。 就像 Castopod 一
Funkwhale 也位于联邦宇宙中这是一个去中心化的社交网络允许两者的互联。Just
Funkwhale 是一个自托管、现代界面、免费开源的音乐服务器。 就像 Castopod 一样Funkwhale 也位于联邦宇宙中这是一个去中心化的社交网络允许两者的互联。Just
as Castopod, Funkwhale is on the fediverse, a decentralized social network
allowing interoperability between the two.
Funkwhale 最初是围绕音乐制作的。 后来随着项目的发展引入了托管播客的能力。And
later on, as the project evolved, the ability to host podcasts was introduced.
与 Funkwhale 不同Castopod 是只围绕播客设计和构建的。 这样可以更简单地实现与播
客相关的生态系统,例如播客 2.0 功能(报表、 章节、地点、人员…)。 This allows
easier implementation for features related to the podcasting ecosystem, such as
the podcasting 2.0 features (transcripts, chapters, locations, persons, …).
与 Funkwhale 不同Castopod 是只围绕播客设计和构建的。 这样可以更简单地实现与播客相关的生态系统,例如播客 2.0 功能(报表、 章节、地点、人员…)。 This
allows easier implementation for features related to the podcasting ecosystem,
such as the podcasting 2.0 features (transcripts, chapters, locations, persons,
…).
因此,如果你想托管你的音乐库,你可能应该使用 Funkwhale如果您想主持一个播客
使用 Castopod。
因此,如果你想托管你的音乐库,你可能应该使用 Funkwhale如果您想主持一个播客请使用 Castopod。
### Castopod 与其他播客
有许多非常棒地解决方案可供你托管播客,并
且[很多](https://podcastindex.org/apps)正在搭上播客 2.0 的便车,就像 Castopod 一
样!
有许多非常棒地解决方案可供你托管播客,并且[很多](https://podcastindex.org/apps)正在搭上播客 2.0 的便车,就像 Castopod 一样!
这些解决方案各不相同,你可以对比 [功能列表](#features)。
@ -129,34 +115,30 @@ the podcasting 2.0 features (transcripts, chapters, locations, persons, …).
full control over what you produce. Also, as it is open-source, you can even
customize it as you wish.
- Castopod 是目前唯一一个同时集成去中心化的,带有 ActivePub 的社交网络以及很多播
客 2.0 功能集成的解决方案,希望弥合两者之间的差距。
- Castopod 是目前唯一一个同时集成去中心化的,带有 ActivePub 的社交网络以及很多播客 2.0 功能集成的解决方案,希望弥合两者之间的差距。
## 贡献
喜欢 Castopod 并且想帮忙吗? 请查看以下文档以帮助你入门。 请查看以下文档以帮助你
入门。
喜欢 Castopod 并且想帮忙吗? 请查看以下文档以帮助你入门。 请查看以下文档以帮助你入门。
### 行为准则
Castopod has adopted a Code of Conduct that we expect project participants to
adhere to. Castopod 已经通过了一项行为准则,并希望所有的参与者都能够遵循本行为准
则。 请阅
读[行为准则](https://code.castopod.org/adaures/castopod/-/blob/beta/CODE_OF_CONDUCT.md)
adhere to.
Castopod 已经通过了一项行为准则,并希望所有的参与者都能够遵循本行为准则。 请阅读[行为准则](https://code.castopod.org/adaures/castopod/-/blob/beta/CODE_OF_CONDUCT.md)
以便了解哪些行为被允许,哪些行为不会被容忍。
### 贡献指南
阅读我们的 [贡献指南](../contributing/guidelines.md) ,了解我们的开发过程。 提出
错 误修正和改进想法,以及如何构建和测试 Castopod 。
阅读我们的 [贡献指南](../contributing/guidelines.md)
,了解我们的开发过程。 提出错 误修正和改进想法,以及如何构建和测试 Castopod 。
## 联系
你可以联系我们寻求帮助或提出任何问题:
- [Discord](https://castopod.org/discord) (用于与开发人员和社区直接互动)
- [问题跟踪器](https://code.castopod.org/adaures/castopod/-/issues)(用于功能请
求和错误报告)
- [问题跟踪器](https://code.castopod.org/adaures/castopod/-/issues)(用于功能请求和错误报告)
或者,你可以在社交媒体上关注我们,以获取有关 Castopod 的新闻:
@ -168,8 +150,8 @@ adhere to. Castopod 已经通过了一项行为准则,并希望所有的参与
## 赞助商
The ongoing development of Castopod is made possible with the support of its
backers. Castopod 的发展离不开赞助商的支持。 如果你想要帮助我们,请考
虑[赞助 Castopod 的开发](https://opencollective.com/castopod/contribute).
backers.
Castopod 的发展离不开赞助商的支持。 如果你想要帮助我们,请考虑[赞助 Castopod 的开发](https://opencollective.com/castopod/contribute).
[![Ad Aures Logo](../../../assets/images/sponsors/adaures.svg)](https://adaures.com/)

View File

@ -2,8 +2,9 @@
title: 認證 & 授權
---
Castopod 使用 `codeigniter/shield` 處理身分認證和授權 與自定義規則。 腳色和權限
在定義為兩個層級: Roles and permissions are defined at two levels:
Castopod 使用 `codeigniter/shield`
處理身分認證和授權 與自定義規則。 腳色和權限在定義為兩個層級: Roles and
permissions are defined at two levels:
1. [實例範圍](#1-instance-wide-roles-and-permissions)
2. [每個播客](#2-per-podcast-roles-and-permissions)

View File

@ -16,7 +16,7 @@ $routes->add('scheduled-video-clips', 'SchedulerController::generateVideoClips',
// Admin area routes
$routes->group(
config('Admin')
->gateway,
->gateway,
[
'namespace' => 'Modules\Admin\Controllers',
],

View File

@ -25,6 +25,7 @@ class AboutController extends BaseController
'languages' => implode(', ', config('App')->supportedLocales),
];
$this->setHtmlHead(lang('AboutCastopod.title'));
return view('settings/about', [
'info' => $instanceInfo,
]);

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Modules\Admin\Controllers;
use App\Libraries\HtmlHead;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\IncomingRequest;
use CodeIgniter\HTTP\RequestInterface;
@ -41,4 +42,16 @@ abstract class BaseController extends Controller
Theme::setTheme('admin');
}
protected function setHtmlHead(string $title): void
{
/** @var HtmlHead $head */
$head = service('html_head');
$head
->title($title . ' | Castopod Admin')
->description(
'Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience.'
);
}
}

View File

@ -81,6 +81,7 @@ class DashboardController extends BaseController
'onlyPodcastId' => $onlyPodcastId,
];
$this->setHtmlHead(lang('Dashboard.home'));
return view('dashboard', $data);
}
}

View File

@ -115,6 +115,7 @@ class EpisodeController extends BaseController
'query' => $query,
];
$this->setHtmlHead(lang('Episode.all_podcast_episodes'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -128,6 +129,7 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead($this->episode->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -148,6 +150,8 @@ class EpisodeController extends BaseController
$currentSeasonNumber
),
];
$this->setHtmlHead(lang('Episode.create'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -261,6 +265,7 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Episode.edit'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -409,6 +414,7 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Episode.publish'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -521,6 +527,7 @@ class EpisodeController extends BaseController
->first(),
];
$this->setHtmlHead(lang('Episode.publish_edit'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -670,11 +677,11 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Episode.publish_date_edit'));
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => $this->episode->title,
]);
return view('episode/publish_date_edit', $data);
}
@ -746,6 +753,7 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Episode.unpublish'));
replace_breadcrumb_params([
0 => $this->podcast->title,
1 => $this->episode->title,
@ -822,6 +830,7 @@ class EpisodeController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Episode.delete'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -928,6 +937,7 @@ class EpisodeController extends BaseController
'themes' => EpisodeModel::$themes,
];
$this->setHtmlHead(lang('Episode.embed.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,

View File

@ -58,6 +58,8 @@ class EpisodePersonController extends BaseController
'personOptions' => (new PersonModel())->getPersonOptions(),
'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(),
];
$this->setHtmlHead(lang('Person.episode_form.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,

View File

@ -26,6 +26,7 @@ class FediverseController extends BaseController
$blockedActors = model('ActorModel', false)
->getBlockedActors();
$this->setHtmlHead(lang('Fediverse.blocked_actors'));
return view('fediverse/blocked_actors', [
'blockedActors' => $blockedActors,
]);
@ -38,6 +39,7 @@ class FediverseController extends BaseController
$blockedDomains = model('BlockedDomainModel', false)
->getBlockedDomains();
$this->setHtmlHead(lang('Fediverse.blocked_domains'));
return view('fediverse/blocked_domains', [
'blockedDomains' => $blockedDomains,
]);

View File

@ -66,10 +66,10 @@ class NotificationController extends BaseController
'pager' => $notifications->pager,
];
$this->setHtmlHead(lang('Notifications.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
return view('podcast/notifications', $data);
}

View File

@ -34,6 +34,7 @@ class PageController extends BaseController
public function list(): string
{
$this->setHtmlHead(lang('Page.all_pages'));
$data = [
'pages' => (new PageModel())->findAll(),
];
@ -43,6 +44,7 @@ class PageController extends BaseController
public function view(): string
{
$this->setHtmlHead($this->page->title);
return view('page/view', [
'page' => $this->page,
]);
@ -52,6 +54,7 @@ class PageController extends BaseController
{
helper('form');
$this->setHtmlHead(lang('Page.create'));
return view('page/create');
}
@ -83,6 +86,7 @@ class PageController extends BaseController
{
helper('form');
$this->setHtmlHead(lang('Page.edit'));
replace_breadcrumb_params([
0 => $this->page->title,
]);

View File

@ -42,6 +42,7 @@ class PersonController extends BaseController
->findAll(),
];
$this->setHtmlHead(lang('Person.all_persons'));
return view('person/list', $data);
}
@ -51,6 +52,7 @@ class PersonController extends BaseController
'person' => $this->person,
];
$this->setHtmlHead($this->person->full_name);
replace_breadcrumb_params([
0 => $this->person->full_name,
]);
@ -61,6 +63,7 @@ class PersonController extends BaseController
{
helper(['form']);
$this->setHtmlHead(lang('Person.create'));
return view('person/create');
}
@ -112,6 +115,7 @@ class PersonController extends BaseController
'person' => $this->person,
];
$this->setHtmlHead(lang('Person.edit'));
replace_breadcrumb_params([
0 => $this->person->full_name,
]);

View File

@ -68,6 +68,7 @@ class PodcastController extends BaseController
];
}
$this->setHtmlHead(lang('Podcast.all_podcasts'));
return view('podcast/list', $data);
}
@ -77,6 +78,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -89,6 +91,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -101,6 +104,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -113,6 +117,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -125,6 +130,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -137,6 +143,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -149,6 +156,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -161,6 +169,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead($this->podcast->title);
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -180,6 +189,7 @@ class PodcastController extends BaseController
'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
];
$this->setHtmlHead(lang('Podcast.create'));
return view('podcast/create', $data);
}
@ -270,6 +280,7 @@ class PodcastController extends BaseController
'categoryOptions' => $categoryOptions,
];
$this->setHtmlHead(lang('Podcast.edit'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -410,6 +421,7 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead(lang('Podcast.delete'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -578,10 +590,10 @@ class PodcastController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead(lang('Podcast.publish'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
return view('podcast/publish', $data);
}
@ -713,10 +725,10 @@ class PodcastController extends BaseController
->first(),
];
$this->setHtmlHead(lang('Podcast.publish_edit'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
return view('podcast/publish_edit', $data);
}

View File

@ -46,6 +46,8 @@ class PodcastPersonController extends BaseController
'personOptions' => (new PersonModel())->getPersonOptions(),
'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(),
];
$this->setHtmlHead(lang('Person.podcast_form.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);

View File

@ -30,6 +30,7 @@ class SettingsController extends BaseController
public function index(): string
{
helper('form');
$this->setHtmlHead(lang('Settings.title'));
return view('settings/general');
}
@ -202,6 +203,7 @@ class SettingsController extends BaseController
public function theme(): string
{
helper('form');
$this->setHtmlHead(lang('Settings.theme.title'));
return view('settings/theme');
}

View File

@ -76,6 +76,7 @@ class SoundbiteController extends BaseController
'pager' => $soundbitesBuilder->pager,
];
$this->setHtmlHead(lang('Soundbite.list.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -92,6 +93,7 @@ class SoundbiteController extends BaseController
'episode' => $this->episode,
];
$this->setHtmlHead(lang('Soundbite.form.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,

View File

@ -82,6 +82,7 @@ class VideoClipsController extends BaseController
'pager' => $videoClipsBuilder->pager,
];
$this->setHtmlHead(lang('VideoClip.list.title'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -99,6 +100,9 @@ class VideoClipsController extends BaseController
'videoClip' => $videoClip,
];
$this->setHtmlHead(lang('VideoClip.title', [
'videoClipLabel' => esc($videoClip->title),
]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->episode->title,
@ -128,9 +132,10 @@ class VideoClipsController extends BaseController
'transcript' => $this->episode->transcript instanceof Transcript,
];
$this->setHtmlHead(lang('VideoClip.form.title'));
if (in_array(false, $checks, true)) {
$data['checks'] = $checks;
return view('episode/video_clips_requirements', $data);
}

View File

@ -2,15 +2,13 @@
declare(strict_types=1);
use CodeIgniter\Router\RouteCollection;
/**
* @copyright 2021 Ad Aures
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
/** @var RouteCollection $routes */
/** @var \CodeIgniter\Router\RouteCollection $routes */
/**
* Analytics routes file

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
use Opawg\UserAgentsV2Php\UserAgentsRSS;
/**

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Modules\Analytics\Entities;
use CodeIgniter\Entity\Entity;
use CodeIgniter\I18n\Time;
/**
* @property int $podcast_id

View File

@ -286,8 +286,7 @@ if (! function_exists('podcast_hit')) {
$parts = explode('-', $range);
$downloadedBytes += array_key_exists(1, $parts)
? $fileSize
: (int) $parts[1] -
(array_key_exists(0, $parts) ? 0 : (int) $parts[0]);
: (int) $parts[1] - (int) $parts[0];
}
}

View File

@ -71,7 +71,7 @@ class EpisodeController extends Controller
protected static function mapEpisode(Episode $episode): Episode
{
$episode->cover_url = $episode->getCover()
->file_url;
->file_url;
$episode->duration = round($episode->audio->duration);
return $episode;

View File

@ -44,9 +44,9 @@ class PodcastController extends Controller
{
$podcast->feed_url = $podcast->getFeedUrl();
$podcast->actor_display_name = $podcast->getActor()
->display_name;
->display_name;
$podcast->cover_url = $podcast->getCover()
->file_url;
->file_url;
$categories = [$podcast->getCategory(), ...$podcast->getOtherCategories()];

View File

@ -56,6 +56,7 @@ class ContributorController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead(lang('Contributor.podcast_contributors'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -69,6 +70,10 @@ class ContributorController extends BaseController
'contributor' => (new UserModel())->getPodcastContributor($this->contributor->id, $this->podcast->id),
];
$this->setHtmlHead(lang('Contributor.view', [
'username' => esc($this->contributor->username),
'podcastTitle' => esc($this->podcast->title),
]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->contributor->username,
@ -113,6 +118,7 @@ class ContributorController extends BaseController
'roleOptions' => $roleOptions,
];
$this->setHtmlHead(lang('Contributor.add_contributor', [esc($this->podcast->title)]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -170,6 +176,7 @@ class ContributorController extends BaseController
'roleOptions' => $roleOptions,
];
$this->setHtmlHead(lang('Contributor.edit_role', [esc($this->contributor->username)]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->contributor->username,
@ -208,6 +215,9 @@ class ContributorController extends BaseController
'contributor' => $this->contributor,
];
$this->setHtmlHead(lang('Contributor.delete_form.title', [
'contributor' => $this->contributor->username,
]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => $this->contributor->username,

View File

@ -18,6 +18,7 @@ class MyAccountController extends BaseController
{
public function index(): string
{
$this->setHtmlHead(lang('MyAccount.info'));
return view('my_account/view');
}
@ -25,6 +26,7 @@ class MyAccountController extends BaseController
{
helper('form');
$this->setHtmlHead(lang('MyAccount.changePassword'));
return view('my_account/change_password');
}

View File

@ -42,6 +42,7 @@ class UserController extends BaseController
'users' => (new UserModel())->findAll(),
];
$this->setHtmlHead(lang('User.all_users'));
return view('user/list', $data);
}
@ -51,6 +52,9 @@ class UserController extends BaseController
'user' => $this->user,
];
$this->setHtmlHead(lang('User.view', [
'username' => esc($this->user->username),
]));
replace_breadcrumb_params([
0 => $this->user->username,
]);
@ -76,6 +80,7 @@ class UserController extends BaseController
'roleOptions' => $roleOptions,
];
$this->setHtmlHead(lang('User.create'));
return view('user/create', $data);
}
@ -182,6 +187,9 @@ class UserController extends BaseController
'roleOptions' => $roleOptions,
];
$this->setHtmlHead(lang('User.edit_role', [
'username' => esc($this->user->username),
]));
replace_breadcrumb_params([
0 => $this->user->username,
]);
@ -221,6 +229,9 @@ class UserController extends BaseController
'user' => $this->user,
];
$this->setHtmlHead(lang('User.delete_form.title', [
'user' => $this->user->username,
]));
replace_breadcrumb_params([
0 => $this->user->username,
]);

View File

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Modules\Fediverse\Entities;
use CodeIgniter\I18n\Time;
use Michalsn\Uuid\UuidEntity;
use RuntimeException;

View File

@ -41,7 +41,7 @@ if (! function_exists('split_handle')) {
/**
* Splits handle into its parts (username, host and port)
*
* @return array<string, string>|false
* @return array{0:string,username:non-empty-string,1:non-empty-string,domain:non-empty-string,2:non-empty-string,port?:non-falsy-string,3?:non-falsy-string}
*/
function split_handle(string $handle): array | false
{

View File

@ -101,7 +101,7 @@ class WebFinger
/**
* Split resource into its parts (username, domain)
*
* @return array<string, string>|false
* @return array{0:string,username:non-empty-string,1:non-empty-string,2:non-empty-string,domain:non-falsy-string,3:non-falsy-string,4:non-falsy-string,5?:non-falsy-string}
*/
private function splitResource(string $resource): bool|array
{

View File

@ -25,7 +25,7 @@ use RuntimeException;
* @property string $file_extension
* @property int $file_size
* @property string $file_mimetype
* @property array|null $file_metadata
* @property array<mixed>|null $file_metadata
* @property 'image'|'audio'|'video'|'document' $type
* @property string|null $description
* @property string|null $language_code

View File

@ -16,7 +16,7 @@ use GdImage;
use Override;
/**
* @property array $sizes
* @property array<string, array<string, int|string>> $sizes
*/
class Image extends BaseMedia
{

View File

@ -34,7 +34,7 @@ if (! function_exists('download_file')) {
curl_setopt($ch, CURLOPT_HTTPHEADER, ['User-Agent: Castopod/' . CP_VERSION]);
// follow redirects up to 20, like Apple Podcasts
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 20);
curl_exec($ch);

View File

@ -55,10 +55,10 @@ class PlatformController extends BaseController
'platforms' => (new PlatformModel())->getPlatformsWithData($this->podcast->id, $platformType),
];
$this->setHtmlHead(lang("Platforms.title.{$platformType}"));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
return view('podcast/platforms', $data);
}

View File

@ -127,7 +127,7 @@ class PlatformModel extends Model
}
/**
* @param array<array<string, string|int>> $data
* @param array<array<string, bool|int|string|null>> $data
*
* @return int|false Number of rows inserted or FALSE on failure
*/

View File

@ -458,7 +458,7 @@ class Platforms
}
/**
* @return null|array{label:string,home_url:string,submit_url:?string}>
* @return null|array{label:string,home_url:string,submit_url:?string}
*/
public function findPlatformBySlug(string $type, string $slug): ?array
{

View File

@ -16,10 +16,10 @@ class CreatePlugin extends BaseCommand
{
protected const HOOKS_IMPORTS = [
'rssBeforeChannel' => ['use App\Entities\Podcast;'],
'rssAfterChannel' => ['use App\Entities\Podcast;', 'use App\Libraries\SimpleRSSElement;'],
'rssAfterChannel' => ['use App\Entities\Podcast;', 'use App\Libraries\RssFeed;'],
'rssBeforeItem' => ['use App\Entities\Episode;'],
'rssAfterItem' => ['use App\Entities\Episode;', 'use App\Libraries\SimpleRSSElement;'],
'siteHead' => [],
'rssAfterItem' => ['use App\Entities\Episode;', 'use App\Libraries\RssFeed;'],
'siteHead' => ['use use App\Libraries\HtmlHead'],
];
protected const HOOKS_METHODS = [
@ -27,7 +27,7 @@ class CreatePlugin extends BaseCommand
{
// YOUR CODE HERE
}',
'rssAfterChannel' => ' public function rssAfterChannel(Podcast $podcast, SimpleRSSElement $channel): void
'rssAfterChannel' => ' public function rssAfterChannel(Podcast $podcast, RssFeed $channel): void
{
// YOUR CODE HERE
}',
@ -35,11 +35,11 @@ class CreatePlugin extends BaseCommand
{
// YOUR CODE HERE
}',
'rssAfterItem' => ' public function rssAfterItem(Episode $episode, SimpleRSSElement $item): void
'rssAfterItem' => ' public function rssAfterItem(Episode $episode, RssFeed $item): void
{
// YOUR CODE HERE
}',
'siteHead' => ' public function siteHead(): void
'siteHead' => ' public function siteHead(HtmlHead $head): void
{
// YOUR CODE HERE
}',

View File

@ -8,7 +8,7 @@ use CodeIgniter\Router\RouteCollection;
$routes->group(
config('Admin')
->gateway,
->gateway,
[
'namespace' => 'Modules\Plugins\Controllers',
],

View File

@ -38,6 +38,7 @@ class PluginController extends BaseController
$pager_links = $pager->makeLinks($page, $perPage, $total);
$this->setHtmlHead(lang('Plugins.installed'));
return view('plugins/installed', [
'total' => $total,
'plugins' => $this->plugins->getPlugins($page, $perPage),
@ -47,8 +48,9 @@ class PluginController extends BaseController
public function vendor(string $vendor): string
{
$vendorPlugins = $this->plugins->getVendorPlugins($vendor);
$this->setHtmlHead(lang('Plugins.installed'));
replace_breadcrumb_params([
$vendor => $vendor,
]);
@ -68,6 +70,7 @@ class PluginController extends BaseController
throw PageNotFoundException::forPageNotFound();
}
$this->setHtmlHead($plugin->getTitle());
replace_breadcrumb_params([
$vendor => $vendor,
$package => $package,
@ -137,6 +140,10 @@ class PluginController extends BaseController
$data['fields'] = $fields;
helper('form');
$this->setHtmlHead(lang('Plugins.settingsTitle', [
'pluginTitle' => $plugin->getTitle(),
'type' => $type,
]));
replace_breadcrumb_params($breadcrumbReplacements);
return view('plugins/settings', $data);
}

View File

@ -6,6 +6,7 @@ namespace Modules\Plugins\Core;
use App\Entities\Episode;
use App\Entities\Podcast;
use App\Libraries\HtmlHead;
use App\Libraries\RssFeed;
use CodeIgniter\HTTP\URI;
use League\CommonMark\Environment\Environment;
@ -101,7 +102,7 @@ abstract class BasePlugin implements PluginInterface
}
#[Override]
public function siteHead(): void
public function siteHead(HtmlHead $head): void
{
}
@ -264,7 +265,7 @@ abstract class BasePlugin implements PluginInterface
return $title;
}
final public function getDescription(): ?string
final public function getDescription(): string
{
$key = sprintf('Plugin.%s.description', $this->key);

View File

@ -6,6 +6,7 @@ namespace Modules\Plugins\Core;
use App\Entities\Episode;
use App\Entities\Podcast;
use App\Libraries\HtmlHead;
use App\Libraries\RssFeed;
interface PluginInterface
@ -18,5 +19,5 @@ interface PluginInterface
public function rssAfterItem(Episode $episode, RssFeed $item): void;
public function siteHead(): void;
public function siteHead(HtmlHead $head): void;
}

View File

@ -6,6 +6,7 @@ namespace Modules\Plugins\Core;
use App\Entities\Episode;
use App\Entities\Podcast;
use App\Libraries\HtmlHead;
use App\Libraries\RssFeed;
use Config\Database;
use Modules\Plugins\Config\Plugins as PluginsConfig;
@ -15,7 +16,7 @@ use Modules\Plugins\Config\Plugins as PluginsConfig;
* @method void rssAfterChannel(Podcast $podcast, RssFeed $channel)
* @method void rssBeforeItem(Episode $episode)
* @method void rssAfterItem(Episode $episode, RssFeed $item)
* @method void siteHead()
* @method void siteHead(HtmlHead $head)
*/
class Plugins
{
@ -29,6 +30,7 @@ class Plugins
'datetime' => ['valid_date[Y-m-d H:i]'],
'email' => ['valid_email'],
'group' => ['permit_empty', 'is_list'],
'html' => ['string'],
'markdown' => ['string'],
'number' => ['integer'],
'radio-group' => ['string'],

View File

@ -7,7 +7,7 @@ namespace Modules\Plugins\Manifest;
use Override;
/**
* @property 'checkbox'|'datetime'|'email'|'group'|'markdown'|'number'|'radio-group'|'rss'|'select-multiple'|'select'|'text'|'textarea'|'toggler'|'url' $type
* @property 'checkbox'|'datetime'|'email'|'group'|'html'|'markdown'|'number'|'radio-group'|'rss'|'select-multiple'|'select'|'text'|'textarea'|'toggler'|'url' $type
* @property string $key
* @property string $label
* @property string $hint
@ -20,7 +20,7 @@ use Override;
class Field extends ManifestObject
{
protected const VALIDATION_RULES = [
'type' => 'permit_empty|in_list[checkbox,datetime,email,group,markdown,number,radio-group,rss,select-multiple,select,text,textarea,toggler,url]',
'type' => 'permit_empty|in_list[checkbox,datetime,email,group,html,markdown,number,radio-group,rss,select-multiple,select,text,textarea,toggler,url]',
'key' => 'required|alpha_dash',
'label' => 'required|string',
'hint' => 'permit_empty|string',

View File

@ -178,6 +178,7 @@
"datetime",
"email",
"group",
"html",
"markdown",
"number",
"radio-group",

View File

@ -160,7 +160,7 @@ class PodcastImport extends BaseCommand
$podcastModel = new PodcastModel();
if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
throw new Exception((string) print_r($podcastModel->errors()));
throw new Exception(print_r($podcastModel->errors(), true));
}
CLI::showProgress(false);
@ -260,7 +260,7 @@ class PodcastImport extends BaseCommand
$podcastModel = new PodcastModel();
if (! ($podcastId = $podcastModel->insert($podcast, true))) {
$db->transRollback();
throw new Exception((string) print_r($podcastModel->errors()));
throw new Exception(print_r($podcastModel->errors(), true));
}
$podcast->id = $podcastId;
@ -326,7 +326,7 @@ class PodcastImport extends BaseCommand
]);
if (! $newPersonId = $personModel->insert($newPodcastPerson)) {
throw new Exception((string) print_r($personModel->errors()));
throw new Exception(print_r($personModel->errors(), true));
}
}
@ -353,7 +353,7 @@ class PodcastImport extends BaseCommand
$personGroupSlug,
$personRoleSlug
)) {
throw new Exception((string) print_r($podcastPersonModel->errors()));
throw new Exception(print_r($podcastPersonModel->errors(), true));
}
}
@ -498,7 +498,7 @@ class PodcastImport extends BaseCommand
if (! ($episodeId = $episodeModel->insert($episode, true))) {
$db->transRollback();
throw new Exception((string) print_r($episodeModel->errors()));
throw new Exception(print_r($episodeModel->errors(), true));
}
$this->importEpisodePersons($episodeId, $item->podcast_persons);
@ -546,7 +546,7 @@ class PodcastImport extends BaseCommand
]);
if (! ($newPersonId = $personModel->insert($newPerson))) {
throw new Exception((string) print_r($personModel->errors()));
throw new Exception(print_r($personModel->errors(), true));
}
}
@ -574,7 +574,7 @@ class PodcastImport extends BaseCommand
$personGroupSlug,
$personRoleSlug
)) {
throw new Exception((string) print_r($episodePersonModel->errors()));
throw new Exception(print_r($episodePersonModel->errors(), true));
}
}
}

View File

@ -28,6 +28,7 @@ class PodcastImportController extends BaseController
{
helper('podcast_import');
$this->setHtmlHead(lang('Podcast.all_imports'));
return view('import/queue', [
'podcastImportsQueue' => get_import_tasks(),
]);
@ -41,6 +42,7 @@ class PodcastImportController extends BaseController
helper('podcast_import');
$this->setHtmlHead(lang('Podcast.all_imports'));
replace_breadcrumb_params([
0 => $podcast->at_handle,
]);
@ -63,6 +65,7 @@ class PodcastImportController extends BaseController
'browserLang' => get_browser_language($this->request->getServer('HTTP_ACCEPT_LANGUAGE')),
];
$this->setHtmlHead(lang('Podcast.import'));
return view('import/add_to_queue', $data);
}
@ -112,6 +115,7 @@ class PodcastImportController extends BaseController
helper('form');
$this->setHtmlHead(lang('PodcastImport.syncForm.title'));
replace_breadcrumb_params([
0 => $podcast->at_handle,
]);

View File

@ -21,10 +21,6 @@ if (! function_exists('get_import_tasks')) {
$podcastImportsQueue = service('settings')
->get('Import.queue') ?? [];
if (! is_array($podcastImportsQueue)) {
return [];
}
if ($podcastHandle !== null) {
$podcastImportsQueue = array_filter(
$podcastImportsQueue,
@ -48,6 +44,6 @@ if (! function_exists('get_import_tasks')) {
return $a->created_at->isAfter($b->created_at) ? -1 : 1;
});
return array_values($podcastImportsQueue);
return $podcastImportsQueue;
}
}

View File

@ -59,6 +59,7 @@ class SubscriptionController extends BaseController
helper('form');
$this->setHtmlHead(lang('Subscription.podcast_subscriptions'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -109,6 +110,7 @@ class SubscriptionController extends BaseController
'subscription' => $this->subscription,
];
$this->setHtmlHead(lang('Subscription.view', [$this->subscription->id]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => '#' . $this->subscription->id,
@ -124,6 +126,7 @@ class SubscriptionController extends BaseController
'podcast' => $this->podcast,
];
$this->setHtmlHead(lang('Subscription.add', [esc($this->podcast->title)]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
]);
@ -250,6 +253,7 @@ class SubscriptionController extends BaseController
'subscription' => $this->subscription,
];
$this->setHtmlHead(lang('Subscription.edit', [esc($this->podcast->title)]));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => '#' . $this->subscription->id,
@ -318,6 +322,7 @@ class SubscriptionController extends BaseController
'subscription' => $this->subscription,
];
$this->setHtmlHead(lang('Subscription.suspend'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => '#' . $this->subscription->id,
@ -413,6 +418,7 @@ class SubscriptionController extends BaseController
'subscription' => $this->subscription,
];
$this->setHtmlHead(lang('Subscription.delete'));
replace_breadcrumb_params([
0 => $this->podcast->at_handle,
1 => '#' . $this->subscription->id,

View File

@ -58,9 +58,7 @@ class Subscription extends Entity
public function getStatus(): string
{
return ($this->expires_at instanceof Time && $this->expires_at->isBefore(
Time::now()
)) ? 'expired' : $this->attributes['status'];
return $this->expires_at->isBefore(Time::now()) ? 'expired' : $this->attributes['status'];
}
/**

Some files were not shown because too many files have changed in this diff Show More