From 9a5d5a15b4945eb319da9e999c4ca60a0a4f6d2d Mon Sep 17 00:00:00 2001 From: Benjamin Bellamy Date: Fri, 21 Aug 2020 08:41:09 +0000 Subject: [PATCH] feat: import podcast from an rss feed url * add podcast import form * add League\\HTMLToMarkdown * add guid field in podcast table * change podcast category from string to id closes #21 --- DEPENDENCIES.md | 3 +- app/Config/Routes.php | 11 + app/Controllers/Admin/Podcast.php | 226 +++++++++++++++++- .../2020-05-30-101500_add_podcasts.php | 17 +- .../2020-06-05-170000_add_episodes.php | 11 +- .../2020-06-05-190000_add_platforms.php | 2 + app/Database/Seeds/AuthSeeder.php | 5 + app/Database/Seeds/PlatformSeeder.php | 13 + app/Entities/Episode.php | 45 ++-- app/Entities/Podcast.php | 11 +- app/Helpers/media_helper.php | 23 +- app/Helpers/misc_helper.php | 108 +++++++++ app/Helpers/rss_helper.php | 4 +- app/Language/en/Breadcrumb.php | 1 + app/Language/en/Podcast.php | 41 +++- app/Models/EpisodeModel.php | 5 +- app/Models/PodcastModel.php | 6 +- app/Views/admin/podcast/edit.php | 2 +- app/Views/admin/podcast/import.php | 163 +++++++++++++ app/Views/admin/podcast/list.php | 5 + composer.json | 3 +- composer.lock | 136 +++++++++-- docs/setup-development.md | 12 + 23 files changed, 782 insertions(+), 71 deletions(-) create mode 100644 app/Views/admin/podcast/import.php diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 612c3284..fed484c9 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -5,13 +5,13 @@ Castopod uses the following components: PHP Dependencies: - [Code Igniter 4](https://codeigniter.com) ([MIT License](https://codeigniter.com/user_guide/license.html)) -- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE)) - [WhichBrowser/Parser-PHP](https://github.com/WhichBrowser/Parser-PHP) ([MIT License](https://github.com/WhichBrowser/Parser-PHP/blob/master/LICENSE)) - [GeoIP2 PHP API](https://github.com/maxmind/GeoIP2-php) ([Apache License 2.0](https://github.com/maxmind/GeoIP2-php/blob/master/LICENSE)) - [getID3](https://github.com/JamesHeinrich/getID3) ([GNU General Public License v3](https://github.com/JamesHeinrich/getID3/blob/2.0/licenses/license.gpl-30.txt)) - [myth-auth](https://github.com/lonnieezell/myth-auth) ([MIT license](https://github.com/lonnieezell/myth-auth/blob/develop/LICENSE.md)) - [commonmark](https://commonmark.thephpleague.com/) ([BSD 3-Clause "New" or "Revised" License](https://github.com/thephpleague/commonmark/blob/latest/LICENSE)) - [phpdotenv](https://github.com/vlucas/phpdotenv) ([ BSD-3-Clause License ](https://github.com/vlucas/phpdotenv/blob/master/LICENSE)) +- [HTML To Markdown for PHP](https://github.com/thephpleague/html-to-markdown) ([MIT License](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE)) Javascript dependencies: @@ -24,3 +24,4 @@ Javascript dependencies: Other: - [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License)) +- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE)) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index adec234c..be223070 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -71,6 +71,10 @@ $routes->group( 'as' => 'admin', ]); + $routes->get('my-podcasts', 'Podcast::myPodcasts', [ + 'as' => 'my-podcasts', + ]); + // Podcasts $routes->group('podcasts', function ($routes) { $routes->get('/', 'Podcast::list', [ @@ -83,6 +87,13 @@ $routes->group( $routes->post('new', 'Podcast::attemptCreate', [ 'filter' => 'permission:podcasts-create', ]); + $routes->get('import', 'Podcast::import', [ + 'as' => 'podcast-import', + 'filter' => 'permission:podcasts-import', + ]); + $routes->post('import', 'Podcast::attemptImport', [ + 'filter' => 'permission:podcasts-import', + ]); // Podcast // Use ids in admin area to help permission and group lookups diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php index 6de23cf9..adfdeaac 100644 --- a/app/Controllers/Admin/Podcast.php +++ b/app/Controllers/Admin/Podcast.php @@ -11,7 +11,9 @@ namespace App\Controllers\Admin; use App\Models\CategoryModel; use App\Models\LanguageModel; use App\Models\PodcastModel; +use App\Models\EpisodeModel; use Config\Services; +use League\HTMLToMarkdown\HtmlConverter; class Podcast extends BaseController { @@ -69,7 +71,7 @@ class Podcast extends BaseController $categoryOptions = array_reduce( $categories, function ($result, $category) { - $result[$category->code] = lang( + $result[$category->id] = lang( 'Podcast.category_options.' . $category->code ); return $result; @@ -110,7 +112,7 @@ class Podcast extends BaseController ), 'image' => $this->request->getFile('image'), 'language' => $this->request->getPost('language'), - 'category' => $this->request->getPost('category'), + 'category_id' => $this->request->getPost('category'), 'explicit' => $this->request->getPost('explicit') == 'yes', 'author' => $this->request->getPost('author'), 'owner_name' => $this->request->getPost('owner_name'), @@ -151,6 +153,222 @@ class Podcast extends BaseController return redirect()->route('podcast-view', [$newPodcastId]); } + public function import() + { + helper(['form', 'misc']); + + $categories = (new CategoryModel())->findAll(); + $languages = (new LanguageModel())->findAll(); + $languageOptions = array_reduce( + $languages, + function ($result, $language) { + $result[$language->code] = $language->native_name; + return $result; + }, + [] + ); + $categoryOptions = array_reduce( + $categories, + function ($result, $category) { + $result[$category->id] = lang( + 'Podcast.category_options.' . $category->code + ); + return $result; + }, + [] + ); + + $data = [ + 'languageOptions' => $languageOptions, + 'categoryOptions' => $categoryOptions, + 'browserLang' => get_browser_language( + $this->request->getServer('HTTP_ACCEPT_LANGUAGE') + ), + ]; + + return view('admin/podcast/import', $data); + } + + public function attemptImport() + { + helper(['media', 'misc']); + + $rules = [ + 'name' => 'required', + 'imported_feed_url' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + try { + $feed = simplexml_load_file( + $this->request->getPost('imported_feed_url') + ); + } catch (\ErrorException $ex) { + return redirect() + ->back() + ->withInput() + ->with('errors', [ + $ex->getMessage() . + ': ' . + $this->request->getPost('imported_feed_url') . + ' ⎋', + ]); + } + + $nsItunes = $feed->channel[0]->children( + 'http://www.itunes.com/dtds/podcast-1.0.dtd' + ); + + $podcast = new \App\Entities\Podcast([ + 'name' => $this->request->getPost('name'), + 'imported_feed_url' => $this->request->getPost('imported_feed_url'), + + 'title' => $feed->channel[0]->title, + 'description' => $feed->channel[0]->description, + 'image' => download_file($nsItunes->image->attributes()), + 'language' => $this->request->getPost('language'), + 'category_id' => $this->request->getPost('category'), + 'explicit' => empty($nsItunes->explicit) + ? false + : $nsItunes->explicit == 'yes', + 'author' => $nsItunes->author, + 'owner_name' => $nsItunes->owner->name, + 'owner_email' => $nsItunes->owner->email, + 'type' => empty($nsItunes->type) ? 'episodic' : $nsItunes->type, + 'copyright' => $feed->channel[0]->copyright, + 'block' => empty($nsItunes->block) + ? false + : $nsItunes->block == 'yes', + 'complete' => empty($nsItunes->complete) + ? false + : $nsItunes->complete == 'yes', + 'episode_description_footer' => '', + 'custom_html_head' => '', + 'created_by' => user(), + 'updated_by' => user(), + ]); + + $podcastModel = new PodcastModel(); + $db = \Config\Database::connect(); + + $db->transStart(); + + if (!($newPodcastId = $podcastModel->insert($podcast, true))) { + $db->transComplete(); + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastModel->errors()); + } + + $authorize = Services::authorization(); + $podcastAdminGroup = $authorize->group('podcast_admin'); + + $podcastModel->addPodcastContributor( + user()->id, + $newPodcastId, + $podcastAdminGroup->id + ); + + $converter = new HtmlConverter(); + + $numberItems = $feed->channel[0]->item->count(); + $lastItem = + !empty($this->request->getPost('max_episodes')) && + $this->request->getPost('max_episodes') < $numberItems + ? $this->request->getPost('max_episodes') + : $numberItems; + + $slugs = []; + + // For each Episode: + for ($itemNumber = 1; $itemNumber <= $lastItem; $itemNumber++) { + $item = $feed->channel[0]->item[$numberItems - $itemNumber]; + + $nsItunes = $item->children( + 'http://www.itunes.com/dtds/podcast-1.0.dtd' + ); + + $slug = slugify( + $this->request->getPost('slug_field') == 'title' + ? $item->title + : basename($item->link) + ); + if (in_array($slug, $slugs)) { + $slugNumber = 2; + while (in_array($slug . '-' . $slugNumber, $slugs)) { + $slugNumber++; + } + $slug = $slug . '-' . $slugNumber; + } + $slugs[] = $slug; + + $newEpisode = new \App\Entities\Episode([ + 'podcast_id' => $newPodcastId, + 'guid' => empty($item->guid) ? null : $item->guid, + 'title' => $item->title, + 'slug' => $slug, + 'enclosure' => download_file($item->enclosure->attributes()), + 'description' => $converter->convert( + $this->request->getPost('description_field') == 'summary' + ? $nsItunes->summary + : ($this->request->getPost('description_field') == + 'subtitle_summary' + ? '

