Commit 4f1e773c authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat(episodes): schedule episode with future publication_date by using cache expiration time

- merge publication date fields into one field instanciated with flatpickr datetime picker
- get user timezone to convert user publication_date input to UTC
- remove setPublishedAt() method from episode entity
- add publication pill component to display the episode publication date info
- clear cache after episode insert
- use CI is_really_writable() helper in install instead of is_writable()
- fix latest episodes layout
- update tsconfig to only include ts folders
- update DEPENDENCIES.md to include flatpickr
- add format_duration helper to format episode enclosure duration instead of translating it (causes
translation bug)
- add Time.ts module to convert UTC time to user localized time for episode publication dates
- fix some layout issues
- update php and js dependencies to latest versions

closes #47
parent 0ab17d10
Pipeline #334 passed with stage
in 8 minutes and 37 seconds
......@@ -37,6 +37,8 @@ Javascript dependencies:
([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/)
([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
- [flatpickr](https://flatpickr.js.org/)
([MIT License](https://github.com/flatpickr/flatpickr/blob/master/LICENSE.md))
Other:
......
......@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components'];
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components', 'misc'];
/**
* Constructor.
......
......@@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController
{
......@@ -95,9 +96,7 @@ class Episode extends BaseController
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
......@@ -125,11 +124,12 @@ class Episode extends BaseController
'block' => $this->request->getPost('block') == 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('client_timezone')
)->setTimezone('UTC'),
]);
$newEpisode->setPublishedAt(
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$episodeModel = new EpisodeModel();
......@@ -185,9 +185,7 @@ class Episode extends BaseController
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
......@@ -210,10 +208,11 @@ class Episode extends BaseController
: null;
$this->episode->type = $this->request->getPost('type');
$this->episode->block = $this->request->getPost('block') == 'yes';
$this->episode->setPublishedAt(
$this->episode->published_at = Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$this->request->getPost('client_timezone')
)->setTimezone('UTC');
$this->episode->updated_by = user();
$enclosure = $this->request->getFile('enclosure');
......
......@@ -388,11 +388,8 @@ class Podcast extends BaseController
: $nsItunes->block === 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => strtotime($item->pubDate),
]);
$newEpisode->setPublishedAt(
date('Y-m-d', strtotime($item->pubDate)),
date('H:i:s', strtotime($item->pubDate))
);
$episodeModel = new EpisodeModel();
......
......@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['analytics', 'svg', 'components'];
protected $helpers = ['analytics', 'svg', 'components', 'misc'];
/**
* Constructor.
......
......@@ -48,7 +48,8 @@ class Episode extends BaseController
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_{$locale}";
if (!($cachedView = cache($cacheName))) {
$previousNextEpisodes = (new EpisodeModel())->getPreviousNextEpisodes(
$episodeModel = new EpisodeModel();
$previousNextEpisodes = $episodeModel->getPreviousNextEpisodes(
$this->episode,
$this->podcast->type
);
......@@ -60,9 +61,15 @@ class Episode extends BaseController
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}
......
......@@ -8,6 +8,7 @@
namespace App\Controllers;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
......@@ -31,15 +32,29 @@ class Feed extends Controller
// If things go wrong the show must go on and the user must be able to download the file
log_message('critical', $e);
}
$cacheName =
"podcast{$podcast->id}_feed" .
($service ? "_{$service['slug']}" : '');
if (!($found = cache($cacheName))) {
$found = get_rss_feed(
$podcast,
$service ? '?s=' . urlencode($service['name']) : ''
);
cache()->save($cacheName, $found, DECADE);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$podcast->id
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $this->response->setXML($found);
}
......
......@@ -48,7 +48,7 @@ class Install extends Controller
}
// Check if the created .env file is writable to continue install process
if (is_writable(ROOTPATH . '.env')) {
if (is_really_writable(ROOTPATH . '.env')) {
try {
$dotenv->required([
'app.baseURL',
......
......@@ -113,7 +113,7 @@ class Podcast extends BaseController
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes(
'episodes' => $episodeModel->getPodcastEpisodes(
$this->podcast->id,
$this->podcast->type,
$yearQuery,
......@@ -121,8 +121,14 @@ class Podcast extends BaseController
),
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
return view('podcast', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}
......
......@@ -10,6 +10,7 @@ namespace App\Entities;
use App\Models\PodcastModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
class Episode extends Entity
......@@ -49,6 +50,11 @@ class Episode extends Entity
*/
protected $description_html;
/**
* @var boolean
*/
protected $is_published;
protected $dates = [
'published_at',
'created_at',
......@@ -232,17 +238,6 @@ class Episode extends Entity
return $converter->convertToHtml($this->attributes['description']);
}
public function setPublishedAt($date, $time)
{
if (empty($date)) {
$this->attributes['published_at'] = null;
} else {
$this->attributes['published_at'] = $date . ' ' . $time;
}
return $this;
}
public function setCreatedBy(\App\Entities\User $user)
{
$this->attributes['created_by'] = $user->id;
......@@ -256,4 +251,17 @@ class Episode extends Entity
return $this;
}
public function getIsPublished()
{
if ($this->is_published) {
return $this->is_published;
}
helper('date');
$this->is_published = $this->published_at->isBefore(Time::now());
return $this->is_published;
}
}
......@@ -256,3 +256,51 @@ if (!function_exists('data_table')) {
}
// ------------------------------------------------------------------------
if (!function_exists('publication_pill')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @param \CodeIgniter\I18n\Time $publicationDate publication datetime of the episode
* @param boolean $isPublished whether or not the episode has been published
* @param string $customClass css class to add to the component
*
* @return string
*/
function publication_pill(
$publicationDate,
$isPublished,
$customClass = ''
): string {
$class = $isPublished
? 'text-green-500 border-green-500'
: 'text-orange-600 border-orange-600';
$label = lang(
$isPublished ? 'Episode.published' : 'Episode.scheduled',
[
'<time
pubdate
datetime="' .
$publicationDate->format(DateTime::ATOM) .
'"
title="' .
$publicationDate .
'">' .
lang('Common.mediumDate', [$publicationDate]) .
'</time>',
]
);
return '<span class="px-1 border ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
}
}
// ------------------------------------------------------------------------
......@@ -143,3 +143,27 @@ function slugify($text)
return $text;
}
//--------------------------------------------------------------------
if (!function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string
*
* @param int $seconds seconds to format
* @param string $separator
*
* @return string
*/
function format_duration($seconds, $separator = ':')
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60
);
}
}
......@@ -14,7 +14,6 @@ return [
'home' => 'Home',
'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} out of {pageCount}',
......
......@@ -22,6 +22,8 @@ return [
'delete' => 'Delete',
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
'published' => 'Published on {0}',
'scheduled' => 'Scheduled for {0}',
'form' => [
'enclosure' => 'Audio file',
'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.',
......@@ -54,11 +56,9 @@ return [
'This text is added at the end of each episode description, it is a good place to input your social links for example.',
'publication_section_title' => 'Publication info',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Publication date',
'date' => 'Date',
'time' => 'Time',
],
'publication_date' => 'Publication date',
'publication_date_hint' =>
'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?',
......
......@@ -14,7 +14,6 @@ return [
'home' => 'Accueil',
'explicit' => 'Explicite',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Propulsé par {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} sur {pageCount}',
......
......@@ -22,6 +22,8 @@ return [
'delete' => 'Supprimer',
'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode',
'published' => 'Publié le {0}',
'scheduled' => 'Planifié pour le {0}',
'form' => [
'enclosure' => 'Fichier audio',
'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
......@@ -54,11 +56,9 @@ return [
'Ce texte est ajouté à la fin de chaque description d’épisode, c’est un bon endroit pour placer vos liens sociaux par exemple.',
'publication_section_title' => 'Information de publication',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Date de publication',
'date' => 'Date',
'time' => 'Heure',
],
'publication_date' => 'Date de publication',
'publication_date_hint' =>
'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
'parental_advisory' => [
'label' => 'Avertissement parental',
'hint' => 'L’épisode contient-il un contenu explicite ?',
......
......@@ -12,6 +12,7 @@ return [
'messages' => [
'wrongPasswordError' =>
'Le mot de passe que vous avez saisi est invalide.',
'passwordChangeSuccess' => 'Le mot de passe a été modifié avec succès !',
'passwordChangeSuccess' =>
'Le mot de passe a été modifié avec succès !',
],
];
......@@ -57,32 +57,21 @@ class EpisodeModel extends Model
];
protected $validationMessages = [];
protected $afterInsert = ['writeEnclosureMetadata'];
protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];
// clear cache beforeUpdate because if slug changes, so will the episode link
protected $beforeUpdate = ['clearCache'];
protected $afterUpdate = ['writeEnclosureMetadata'];
protected $beforeDelete = ['clearCache'];
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
public function getEpisodeBySlug($podcastId, $episodeSlug)
{
if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) {
$found = $this->where([
'podcast_id' => $podcastId,
'slug' => $episodeSlug,
])->first();
])
->where('`published_at` <= NOW()', null, false)
->first();
cache()->save(
"podcast{$podcastId}_episode@{$episodeSlug}",
......@@ -120,6 +109,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' <' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
$nextData = $this->orderBy('(' . $sortNumberField . ') ASC')
......@@ -127,6 +117,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' >' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
return [
......@@ -160,7 +151,9 @@ class EpisodeModel extends Model
);
if (!($found = cache($cacheName))) {
$where = ['podcast_id' => $podcastId];
$where = [
'podcast_id' => $podcastId,
];
if ($year) {
$where['YEAR(published_at)'] = $year;
$where['season_number'] = null;
......@@ -172,15 +165,27 @@ class EpisodeModel extends Model
if ($podcastType == 'serial') {
// podcast is serial
$found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('season_number DESC, number ASC')
->findAll();
} else {
$found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('published_at', 'DESC')
->findAll();
}
cache()->save($cacheName, $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -197,12 +202,23 @@ class EpisodeModel extends Model
'season_number' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('year')
->orderBy('year', 'DESC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_years", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_years",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -219,12 +235,23 @@ class EpisodeModel extends Model
'season_number is not' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('season_number')
->orderBy('season_number', 'ASC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_seasons", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_seasons",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
......@@ -264,6 +291,43 @@ class EpisodeModel extends Model
return $defaultQuery;
}
/**
* Returns the timestamp difference in seconds between the next episode to publish and the current timestamp
* Returns false if there's no episode to publish
*
* @param int $podcastId
*
* @return int|false seconds
*/
public function getSecondsToNextUnpublishedEpisode(int $podcastId)
{
$result = $this->select(
'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff'
)
->where([
'podcast_id' => $podcastId,
])
->where('`published_at` > NOW()', null, false)
->orderBy('published_at', 'asc')
->get()
->getResultArray();
return (int) $result ? $result[0]['timestamp_diff'] : false;
}
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
protected function clearCache(array $data)
{
$episodeModel = new EpisodeModel();
......
......@@ -59,7 +59,7 @@ class PodcastModel extends Model
];
protected $validationMessages = [];
// clear cache before update if by any chance, the podcast name changes, and so will the podcast link
// clear cache before update if by any chance, the podcast name changes, so will the podcast link
protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
......
import ClientTimezone from "./modules/ClientTimezone";
import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown";
import MarkdownEditor from "./modules/MarkdownEditor";