From 3d363f2efe99836ac05c305a2fa683e342f06561 Mon Sep 17 00:00:00 2001 From: Ola Hneini Date: Tue, 5 Jul 2022 16:39:20 +0000 Subject: [PATCH] feat: add publish feature for podcasts and set draft by default closes #128, #220 --- .../2020-05-30-101500_add_podcasts.php | 4 + app/Database/Seeds/AuthSeeder.php | 2 +- app/Entities/Episode.php | 2 + app/Entities/Podcast.php | 24 ++ app/Helpers/components_helper.php | 59 ++- app/Helpers/misc_helper.php | 27 ++ app/Models/EpisodeModel.php | 2 +- app/Models/PodcastModel.php | 9 +- app/Resources/icons/warning.svg | 6 + app/Resources/styles/custom.css | 10 + modules/Admin/Config/Routes.php | 43 +++ .../Admin/Controllers/EpisodeController.php | 144 ++++--- .../Admin/Controllers/PodcastController.php | 361 ++++++++++++++++++ .../Controllers/PodcastImportController.php | 15 + modules/Admin/Language/en/Episode.php | 14 +- modules/Admin/Language/en/Podcast.php | 39 +- .../WebSub/Controllers/WebSubController.php | 1 + themes/cp_admin/_layout.php | 3 + themes/cp_admin/episode/publish.php | 40 +- themes/cp_admin/episode/publish_edit.php | 38 +- themes/cp_admin/podcast/_card.php | 10 +- themes/cp_admin/podcast/create.php | 2 +- themes/cp_admin/podcast/edit.php | 2 +- themes/cp_admin/podcast/publish.php | 80 ++++ themes/cp_admin/podcast/publish_edit.php | 81 ++++ 25 files changed, 909 insertions(+), 109 deletions(-) create mode 100644 app/Resources/icons/warning.svg create mode 100644 themes/cp_admin/podcast/publish.php create mode 100644 themes/cp_admin/podcast/publish_edit.php diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php index bc992195..61f3b86a 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -177,6 +177,10 @@ class AddPodcasts extends Migration 'type' => 'INT', 'unsigned' => true, ], + 'published_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], 'created_at' => [ 'type' => 'DATETIME', ], diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index 8f09f54a..365726e6 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -168,7 +168,7 @@ class AuthSeeder extends Seeder [ 'name' => 'manage_publications', 'description' => - 'Publish / unpublish episodes & posts of a podcast', + 'Publish a podcast and publish / unpublish its episodes & posts', 'has_permission' => ['podcast_admin'], ], [ diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 6474642e..60f89c44 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -541,6 +541,8 @@ class Episode extends Entity if ($this->publication_status === null) { if ($this->published_at === null) { $this->publication_status = 'not_published'; + } elseif ($this->getPodcast()->publication_status !== 'published') { + $this->publication_status = 'with_podcast'; } elseif ($this->published_at->isBefore(Time::now())) { $this->publication_status = 'published'; } else { diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 9e58b461..bf1b64b7 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -79,6 +79,8 @@ use RuntimeException; * @property string|null $partner_image_url * @property int $created_by * @property int $updated_by + * @property string $publication_status; + * @property Time|null $published_at; * @property Time $created_at; * @property Time $updated_at; * @@ -147,6 +149,13 @@ class Podcast extends Entity protected string $custom_rss_string; + protected ?string $publication_status = null; + + /** + * @var string[] + */ + protected $dates = ['published_at', 'created_at', 'updated_at']; + /** * @var array */ @@ -459,6 +468,21 @@ class Podcast extends Entity return $this->description; } + public function getPublicationStatus(): string + { + if ($this->publication_status === null) { + if ($this->published_at === null) { + $this->publication_status = 'not_published'; + } elseif ($this->published_at->isBefore(Time::now())) { + $this->publication_status = 'published'; + } else { + $this->publication_status = 'scheduled'; + } + } + + return $this->publication_status; + } + /** * Returns the podcast's podcasting platform links * diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php index 067b4dfb..6bef7013 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -116,18 +116,27 @@ if (! function_exists('publication_pill')) { $class = match ($publicationStatus) { 'published' => 'text-pine-500 border-pine-500 bg-pine-50', 'scheduled' => 'text-red-600 border-red-600 bg-red-50', + 'with_podcast' => 'text-blue-600 border-blue-600 bg-blue-50', 'not_published' => 'text-gray-600 border-gray-600 bg-gray-50', default => 'text-gray-600 border-gray-600 bg-gray-50', }; + $title = match ($publicationStatus) { + 'published', 'scheduled' => (string) $publicationDate, + 'with_podcast' => lang('Episode.with_podcast_hint'), + 'not_published' => '', + default => '', + }; + $label = lang('Episode.publication_status.' . $publicationStatus); - return '' . $label . + ($publicationStatus === 'with_podcast' ? '' : '') . ''; } } @@ -136,7 +145,7 @@ if (! function_exists('publication_pill')) { if (! function_exists('publication_button')) { /** - * Publication button component + * Publication button component for episodes * * Displays the appropriate publication button depending on the publication post. */ @@ -149,6 +158,7 @@ if (! function_exists('publication_button')) { $variant = 'primary'; $iconLeft = 'upload-cloud'; break; + case 'with_podcast': case 'scheduled': $label = lang('Episode.publish_edit'); $route = route_to('episode-publish_edit', $podcastId, $episodeId); @@ -177,6 +187,51 @@ if (! function_exists('publication_button')) { // ------------------------------------------------------------------------ +if (! function_exists('publication_status_banner')) { + /** + * Publication status banner component for podcasts + * + * Displays the appropriate banner depending on the podcast's publication status. + */ + function publication_status_banner(?Time $publicationDate, int $podcastId, string $publicationStatus): string + { + switch ($publicationStatus) { + case 'not_published': + $bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode'); + $bannerText = lang('Podcast.publication_status_banner.not_published'); + $linkRoute = route_to('podcast-publish', $podcastId); + $linkLabel = lang('Podcast.publish'); + break; + case 'scheduled': + $bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode'); + $bannerText = lang('Podcast.publication_status_banner.scheduled', [ + 'publication_date' => local_time($publicationDate), + ], null, false); + $linkRoute = route_to('podcast-publish_edit', $podcastId); + $linkLabel = lang('Podcast.publish_edit'); + break; + default: + $bannerDisclaimer = ''; + $bannerText = ''; + $linkRoute = ''; + $linkLabel = ''; + break; + } + + return << +

+ {$bannerDisclaimer} + {$bannerText} +

+ {$linkLabel} + + CODE_SAMPLE; + } +} + +// ------------------------------------------------------------------------ + if (! function_exists('episode_numbering')) { /** * Returns relevant translated episode numbering. diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index f4b0a602..e69e9e87 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -8,6 +8,8 @@ declare(strict_types=1); * @link https://castopod.org/ */ +use CodeIgniter\I18n\Time; + if (! function_exists('get_browser_language')) { /** * Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE` @@ -292,3 +294,28 @@ if (! function_exists('format_bytes')) { return round($bytes, $precision) . $units[$pow]; } } + +if (! function_exists('local_time')) { + function local_time(Time $time): string + { + $formatter = new IntlDateFormatter(service( + 'request' + )->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG); + $translatedDate = $time->toLocalizedString($formatter->getPattern()); + $datetime = $time->format(DateTime::ISO8601); + + return << + + + CODE_SAMPLE; + } +} diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 1c8a6667..f218f47b 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -142,7 +142,7 @@ class EpisodeModel extends Model ->join('podcasts', 'podcasts.id = episodes.podcast_id') ->where('slug', $episodeSlug) ->where('podcasts.handle', $podcastHandle) - ->where('`published_at` <= UTC_TIMESTAMP()', null, false) + ->where('`' . $this->db->getPrefix() . 'episodes`.`published_at` <= UTC_TIMESTAMP()', null, false) ->first(); cache() diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 169acfc8..8a5fd8c6 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -64,6 +64,7 @@ class PodcastModel extends Model 'partner_id', 'partner_link_url', 'partner_image_url', + 'published_at', 'created_by', 'updated_by', ]; @@ -92,6 +93,7 @@ class PodcastModel extends Model 'owner_email' => 'required|valid_email', 'new_feed_url' => 'valid_url_strict|permit_empty', 'type' => 'required', + 'published_at' => 'valid_date|permit_empty', 'created_by' => 'required', 'updated_by' => 'required', ]; @@ -128,6 +130,7 @@ class PodcastModel extends Model $cacheName = "podcast-{$podcastHandle}"; if (! ($found = cache($cacheName))) { $found = $this->where('handle', $podcastHandle) + ->where('`published_at` <= UTC_TIMESTAMP()', null, false) ->first(); cache() ->save("podcast-{$podcastHandle}", $found, DECADE); @@ -168,9 +171,9 @@ class PodcastModel extends Model */ public function getAllPodcasts(string $orderBy = null): array { - if ($orderBy === 'activity') { - $prefix = $this->db->getPrefix(); + $prefix = $this->db->getPrefix(); + if ($orderBy === 'activity') { $fediverseTablePrefix = $prefix . config('Fediverse') ->tablesPrefix; $this->builder() @@ -195,7 +198,7 @@ class PodcastModel extends Model $this->orderBy('created_at', 'ASC'); } - return $this->findAll(); + return $this->where('`' . $prefix . 'podcasts`.`published_at` <= UTC_TIMESTAMP()', null, false)->findAll(); } /** diff --git a/app/Resources/icons/warning.svg b/app/Resources/icons/warning.svg new file mode 100644 index 00000000..e01de7fb --- /dev/null +++ b/app/Resources/icons/warning.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css index eceef962..fc539d27 100644 --- a/app/Resources/styles/custom.css +++ b/app/Resources/styles/custom.css @@ -42,4 +42,14 @@ hsla(0 0% 0% / 0.8) 100% ); } + + .bg-stripes-gray { + background-image: repeating-linear-gradient( + -45deg, + #f3f4f6, + #f3f4f6 10px, + #e5e7eb 10px, + #e5e7eb 20px + ); + } } diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index 4bde002e..b3ffcd13 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -119,6 +119,49 @@ $routes->group( $routes->post('edit', 'PodcastController::attemptEdit/$1', [ 'filter' => 'permission:podcast-edit', ]); + $routes->get( + 'publish', + 'PodcastController::publish/$1', + [ + 'as' => 'podcast-publish', + 'filter' => + 'permission:podcast-manage_publications', + ], + ); + $routes->post( + 'publish', + 'PodcastController::attemptPublish/$1', + [ + 'filter' => + 'permission:podcast-manage_publications', + ], + ); + $routes->get( + 'publish-edit', + 'PodcastController::publishEdit/$1', + [ + 'as' => 'podcast-publish_edit', + 'filter' => + 'permission:podcast-manage_publications', + ], + ); + $routes->post( + 'publish-edit', + 'PodcastController::attemptPublishEdit/$1', + [ + 'filter' => + 'permission:podcast-manage_publications', + ], + ); + $routes->get( + 'publish-cancel', + 'PodcastController::publishCancel/$1', + [ + 'as' => 'podcast-publish-cancel', + 'filter' => + 'permission:podcast-manage_publications', + ], + ); $routes->get('edit/delete-banner', 'PodcastController::deleteBanner/$1', [ 'as' => 'podcast-banner-delete', 'filter' => 'permission:podcast-edit', diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index 619c5622..18c961c2 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -440,17 +440,19 @@ class EpisodeController extends BaseController public function attemptPublish(): RedirectResponse { - $rules = [ - 'publication_method' => 'required', - 'scheduled_publication_date' => - 'valid_date[Y-m-d H:i]|permit_empty', - ]; + if ($this->podcast->publication_status === 'published') { + $rules = [ + 'publication_method' => 'required', + 'scheduled_publication_date' => + 'valid_date[Y-m-d H:i]|permit_empty', + ]; - if (! $this->validate($rules)) { - return redirect() - ->back() - ->withInput() - ->with('errors', $this->validator->getErrors()); + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } } $db = db_connect(); @@ -463,22 +465,29 @@ class EpisodeController extends BaseController 'created_by' => user_id(), ]); - $publishMethod = $this->request->getPost('publication_method'); - if ($publishMethod === 'schedule') { - $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); - if ($scheduledPublicationDate) { - $this->episode->published_at = Time::createFromFormat( - 'Y-m-d H:i', - $scheduledPublicationDate, - $this->request->getPost('client_timezone'), - )->setTimezone(app_timezone()); + if ($this->podcast->publication_status === 'published') { + $publishMethod = $this->request->getPost('publication_method'); + if ($publishMethod === 'schedule') { + $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); + if ($scheduledPublicationDate) { + $this->episode->published_at = Time::createFromFormat( + 'Y-m-d H:i', + $scheduledPublicationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } else { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('error', lang('Episode.messages.scheduleDateError')); + } } else { - $db->transRollback(); - return redirect() - ->back() - ->withInput() - ->with('error', 'Schedule date must be set!'); + $this->episode->published_at = Time::now(); } + } elseif ($this->podcast->publication_status === 'scheduled') { + // podcast publication date has already been set + $this->episode->published_at = $this->podcast->published_at->addSeconds(1); } else { $this->episode->published_at = Time::now(); } @@ -505,12 +514,17 @@ class EpisodeController extends BaseController $db->transComplete(); - return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]); + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( + 'message', + lang('Episode.messages.publishSuccess', [ + 'publication_status' => $this->episode->publication_status, + ]) + ); } public function publishEdit(): string | RedirectResponse { - if ($this->episode->publication_status === 'scheduled') { + if (in_array($this->episode->publication_status, ['scheduled', 'with_podcast'], true)) { helper(['form']); $data = [ @@ -539,39 +553,48 @@ class EpisodeController extends BaseController public function attemptPublishEdit(): RedirectResponse { - $rules = [ - 'post_id' => 'required', - 'publication_method' => 'required', - 'scheduled_publication_date' => - 'valid_date[Y-m-d H:i]|permit_empty', - ]; + if ($this->podcast->publication_status === 'published') { + $rules = [ + 'post_id' => 'required', + 'publication_method' => 'required', + 'scheduled_publication_date' => + 'valid_date[Y-m-d H:i]|permit_empty', + ]; - if (! $this->validate($rules)) { - return redirect() - ->back() - ->withInput() - ->with('errors', $this->validator->getErrors()); + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } } $db = db_connect(); $db->transStart(); - $publishMethod = $this->request->getPost('publication_method'); - if ($publishMethod === 'schedule') { - $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); - if ($scheduledPublicationDate) { - $this->episode->published_at = Time::createFromFormat( - 'Y-m-d H:i', - $scheduledPublicationDate, - $this->request->getPost('client_timezone'), - )->setTimezone(app_timezone()); + if ($this->podcast->publication_status === 'published') { + $publishMethod = $this->request->getPost('publication_method'); + if ($publishMethod === 'schedule') { + $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); + if ($scheduledPublicationDate) { + $this->episode->published_at = Time::createFromFormat( + 'Y-m-d H:i', + $scheduledPublicationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } else { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('error', lang('Episode.messages.scheduleDateError')); + } } else { - $db->transRollback(); - return redirect() - ->back() - ->withInput() - ->with('error', 'Schedule date must be set!'); + $this->episode->published_at = Time::now(); } + } elseif ($this->podcast->publication_status === 'scheduled') { + // podcast publication date has already been set + $this->episode->published_at = $this->podcast->published_at->addSeconds(1); } else { $this->episode->published_at = Time::now(); } @@ -603,12 +626,17 @@ class EpisodeController extends BaseController $db->transComplete(); - return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]); + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( + 'message', + lang('Episode.messages.publishSuccess', [ + 'publication_status' => $this->episode->publication_status, + ]) + ); } public function publishCancel(): RedirectResponse { - if ($this->episode->publication_status === 'scheduled') { + if (in_array($this->episode->publication_status, ['scheduled', 'with_podcast'], true)) { $db = db_connect(); $db->transStart(); @@ -634,13 +662,13 @@ class EpisodeController extends BaseController $db->transComplete(); - return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]); + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( + 'message', + lang('Episode.messages.publishCancelSuccess') + ); } - return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( - 'message', - lang('Episode.messages.publishCancelSuccess') - ); + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]); } public function unpublish(): string | RedirectResponse diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php index 8a18b7b3..e0813877 100644 --- a/modules/Admin/Controllers/PodcastController.php +++ b/modules/Admin/Controllers/PodcastController.php @@ -12,14 +12,17 @@ namespace Modules\Admin\Controllers; use App\Entities\Location; use App\Entities\Podcast; +use App\Entities\Post; use App\Models\ActorModel; use App\Models\CategoryModel; use App\Models\EpisodeModel; use App\Models\LanguageModel; use App\Models\MediaModel; use App\Models\PodcastModel; +use App\Models\PostModel; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\I18n\Time; use Config\Services; use Modules\Analytics\Models\AnalyticsPodcastByCountryModel; use Modules\Analytics\Models\AnalyticsPodcastByEpisodeModel; @@ -237,6 +240,7 @@ class PodcastController extends BaseController 'is_locked' => $this->request->getPost('lock') === 'yes', 'created_by' => user_id(), 'updated_by' => user_id(), + 'published_at' => null, ]); $podcastModel = new PodcastModel(); @@ -604,4 +608,361 @@ class PodcastController extends BaseController 'podcast_handle' => $this->podcast->handle, ])); } + + public function publish(): string | RedirectResponse + { + helper(['form']); + + $data = [ + 'podcast' => $this->podcast, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + + return view('podcast/publish', $data); + } + + public function attemptPublish(): RedirectResponse + { + if ($this->podcast->publication_status !== 'not_published') { + return redirect()->route('podcast-view', [$this->podcast->id])->with( + 'error', + lang('Podcast.messages.publishError') + ); + } + + $rules = [ + 'publication_method' => 'required', + 'scheduled_publication_date' => + 'valid_date[Y-m-d H:i]|permit_empty', + ]; + + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $db = db_connect(); + $db->transStart(); + + $publishMethod = $this->request->getPost('publication_method'); + if ($publishMethod === 'schedule') { + $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); + if ($scheduledPublicationDate) { + $this->podcast->published_at = Time::createFromFormat( + 'Y-m-d H:i', + $scheduledPublicationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } else { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('error', lang('Podcast.messages.scheduleDateError')); + } + } else { + $this->podcast->published_at = Time::now(); + } + + $message = $this->request->getPost('message'); + // only create post if message is not empty + if ($message !== '') { + $newPost = new Post([ + 'actor_id' => $this->podcast->actor_id, + 'message' => $message, + 'created_by' => user_id(), + ]); + + $newPost->published_at = $this->podcast->published_at; + + $postModel = new PostModel(); + if (! $postModel->addPost($newPost)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $postModel->errors()); + } + } + + $episodes = (new EpisodeModel()) + ->where('podcast_id', $this->podcast->id) + ->where('published_at !=', null) + ->findAll(); + + foreach ($episodes as $episode) { + $episode->published_at = $this->podcast->published_at->addSeconds(1); + + $episodeModel = new EpisodeModel(); + if (! $episodeModel->update($episode->id, $episode)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + + $post = (new PostModel())->where('episode_id', $episode->id) + ->first(); + + if ($post !== null) { + $post->published_at = $episode->published_at; + $postModel = new PostModel(); + if (! $postModel->update($post->id, $post)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $postModel->errors()); + } + } + } + + $podcastModel = new PodcastModel(); + if (! $podcastModel->update($this->podcast->id, $this->podcast)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastModel->errors()); + } + + $db->transComplete(); + + return redirect()->route('podcast-view', [$this->podcast->id]); + } + + public function publishEdit(): string | RedirectResponse + { + helper(['form']); + + $data = [ + 'podcast' => $this->podcast, + 'post' => (new PostModel()) + ->where([ + 'actor_id' => $this->podcast->actor_id, + 'episode_id' => null, + ]) + ->first(), + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + + return view('podcast/publish_edit', $data); + } + + public function attemptPublishEdit(): RedirectResponse + { + if ($this->podcast->publication_status !== 'scheduled') { + return redirect()->route('podcast-view', [$this->podcast->id])->with( + 'error', + lang('Podcast.messages.publishEditError') + ); + } + + $rules = [ + 'publication_method' => 'required', + 'scheduled_publication_date' => + 'valid_date[Y-m-d H:i]|permit_empty', + ]; + + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $db = db_connect(); + $db->transStart(); + + $publishMethod = $this->request->getPost('publication_method'); + if ($publishMethod === 'schedule') { + $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date'); + if ($scheduledPublicationDate) { + $this->podcast->published_at = Time::createFromFormat( + 'Y-m-d H:i', + $scheduledPublicationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + } else { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('error', lang('Podcast.messages.scheduleDateError')); + } + } else { + $this->podcast->published_at = Time::now(); + } + + $post = (new PostModel()) + ->where([ + 'actor_id' => $this->podcast->actor_id, + 'episode_id' => null, + ]) + ->first(); + + $newPostMessage = $this->request->getPost('message'); + + if ($post !== null) { + if ($newPostMessage !== '') { + // edit post if post exists and message is not empty + $post->message = $newPostMessage; + $post->published_at = $this->podcast->published_at; + + $postModel = new PostModel(); + if (! $postModel->editPost($post)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $postModel->errors()); + } + } else { + // remove post if post exists and message is empty + $postModel = new PostModel(); + $post = $postModel + ->where([ + 'actor_id' => $this->podcast->actor_id, + 'episode_id' => null, + ]) + ->first(); + $postModel->removePost($post); + } + } elseif ($newPostMessage !== '') { + // create post if there is no post and message is not empty + $newPost = new Post([ + 'actor_id' => $this->podcast->actor_id, + 'message' => $newPostMessage, + 'created_by' => user_id(), + ]); + + $newPost->published_at = $this->podcast->published_at; + + $postModel = new PostModel(); + if (! $postModel->addPost($newPost)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $postModel->errors()); + } + } + + $episodes = (new EpisodeModel()) + ->where('podcast_id', $this->podcast->id) + ->where('published_at !=', null) + ->findAll(); + + foreach ($episodes as $episode) { + $episode->published_at = $this->podcast->published_at->addSeconds(1); + + $episodeModel = new EpisodeModel(); + if (! $episodeModel->update($episode->id, $episode)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + + $post = (new PostModel())->where('episode_id', $episode->id) + ->first(); + + if ($post !== null) { + $post->published_at = $episode->published_at; + $postModel = new PostModel(); + if (! $postModel->update($post->id, $post)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $postModel->errors()); + } + } + } + + $podcastModel = new PodcastModel(); + if (! $podcastModel->update($this->podcast->id, $this->podcast)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastModel->errors()); + } + + $db->transComplete(); + + return redirect()->route('podcast-view', [$this->podcast->id]); + } + + public function publishCancel(): RedirectResponse + { + if ($this->podcast->publication_status !== 'scheduled') { + return redirect()->route('podcast-view', [$this->podcast->id]); + } + + $db = db_connect(); + $db->transStart(); + + $postModel = new PostModel(); + $post = $postModel + ->where([ + 'actor_id' => $this->podcast->actor_id, + 'episode_id' => null, + ]) + ->first(); + if ($post !== null) { + $postModel->removePost($post); + } + + $episodes = (new EpisodeModel()) + ->where('podcast_id', $this->podcast->id) + ->where('published_at !=', null) + ->findAll(); + + foreach ($episodes as $episode) { + $episode->published_at = null; + + $episodeModel = new EpisodeModel(); + if (! $episodeModel->update($episode->id, $episode)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + + $postModel = new PostModel(); + $post = $postModel->where('episode_id', $episode->id) + ->first(); + $postModel->removePost($post); + } + + $this->podcast->published_at = null; + + $podcastModel = new PodcastModel(); + if (! $podcastModel->update($this->podcast->id, $this->podcast)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastModel->errors()); + } + + $db->transComplete(); + + return redirect()->route('podcast-view', [$this->podcast->id])->with( + 'message', + lang('Podcast.messages.publishCancelSuccess') + ); + } } diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php index b200910a..bd98e671 100644 --- a/modules/Admin/Controllers/PodcastImportController.php +++ b/modules/Admin/Controllers/PodcastImportController.php @@ -450,12 +450,27 @@ class PodcastImportController extends BaseController ->with('errors', $episodePersonModel->errors()); } } + + if ($itemNumber === 1) { + $firstEpisodePublicationDate = strtotime((string) $item->pubDate); + } } // set interact as the newly imported podcast actor $importedPodcast = (new PodcastModel())->getPodcastById($newPodcastId); set_interact_as_actor($importedPodcast->actor_id); + // set podcast publication date + $importedPodcast->published_at = $firstEpisodePublicationDate ?? $importedPodcast->created_at; + $podcastModel = new PodcastModel(); + if (! $podcastModel->update($importedPodcast->id, $importedPodcast)) { + $db->transRollback(); + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastModel->errors()); + } + $db->transComplete(); return redirect()->route('podcast-view', [$newPodcastId]); diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index e82ff8ca..539f6b89 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -34,9 +34,11 @@ return [ 'create' => 'Add an episode', 'publication_status' => [ 'published' => 'Published', + 'with_podcast' => 'Published', 'scheduled' => 'Scheduled', 'not_published' => 'Not published', ], + 'with_podcast_hint' => 'To be published at the same time as the podcast', 'list' => [ 'search' => [ 'placeholder' => 'Search for an episode', @@ -55,8 +57,15 @@ return [ 'messages' => [ 'createSuccess' => 'Episode has been successfully created!', 'editSuccess' => 'Episode has been successfully updated!', + 'publishSuccess' => '{publication_status, select, + published {Episode successfully published!} + scheduled {Episode publication successfully scheduled!} + with_podcast {This episode will be published at the same time as the podcast.} + other {This episode is not published.} + }', 'publishCancelSuccess' => 'Episode publication successfully cancelled!', 'unpublishBeforeDeleteTip' => 'You must unpublish the episode before deleting it.', + 'scheduleDateError' => 'Schedule date must be set!', 'deletePublishedEpisodeError' => 'Please unpublish the episode before deleting it.', 'deleteSuccess' => 'Episode successfully deleted!', 'deleteError' => 'Failed to delete episode {type, select, @@ -138,9 +147,9 @@ return [ 'If you need RSS tags that Castopod does not handle, set them here.', 'custom_rss' => 'Custom RSS tags for the episode', 'custom_rss_hint' => 'This will be injected within the ❬item❭ tag.', - 'block' => 'Episode should be hidden from all platforms', + 'block' => 'Episode should be hidden from public catalogues', 'block_hint' => - 'The episode show or hide post. If you want this episode removed from the Apple directory, toggle this on.', + 'The episode show or hide status: toggling this on prevents the episode from appearing in Apple Podcasts, Google Podcasts, and any third party apps that pull shows from these directories. (Not guaranteed)', 'submit_create' => 'Create episode', 'submit_edit' => 'Save episode', ], @@ -154,6 +163,7 @@ return [ 'publication_method' => [ 'now' => 'Now', 'schedule' => 'Schedule', + 'with_podcast' => 'Publish alongside podcast', ], 'scheduled_publication_date' => 'Scheduled publication date', 'scheduled_publication_date_clear' => 'Clear publication date', diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php index 2561b876..19a022b5 100644 --- a/modules/Admin/Language/en/Podcast.php +++ b/modules/Admin/Language/en/Podcast.php @@ -16,14 +16,17 @@ return [ 'new_episode' => 'New Episode', 'view' => 'View podcast', 'edit' => 'Edit podcast', + 'publish' => 'Publish podcast', + 'publish_edit' => 'Edit publication', 'delete' => 'Delete podcast', 'see_episodes' => 'See episodes', 'see_contributors' => 'See contributors', 'go_to_page' => 'Go to page', 'latest_episodes' => 'Latest episodes', 'see_all_episodes' => 'See all episodes', + 'draft' => 'Draft', 'messages' => [ - 'createSuccess' => 'Podcast has been successfully created!', + 'createSuccess' => 'Podcast successfully created!', 'editSuccess' => 'Podcast has been successfully updated!', 'importSuccess' => 'Podcast has been successfully imported!', 'deleteSuccess' => 'Podcast @{podcast_handle} successfully deleted!', @@ -46,6 +49,10 @@ return [ } added to the podcast!', 'podcastFeedUpToDate' => 'Podcast is already up to date.', 'podcastNotImported' => 'Podcast could not be updated as it was not imported.', + 'publishError' => 'This podcast is either already published or scheduled for publication.', + 'publishEditError' => 'This podcast is not scheduled for publication.', + 'publishCancelSuccess' => 'Podcast publication successfully cancelled!', + 'scheduleDateError' => 'Schedule date must be set!', ], 'form' => [ 'identity_section_title' => 'Podcast identity', @@ -121,7 +128,9 @@ return [ 'partner_link_url_hint' => 'The generic partner link address', 'partner_image_url_hint' => 'The generic partner image address', 'status_section_title' => 'Status', - 'block' => 'Podcast should be hidden from all platforms', + 'block' => 'Podcast should be hidden from public catalogues', + 'block_hint' => + 'The podcast show or hide status: toggling this on prevents the entire podcast from appearing in Apple Podcasts, Google Podcasts, and any third party apps that pull shows from these directories. (Not guaranteed)', 'complete' => 'Podcast will not be having new episodes', 'lock' => 'Prevent podcast from being copied', 'lock_hint' => @@ -242,6 +251,32 @@ return [ 'film_reviews' => 'Film Reviews', 'tv_reviews' => 'TV Reviews', ], + 'publish_form' => [ + 'back_to_podcast_dashboard' => 'Back to podcast dashboard', + 'post' => 'Your announcement post', + 'post_hint' => + "Write a message to announce the publication of your podcast. The message will be featured in your podcast's homepage.", + 'message_placeholder' => 'Write your message…', + 'submit' => 'Publish', + 'publication_date' => 'Publication date', + 'publication_method' => [ + 'now' => 'Now', + 'schedule' => 'Schedule', + ], + 'scheduled_publication_date' => 'Scheduled publication date', + 'scheduled_publication_date_hint' => + 'You can schedule the podcast release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm', + 'submit_edit' => 'Edit publication', + 'cancel_publication' => 'Cancel publication', + 'message_warning' => 'You did not write a message for your announcement post!', + 'message_warning_hint' => 'Having a message increases social engagement, resulting in a better visibility for your podcast.', + 'message_warning_submit' => 'Publish anyway', + ], + 'publication_status_banner' => [ + 'draft_mode' => 'draft mode', + 'not_published' => 'This podcast is not yet published.', + 'scheduled' => 'This podcast is scheduled for publication on {publication_date}.', + ], 'delete_form' => [ 'disclaimer' => "Deleting the podcast will delete all episodes, media files, posts and analytics associated with it. This action is irreversible, you will not be able to retrieve them afterwards.", diff --git a/modules/WebSub/Controllers/WebSubController.php b/modules/WebSub/Controllers/WebSubController.php index 72e6d314..0ca59e9c 100644 --- a/modules/WebSub/Controllers/WebSubController.php +++ b/modules/WebSub/Controllers/WebSubController.php @@ -32,6 +32,7 @@ class WebSubController extends Controller ->select('podcasts.*') ->join('episodes', 'podcasts.id = episodes.podcast_id', 'left outer') ->where('podcasts.is_published_on_hubs', false) + ->where('`' . $podcastModel->db->getPrefix() . 'podcasts`.`published_at` <= UTC_TIMESTAMP()', null, false) ->orGroupStart() ->where('episodes.is_published_on_hubs', false) ->where('`' . $podcastModel->db->getPrefix() . 'episodes`.`published_at` <= UTC_TIMESTAMP()', null, false) diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php index 621e46fe..5d25b970 100644 --- a/themes/cp_admin/_layout.php +++ b/themes/cp_admin/_layout.php @@ -39,6 +39,9 @@ + publication_status !== 'published'): ?> + published_at, $podcast->id, $podcast->publication_status) ?> +
renderSection('content') ?> diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php index e3a5e223..50cc5049 100644 --- a/themes/cp_admin/episode/publish.php +++ b/themes/cp_admin/episode/publish.php @@ -69,28 +69,30 @@
-
-publication_status === 'published'): ?> +
+ - -
- /> - -
- + +
+ /> + +
+ +
-
-
- +
+ +
diff --git a/themes/cp_admin/episode/publish_edit.php b/themes/cp_admin/episode/publish_edit.php index 56af8dfd..216d1e70 100644 --- a/themes/cp_admin/episode/publish_edit.php +++ b/themes/cp_admin/episode/publish_edit.php @@ -73,27 +73,29 @@
-
-publication_status === 'published'): ?> +
+ - -
- /> - -
- + +
+ /> + +
+ +
-
-
+
+ diff --git a/themes/cp_admin/podcast/_card.php b/themes/cp_admin/podcast/_card.php index 61a60330..14797775 100644 --- a/themes/cp_admin/podcast/_card.php +++ b/themes/cp_admin/podcast/_card.php @@ -1,11 +1,19 @@
-
+
<?= esc($podcast->title) ?>
+ publication_status !== 'published'): ?> + + + publication_status === 'scheduled'): ?> + + + +

title) ?>

@handle) ?>

diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php index 3d4250e3..6cfc85b0 100644 --- a/themes/cp_admin/podcast/create.php +++ b/themes/cp_admin/podcast/create.php @@ -204,7 +204,7 @@ - + diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php index 0fa9ae27..7e903568 100644 --- a/themes/cp_admin/podcast/edit.php +++ b/themes/cp_admin/podcast/edit.php @@ -244,7 +244,7 @@ - + diff --git a/themes/cp_admin/podcast/publish.php b/themes/cp_admin/podcast/publish.php new file mode 100644 index 00000000..8469551f --- /dev/null +++ b/themes/cp_admin/podcast/publish.php @@ -0,0 +1,80 @@ +extend('_layout') ?> + +section('title') ?> + +endSection() ?> + +section('pageTitle') ?> + +endSection() ?> + +section('content') ?> + +id), + icon('arrow-left', 'mr-2 text-lg') . lang('Podcast.publish_form.back_to_podcast_dashboard'), + [ + 'class' => 'inline-flex items-center font-semibold mr-4 text-sm focus:ring-accent', + ], +) ?> + +
+ + + + + +
+
+ <?= esc($podcast->actor->display_name) ?> +
+

+ actor->display_name) ?> + @actor->username) ?> +

+
+
+
+ +
+
+ 0 + 0 + 0 +
+
+ +
+ + +
+ /> + +
+ +
+
+
+ + + +
+ + +
+ +
+ +endSection() ?> diff --git a/themes/cp_admin/podcast/publish_edit.php b/themes/cp_admin/podcast/publish_edit.php new file mode 100644 index 00000000..1cca91b9 --- /dev/null +++ b/themes/cp_admin/podcast/publish_edit.php @@ -0,0 +1,81 @@ +extend('_layout') ?> + +section('title') ?> + +endSection() ?> + +section('pageTitle') ?> + +endSection() ?> + +section('content') ?> + +id), + icon('arrow-left', 'mr-2 text-lg') . lang('Podcast.publish_form.back_to_podcast_dashboard'), + [ + 'class' => 'inline-flex items-center font-semibold mr-4 text-sm', + ], +) ?> + +
+ + + + + +
+
+ <?= esc($podcast->actor->display_name) ?> +
+

+ actor->display_name) ?> + @actor->username) ?> +

+ published_at, 'text-xs text-skin-muted') ?> +
+
+
+ +
+
+ 0 + 0 + 0 +
+
+ +
+ + +
+ /> + +
+ +
+
+
+ + + +
+ + +
+ +
+ +endSection() ?>