' . + $nsItunes->subtitle . + "

\n" . + $nsItunes->summary + : $item->description) + ), + 'image' => empty($nsItunes->image->attributes()) + ? null + : download_file($nsItunes->image->attributes()), + 'explicit' => $nsItunes->explicit == 'yes', + 'number' => $this->request->getPost('force_renumber') + ? $itemNumber + : $nsItunes->episode, + 'season_number' => empty( + $this->request->getPost('season_number') + ) + ? $nsItunes->season + : $this->request->getPost('season_number'), + 'type' => empty($nsItunes->episodeType) + ? 'full' + : $nsItunes->episodeType, + 'block' => empty($nsItunes->block) + ? false + : $nsItunes->block == 'yes', + 'created_by' => user(), + 'updated_by' => user(), + ]); + $newEpisode->setPublishedAt( + date('Y-m-d', strtotime($item->pubDate)), + date('H:i:s', strtotime($item->pubDate)) + ); + + $episodeModel = new EpisodeModel(); + + if (!$episodeModel->save($newEpisode)) { + // FIX: What shall we do? + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); + } + } + + $db->transComplete(); + + return redirect()->route('podcast-list'); + } + public function edit() { helper('form'); @@ -168,7 +386,7 @@ class Podcast extends BaseController $categoryOptions = array_reduce( $categories, function ($result, $category) { - $result[$category->code] = lang( + $result[$category->id] = lang( 'Podcast.category_options.' . $category->code ); return $result; @@ -212,7 +430,7 @@ class Podcast extends BaseController $this->podcast->image = $image; } $this->podcast->language = $this->request->getPost('language'); - $this->podcast->category = $this->request->getPost('category'); + $this->podcast->category_id = $this->request->getPost('category'); $this->podcast->explicit = $this->request->getPost('explicit') == 'yes'; $this->podcast->author = $this->request->getPost('author'); $this->podcast->owner_name = $this->request->getPost('owner_name'); 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 5aa7aa20..4c024439 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -44,10 +44,11 @@ class AddPodcasts extends Migration 'type' => 'VARCHAR', 'constraint' => 2, ], - 'category' => [ - 'type' => 'VARCHAR', - 'constraint' => 1024, - 'null' => true, + 'category_id' => [ + 'type' => 'INT', + 'constraint' => 10, + 'unsigned' => true, + 'default' => 0, ], 'explicit' => [ 'type' => 'TINYINT', @@ -105,6 +106,13 @@ class AddPodcasts extends Migration 'constraint' => 11, 'unsigned' => true, ], + 'imported_feed_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 1024, + 'comment' => + 'The RSS feed URL if this podcast was imported, NULL otherwise.', + 'null' => true, + ], 'created_at' => [ 'type' => 'TIMESTAMP', ], @@ -117,6 +125,7 @@ class AddPodcasts extends Migration ], ]); $this->forge->addKey('id', true); + $this->forge->addForeignKey('category_id', 'categories', 'id'); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); $this->forge->createTable('podcasts'); diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index c0fa74af..49625900 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -29,6 +29,10 @@ class AddEpisodes extends Migration 'constraint' => 20, 'unsigned' => true, ], + 'guid' => [ + 'type' => 'VARCHAR', + 'constraint' => 191, + ], 'title' => [ 'type' => 'VARCHAR', 'constraint' => 1024, @@ -59,16 +63,17 @@ class AddEpisodes extends Migration 'type' => 'INT', 'constraint' => 10, 'unsigned' => true, + 'null' => true, ], 'season_number' => [ 'type' => 'INT', 'constraint' => 10, 'unsigned' => true, - 'default' => 1, + 'null' => true, ], 'type' => [ 'type' => 'ENUM', - 'constraint' => ['full', 'trailer', 'bonus'], + 'constraint' => ['trailer', 'full', 'bonus'], 'default' => 'full', ], 'block' => [ @@ -103,8 +108,6 @@ class AddEpisodes extends Migration ]); $this->forge->addKey('id', true); $this->forge->addUniqueKey(['podcast_id', 'slug']); - - $this->forge->addUniqueKey(['podcast_id', 'season_number', 'number']); $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); diff --git a/app/Database/Migrations/2020-06-05-190000_add_platforms.php b/app/Database/Migrations/2020-06-05-190000_add_platforms.php index c65929d0..c012efd6 100644 --- a/app/Database/Migrations/2020-06-05-190000_add_platforms.php +++ b/app/Database/Migrations/2020-06-05-190000_add_platforms.php @@ -66,6 +66,8 @@ class AddPlatforms extends Migration 'type' => 'TINYINT', 'constraint' => 1, 'default' => 0, + 'comment' => + 'Android deeplinking for this platform: 0=No, 1=Manual, 2=Automatic.', ], 'logo_file_name' => [ 'type' => 'VARCHAR', diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index 6ddfd950..06286125 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -90,6 +90,11 @@ class AuthSeeder extends Seeder 'description' => 'Add a new podcast', 'has_permission' => ['superadmin'], ], + [ + 'name' => 'import', + 'description' => 'Import a new podcast from an external feed', + 'has_permission' => ['superadmin'], + ], [ 'name' => 'list', 'description' => 'List all podcasts and their episodes', diff --git a/app/Database/Seeds/PlatformSeeder.php b/app/Database/Seeds/PlatformSeeder.php index fd02b743..c46a0e62 100644 --- a/app/Database/Seeds/PlatformSeeder.php +++ b/app/Database/Seeds/PlatformSeeder.php @@ -209,6 +209,19 @@ class PlatformSeeder extends Seeder 'android_deeplink' => 2, 'logo_file_name' => 'Podbean.png', ], + [ + 'name' => 'Podcast Addict', + 'home_url' => 'https://podcastaddict.com/', + 'submit_url' => 'https://podcastaddict.com/submit', + 'iosapp_url' => '', + 'androidapp_url' => + 'https://play.google.com/store/apps/details?id=com.bambuna.podcastaddict', + 'comment' => '', + 'display_by_default' => 0, + 'ios_deeplink' => 0, + 'android_deeplink' => 2, + 'logo_file_name' => 'podcastaddict.svg', + ], [ 'name' => 'Podcastland', 'home_url' => 'https://podcastland.com/', diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 45f6bae6..1ddc42ee 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -19,11 +19,6 @@ class Episode extends Entity */ protected $podcast; - /** - * @var string - */ - protected $GUID; - /** * @var string */ @@ -77,13 +72,14 @@ class Episode extends Entity ]; protected $casts = [ + 'guid' => 'string', 'slug' => 'string', 'title' => 'string', 'enclosure_uri' => 'string', 'description' => 'string', 'image_uri' => '?string', 'explicit' => 'boolean', - 'number' => 'integer', + 'number' => '?integer', 'season_number' => '?integer', 'type' => 'string', 'block' => 'boolean', @@ -91,9 +87,19 @@ class Episode extends Entity 'updated_by' => 'integer', ]; - public function setImage(?\CodeIgniter\HTTP\Files\UploadedFile $image) + /** + * Saves an episode image + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image + * + */ + public function setImage($image) { - if (!empty($image) && $image->isValid()) { + if ( + !empty($image) && + (!($image instanceof \CodeIgniter\HTTP\Files\UploadedFile) || + $image->isValid()) + ) { // check whether the user has inputted an image and store it $this->attributes['image_uri'] = save_podcast_media( $image, @@ -136,10 +142,19 @@ class Episode extends Entity return $this->getPodcast()->image_url; } - public function setEnclosure( - \CodeIgniter\HTTP\Files\UploadedFile $enclosure = null - ) { - if (!empty($enclosure) && $enclosure->isValid()) { + /** + * Saves an enclosure + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $enclosure + * + */ + public function setEnclosure($enclosure = null) + { + if ( + !empty($enclosure) && + (!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) || + $enclosure->isValid()) + ) { helper('media'); $this->attributes['enclosure_uri'] = save_podcast_media( @@ -194,9 +209,11 @@ class Episode extends Entity ); } - public function getGUID() + public function setGuid($guid = null) { - return $this->getLink(); + return $this->attributes['guid'] = empty($guid) + ? $this->getLink() + : $guid; } public function getPodcast() diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index c139ef02..916d7232 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -57,7 +57,7 @@ class Podcast extends Entity 'description' => 'string', 'image_uri' => 'string', 'language' => 'string', - 'category' => 'string', + 'category_id' => 'integer', 'explicit' => 'boolean', 'author' => '?string', 'owner_name' => '?string', @@ -70,9 +70,16 @@ class Podcast extends Entity 'custom_html_head' => '?string', 'created_by' => 'integer', 'updated_by' => 'integer', + 'imported_feed_url' => '?string', ]; - public function setImage(\CodeIgniter\HTTP\Files\UploadedFile $image = null) + /** + * Saves a cover image to the corresponding podcast folder in `public/media/podcast_name/` + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image + * + */ + public function setImage($image = null) { if ($image) { helper('media'); diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php index 1f21679a..594bb009 100644 --- a/app/Helpers/media_helper.php +++ b/app/Helpers/media_helper.php @@ -9,7 +9,7 @@ /** * Saves a file to the corresponding podcast folder in `public/media` * - * @param \CodeIgniter\HTTP\Files\UploadedFile $file + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $file * @param string $podcast_name * @param string $file_name * @@ -17,7 +17,12 @@ */ function save_podcast_media($file, $podcast_name, $media_name) { - $file_name = $media_name . '.' . $file->guessExtension(); + $file_name = $media_name . '.' . $file->getExtension(); + + if (!file_exists(config('App')->mediaRoot . '/' . $podcast_name)) { + mkdir(config('App')->mediaRoot . '/' . $podcast_name, 0777, true); + touch(config('App')->mediaRoot . '/' . $podcast_name . '/index.html'); + } // move to media folder and overwrite file if already existing $file->move( @@ -29,6 +34,20 @@ function save_podcast_media($file, $podcast_name, $media_name) return $podcast_name . '/' . $file_name; } +function download_file($fileUrl) +{ + $tmpFilename = + time() . + '_' . + bin2hex(random_bytes(10)) . + '.' . + pathinfo($fileUrl, PATHINFO_EXTENSION); + $tmpFilePath = WRITEPATH . 'uploads/' . $tmpFilename; + file_put_contents($tmpFilePath, file_get_contents($fileUrl)); + + return new \CodeIgniter\Files\File($tmpFilePath); +} + /** * Prefixes the root media path to a given uri * diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index b1511ef3..b1d1b168 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -35,3 +35,111 @@ function startsWith($string, $query) { return substr($string, 0, strlen($query)) === $query; } + +function slugify($text) +{ + if (empty($text)) { + return 'n-a'; + } + + // replace non letter or digits by - + $text = preg_replace('~[^\pL\d]+~u', '-', $text); + + $unwanted_array = [ + 'Š' => 'S', + 'š' => 's', + 'Đ' => 'Dj', + 'đ' => 'dj', + 'Ž' => 'Z', + 'ž' => 'z', + 'Č' => 'C', + 'č' => 'c', + 'Ć' => 'C', + 'ć' => 'c', + 'À' => 'A', + 'Á' => 'A', + 'Â' => 'A', + 'Ã' => 'A', + 'Ä' => 'A', + 'Å' => 'A', + 'Æ' => 'AE', + 'Ç' => 'C', + 'È' => 'E', + 'É' => 'E', + 'Ê' => 'E', + 'Ë' => 'E', + 'Ì' => 'I', + 'Í' => 'I', + 'Î' => 'I', + 'Ï' => 'I', + 'Ñ' => 'N', + 'Ò' => 'O', + 'Ó' => 'O', + 'Ô' => 'O', + 'Õ' => 'O', + 'Ö' => 'O', + 'Ø' => 'O', + 'Œ' => 'OE', + 'Ù' => 'U', + 'Ú' => 'U', + 'Û' => 'U', + 'Ü' => 'U', + 'Ý' => 'Y', + 'Þ' => 'B', + 'ß' => 'Ss', + 'à' => 'a', + 'á' => 'a', + 'â' => 'a', + 'ã' => 'a', + 'ä' => 'a', + 'å' => 'a', + 'æ' => 'ae', + 'ç' => 'c', + 'è' => 'e', + 'é' => 'e', + 'ê' => 'e', + 'ë' => 'e', + 'ì' => 'i', + 'í' => 'i', + 'î' => 'i', + 'ï' => 'i', + 'ð' => 'o', + 'ñ' => 'n', + 'ò' => 'o', + 'ó' => 'o', + 'ô' => 'o', + 'õ' => 'o', + 'ö' => 'o', + 'ø' => 'o', + 'œ' => 'OE', + 'ù' => 'u', + 'ú' => 'u', + 'û' => 'u', + 'ý' => 'y', + 'ý' => 'y', + 'þ' => 'b', + 'ÿ' => 'y', + 'Ŕ' => 'R', + 'ŕ' => 'r', + '/' => '-', + ' ' => '-', + ]; + $text = strtr($text, $unwanted_array); + + // transliterate + $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text); + + // remove unwanted characters + $text = preg_replace('~[^-\w]+~', '', $text); + + // trim + $text = trim($text, '-'); + + // remove duplicate - + $text = preg_replace('~-+~', '-', $text); + + // lowercase + $text = strtolower($text); + + return $text; +} diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 58f50726..f08297f6 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -23,7 +23,7 @@ function get_rss_feed($podcast) $episodes = $podcast->episodes; $podcast_category = $category_model - ->where('code', $podcast->category) + ->where('id', $podcast->category_id) ->first(); $itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; @@ -50,7 +50,7 @@ function get_rss_feed($podcast) ); $channel->addChild( 'generator', - 'Castopod 0.0.0-development - https://castopod.org' + 'Castopod 0.0.0-development - https://castopod.org/' ); $channel->addChild('docs', 'https://cyber.harvard.edu/rss/rss.html'); diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php index bd5d4b61..d434287b 100644 --- a/app/Language/en/Breadcrumb.php +++ b/app/Language/en/Breadcrumb.php @@ -19,4 +19,5 @@ return [ 'users' => 'users', 'my-account' => 'my account', 'change-password' => 'change password', + 'import' => 'feed import', ]; diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php index b64c6725..8a6bda75 100644 --- a/app/Language/en/Podcast.php +++ b/app/Language/en/Podcast.php @@ -10,6 +10,7 @@ return [ 'all_podcasts' => 'All podcasts', 'no_podcast' => 'No podcast found!', 'create' => 'Create a Podcast', + 'import' => 'Create and Import a Podcast from an existing Feed', 'new_episode' => 'New Episode', 'feed' => 'RSS feed', 'view' => 'View podcast', @@ -21,10 +22,10 @@ return [ 'form' => [ 'title' => 'Title', 'title_help' => - 'This podcast title. It will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).', + 'The podcast title will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).', 'name' => 'Name', 'name_help' => - 'This podcast name. It will be used in the URL address. It will be used as a Fedivers actor name, (for instance, it will be the podcast Mastodon’s name).', + 'The podcast will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodon’s name).', 'description' => 'Description', 'description_help' => 'It will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).', @@ -33,7 +34,7 @@ return [ 'This text will be automatically added at the end of each episode description, so that you don’t have to copy/paste it a gazillion times.', 'image' => 'Image', 'image_help' => - 'This podcast image. It should be square, JPEG or PNG, minimum 1400 x 1400 pixels and maximum 3000 x 3000 pixels.', + 'This podcast image. It must be square, JPEG or PNG, minimum 1400 x 1400 pixels and maximum 3000 x 3000 pixels.', 'language' => 'Language', 'language_help' => 'The language spoken on the podcast.', 'category' => 'Category', @@ -44,10 +45,10 @@ return [ 'The podcast parental advisory information. Does it contain explicit content?', 'owner_name' => 'Owner name', 'owner_name_help' => - 'The podcast owner contact name. For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', + 'For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', 'owner_email' => 'Owner email', 'owner_email_help' => - 'The podcast owner contact e-mail. For administrative use only. It will mostly be used by some platforms to verify this podcast ownerhip. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', + 'It will be used by most platforms to verify this podcast ownership. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', 'author' => 'Author', 'author_help' => 'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.', @@ -75,6 +76,36 @@ return [ 'submit_create' => 'Create podcast', 'submit_edit' => 'Save podcast', ], + 'form_import' => [ + 'name' => 'Name', + 'name_help' => + 'This podcast name. It will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodon’s name).', + 'imported_feed_url' => 'Feed URL', + 'imported_feed_url_help' => + 'Make sure you are legally allowed to copy that podcast.', + 'force_renumber' => 'Force episodes renumbering', + 'force_renumber_help' => + 'Use this if your old podcast does not have number but you want some on your new one.', + 'season_number' => 'Season number', + 'season_number_help' => + 'Use this if your old podcast does not have season number but you want one on your new one. Leave blank otherwise.', + 'slug_field' => [ + 'label' => 'Which field should be used to calculate episode slug', + 'link' => '<link>', + 'title' => '<title>', + ], + 'description_field' => [ + 'label' => 'Source field used for episode description / show notes', + 'description' => '<description>', + 'summary' => '<itunes:summary>', + 'subtitle_summary' => + '<itunes:subtitle> <itunes:summary>', + ], + 'max_episodes' => 'Maximum number of episodes to import', + 'max_episodes_helper' => 'Leave blank to import all episodes', + 'submit_import' => 'Import podcast', + 'submit_importing' => 'Importing podcast, this could take a while…', + ], 'category_options' => [ 'uncategorized' => 'uncategorized', 'arts' => 'Arts', diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 9f911d77..b346fcff 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -17,6 +17,7 @@ class EpisodeModel extends Model protected $allowedFields = [ 'podcast_id', + 'guid', 'title', 'slug', 'enclosure_uri', @@ -44,8 +45,8 @@ class EpisodeModel extends Model 'enclosure_uri' => 'required', 'description' => 'required', 'image_uri' => 'required', - 'number' => 'required|is_natural_no_zero', - 'season_number' => 'required|is_natural_no_zero', + 'number' => 'is_natural_no_zero|permit_empty', + 'season_number' => 'is_natural_no_zero|permit_empty', 'type' => 'required', 'published_at' => 'valid_date|permit_empty', 'created_by' => 'required', diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 9a3bc6ac..023f7a23 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -23,7 +23,7 @@ class PodcastModel extends Model 'episode_description_footer', 'image_uri', 'language', - 'category', + 'category_id', 'explicit', 'owner_name', 'owner_email', @@ -35,6 +35,7 @@ class PodcastModel extends Model 'custom_html_head', 'created_by', 'updated_by', + 'imported_feed_url', ]; protected $returnType = \App\Entities\Podcast::class; @@ -49,8 +50,7 @@ class PodcastModel extends Model 'description' => 'required', 'image_uri' => 'required', 'language' => 'required', - 'category' => 'required', - 'owner_name' => 'required', + 'category_id' => 'required', 'owner_email' => 'required|valid_email', 'type' => 'required', 'created_by' => 'required', diff --git a/app/Views/admin/podcast/edit.php b/app/Views/admin/podcast/edit.php index 7581a648..99c597e8 100644 --- a/app/Views/admin/podcast/edit.php +++ b/app/Views/admin/podcast/edit.php @@ -92,7 +92,7 @@ category), + old('category', $podcast->category_id), [ 'id' => 'category', 'class' => 'form-select mb-4', diff --git a/app/Views/admin/podcast/import.php b/app/Views/admin/podcast/import.php new file mode 100644 index 00000000..5bbaf110 --- /dev/null +++ b/app/Views/admin/podcast/import.php @@ -0,0 +1,163 @@ +extend('admin/_layout') ?> + +section('title') ?> + +endSection() ?> + + +section('content') ?> + + 'post', + 'class' => 'flex flex-col max-w-md', +]) ?> + + + +
+ + +
+ +
+ + +
+ + + 'language', + 'class' => 'form-select mb-4', + 'required' => 'required', +]) ?> + + + 'category', + 'class' => 'form-select mb-4', + 'required' => 'required', +]) ?> + + 'flex flex-col mb-4', +]) ?> + + + + + 'flex flex-col mb-4', +]) ?> + + + + + + + + +
+ + +
+ +
+ + +
+ + + + + +endSection() ?> diff --git a/app/Views/admin/podcast/list.php b/app/Views/admin/podcast/list.php index 83768a7c..cc1097cf 100644 --- a/app/Views/admin/podcast/list.php +++ b/app/Views/admin/podcast/list.php @@ -7,6 +7,11 @@ ) ?>"> + + + endSection() ?> diff --git a/composer.json b/composer.json index 44be1c8c..dc3cf041 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "myth/auth": "dev-develop", "codeigniter4/codeigniter4": "dev-develop", "league/commonmark": "^1.5", - "vlucas/phpdotenv": "^5.1" + "vlucas/phpdotenv": "^5.1", + "league/html-to-markdown": "^4.10" }, "require-dev": { "mikey179/vfsstream": "1.6.*", diff --git a/composer.lock b/composer.lock index bdeba108..e487409e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a6be291e1c7f73b73182cd7b49234688", + "content-hash": "38eeae7f5d0143863430cda9df10d487", "packages": [ { "name": "codeigniter4/codeigniter4", @@ -12,12 +12,12 @@ "source": { "type": "git", "url": "https://github.com/codeigniter4/CodeIgniter4.git", - "reference": "cbfc8d27645fc9fe19d540c796b627852b4a1142" + "reference": "9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/cbfc8d27645fc9fe19d540c796b627852b4a1142", - "reference": "cbfc8d27645fc9fe19d540c796b627852b4a1142", + "url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee", + "reference": "9a7e826138bf8940ef8c7a25d59d67b1aebfe0ee", "shasum": "" }, "require": { @@ -34,6 +34,7 @@ "codeigniter4/codeigniter4-standard": "^1.0", "fzaninotto/faker": "^1.9@dev", "mikey179/vfsstream": "1.6.*", + "phpstan/phpstan": "^0.12.37", "phpunit/phpunit": "^8.5", "predis/predis": "^1.1", "squizlabs/php_codesniffer": "^3.3" @@ -65,7 +66,7 @@ "slack": "https://codeigniterchat.slack.com", "issues": "https://github.com/codeigniter4/CodeIgniter4/issues" }, - "time": "2020-08-04T03:43:32+00:00" + "time": "2020-08-17T14:11:23+00:00" }, { "name": "composer/ca-bundle", @@ -602,30 +603,112 @@ "time": "2020-07-19T22:47:30+00:00" }, { - "name": "maxmind-db/reader", - "version": "v1.6.0", + "name": "league/html-to-markdown", + "version": "4.10.0", "source": { "type": "git", - "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", - "reference": "febd4920bf17c1da84cef58e56a8227dfb37fbe4" + "url": "https://github.com/thephpleague/html-to-markdown.git", + "reference": "0868ae7a552e809e5cd8f93ba022071640408e88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/febd4920bf17c1da84cef58e56a8227dfb37fbe4", - "reference": "febd4920bf17c1da84cef58e56a8227dfb37fbe4", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/0868ae7a552e809e5cd8f93ba022071640408e88", + "reference": "0868ae7a552e809e5cd8f93ba022071640408e88", "shasum": "" }, "require": { - "php": ">=5.6" + "ext-dom": "*", + "ext-xml": "*", + "php": ">=5.3.3" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "~1.1.0", + "phpunit/phpunit": "^4.8|^5.7", + "scrutinizer/ocular": "~1.1" + }, + "bin": [ + "bin/html-to-markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.10-dev" + } + }, + "autoload": { + "psr-4": { + "League\\HTMLToMarkdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + }, + { + "name": "Nick Cernis", + "email": "nick@cern.is", + "homepage": "http://modernnerd.net", + "role": "Original Author" + } + ], + "description": "An HTML-to-markdown conversion helper for PHP", + "homepage": "https://github.com/thephpleague/html-to-markdown", + "keywords": [ + "html", + "markdown" + ], + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://www.patreon.com/colinodell", + "type": "patreon" + } + ], + "time": "2020-07-01T00:34:03+00:00" + }, + { + "name": "maxmind-db/reader", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", + "reference": "942553da239f12051275f9c666538b5dd09e2908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/942553da239f12051275f9c666538b5dd09e2908", + "reference": "942553da239f12051275f9c666538b5dd09e2908", + "shasum": "" + }, + "require": { + "php": ">=7.2" }, "conflict": { - "ext-maxminddb": "<1.6.0,>=2.0.0" + "ext-maxminddb": "<1.7.0,>=2.0.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "2.*", "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpcov": "^3.0", - "phpunit/phpunit": "5.*", + "phpunit/phpcov": ">=6.0.0", + "phpunit/phpunit": ">=8.0.0,<10.0.0", "squizlabs/php_codesniffer": "3.*" }, "suggest": { @@ -659,7 +742,7 @@ "geolocation", "maxmind" ], - "time": "2019-12-19T22:59:03+00:00" + "time": "2020-08-07T22:10:05+00:00" }, { "name": "maxmind/web-service-common", @@ -1618,16 +1701,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.2.0", + "version": "5.2.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "3170448f5769fe19f456173d833734e0ff1b84df" + "reference": "d870572532cd70bc3fab58f2e23ad423c8404c44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/3170448f5769fe19f456173d833734e0ff1b84df", - "reference": "3170448f5769fe19f456173d833734e0ff1b84df", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d870572532cd70bc3fab58f2e23ad423c8404c44", + "reference": "d870572532cd70bc3fab58f2e23ad423c8404c44", "shasum": "" }, "require": { @@ -1666,7 +1749,7 @@ } ], "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "time": "2020-07-20T20:05:34+00:00" + "time": "2020-08-15T11:14:08+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -2026,6 +2109,7 @@ "keywords": [ "tokenizer" ], + "abandoned": true, "time": "2019-09-17T06:23:10+00:00" }, { @@ -2738,16 +2822,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.5.5", + "version": "3.5.6", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6" + "reference": "e97627871a7eab2f70e59166072a6b767d5834e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/73e2e7f57d958e7228fce50dc0c61f58f017f9f6", - "reference": "73e2e7f57d958e7228fce50dc0c61f58f017f9f6", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0", + "reference": "e97627871a7eab2f70e59166072a6b767d5834e0", "shasum": "" }, "require": { @@ -2785,7 +2869,7 @@ "phpcs", "standards" ], - "time": "2020-04-17T01:09:41+00:00" + "time": "2020-08-10T04:50:15+00:00" }, { "name": "theseer/tokenizer", diff --git a/docs/setup-development.md b/docs/setup-development.md index 47668c9a..66e02a3c 100644 --- a/docs/setup-development.md +++ b/docs/setup-development.md @@ -106,12 +106,24 @@ docker-compose run --rm app php spark migrate -all 2. Populate the database with the required data: +```bash +# Populates all required data +docker-compose run --rm app php spark db:seed AppSeeder +``` + +You may also add only data you chose: + ```bash # Populates all categories docker-compose run --rm app php spark db:seed CategorySeeder +# Populates all Languages docker-compose run --rm app php spark db:seed LanguageSeeder +# Populates all podcasts platforms docker-compose run --rm app php spark db:seed PlatformSeeder +# Populates all Authentication data (roles definition…) docker-compose run --rm app php spark db:seed AuthSeeder +# Populates test data (login: admin / password: AGUehL3P) +docker-compose run --rm app php spark db:seed TestSeeder ``` 3. (optionnal) Populate the database with test data: