From 39ebf6bbc1255b2900ae7c17b0f9df7e4dcf87c1 Mon Sep 17 00:00:00 2001 From: Daniel Siepmann Date: Tue, 26 May 2020 22:11:02 +0200 Subject: [PATCH] Add basic import of opml, display feed and buckets --- .env.test | 5 + .gitignore | 6 + assets/sass/_layout.scss | 2 +- bin/phpunit | 13 + composer.json | 1 + composer.lock | 324 ++++++++++++++++++++++- config/routes.yaml | 4 + config/services.yaml | 9 + phpunit.xml.dist | 33 +++ public/css/style.css | 2 +- src/Command/ImportOpmlfileCommand.php | 39 +++ src/Controller/FeedController.php | 16 ++ src/Entity/Bucket.php | 16 ++ src/Entity/Feed.php | 89 +++++++ src/Migrations/Version20200526192352.php | 37 +++ src/Repository/FeedRepository.php | 50 ++++ src/Service/OpmlImporter.php | 130 +++++++++ src/Service/Slugger.php | 38 +++ symfony.lock | 27 ++ templates/base.html.twig | 2 +- templates/bucket/index.html.twig | 17 +- templates/feed/entries.html.twig | 2 + templates/feed/index.html.twig | 20 ++ templates/feed/show.html.twig | 26 ++ tests/Service/OpmlImporterTest.php | 73 +++++ tests/bootstrap.php | 11 + 26 files changed, 986 insertions(+), 6 deletions(-) create mode 100644 .env.test create mode 100755 bin/phpunit create mode 100644 phpunit.xml.dist create mode 100644 src/Command/ImportOpmlfileCommand.php create mode 100644 src/Controller/FeedController.php create mode 100644 src/Entity/Feed.php create mode 100644 src/Migrations/Version20200526192352.php create mode 100644 src/Repository/FeedRepository.php create mode 100644 src/Service/OpmlImporter.php create mode 100644 src/Service/Slugger.php create mode 100644 templates/feed/entries.html.twig create mode 100644 templates/feed/index.html.twig create mode 100644 templates/feed/show.html.twig create mode 100644 tests/Service/OpmlImporterTest.php create mode 100644 tests/bootstrap.php diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..d048686 --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' +SYMFONY_DEPRECATIONS_HELPER=999999 +PANTHER_APP_ENV=panther diff --git a/.gitignore b/.gitignore index 4816dca..66734ea 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,9 @@ /vendor/ ###< symfony/framework-bundle ### /node_modules/ + +###> symfony/phpunit-bridge ### +.phpunit +.phpunit.result.cache +/phpunit.xml +###< symfony/phpunit-bridge ### diff --git a/assets/sass/_layout.scss b/assets/sass/_layout.scss index dd8217a..13cbcda 100644 --- a/assets/sass/_layout.scss +++ b/assets/sass/_layout.scss @@ -1,4 +1,4 @@ body > main { display: grid; - grid-template-columns: var(--width-sidebar-max) 100%; + grid-template-columns: var(--width-sidebar-max) calc(100% - var(--width-sidebar-max)); } diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..4d1ed05 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,13 @@ +#!/usr/bin/env php +=5.5.9" + }, + "conflict": { + "phpunit/phpunit": "<4.8.35|<5.4.3,>=5.0|<6.4,>=6.0|9.1.2" + }, + "suggest": { + "symfony/error-handler": "For tracking deprecated interfaces usages at runtime with DebugClassLoader" + }, + "bin": [ + "bin/simple-phpunit" + ], + "type": "symfony-bridge", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + }, + "thanks": { + "name": "phpunit/phpunit", + "url": "https://github.com/sebastianbergmann/phpunit" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Bridge\\PhpUnit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony PHPUnit Bridge", + "homepage": "https://symfony.com", + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-04-28T17:58:55+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.17.0", @@ -3620,6 +3914,34 @@ ], "time": "2020-03-27T16:56:45+00:00" }, + { + "name": "symfony/test-pack", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/test-pack.git", + "reference": "ff87e800a67d06c423389f77b8209bc9dc469def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/test-pack/zipball/ff87e800a67d06c423389f77b8209bc9dc469def", + "reference": "ff87e800a67d06c423389f77b8209bc9dc469def", + "shasum": "" + }, + "require": { + "php": "^7.0", + "symfony/browser-kit": "*", + "symfony/css-selector": "*", + "symfony/phpunit-bridge": "*" + }, + "type": "symfony-pack", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A pack for functional and end-to-end testing within a Symfony app", + "time": "2019-06-21T06:27:32+00:00" + }, { "name": "symfony/translation-contracts", "version": "v2.0.1", diff --git a/config/routes.yaml b/config/routes.yaml index 40e54b5..b928235 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -8,3 +8,7 @@ start: bucket: path: /bucket/{slug} controller: App\Controller\BucketController::show + +feed: + path: /feed/{slug} + controller: App\Controller\FeedController::show diff --git a/config/services.yaml b/config/services.yaml index ff7ddf6..2954b51 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,3 +22,12 @@ services: App\Controller\: resource: '../src/Controller' tags: ['controller.service_arguments'] + + App\Command\ImportOpmlfileCommand: + tags: + - name: 'console.command' + command: 'app:import:opmlfile' + + slugger: + class: App\Service\Slugger + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d81f0c3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + tests + + + + + + src + + + + + + + diff --git a/public/css/style.css b/public/css/style.css index e3cf2a4..a7c217a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -59,4 +59,4 @@ body > footer { body > main { display: grid; - grid-template-columns: var(--width-sidebar-max) 100%; } + grid-template-columns: var(--width-sidebar-max) calc(100% - var(--width-sidebar-max)); } diff --git a/src/Command/ImportOpmlfileCommand.php b/src/Command/ImportOpmlfileCommand.php new file mode 100644 index 0000000..3d4dc19 --- /dev/null +++ b/src/Command/ImportOpmlfileCommand.php @@ -0,0 +1,39 @@ +importer = $importer; + } + + protected function configure() + { + $this->setDescription('Imports an OPML file.'); + $this->addArgument('file', InputArgument::REQUIRED, 'The OPML file to import.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->importer->importFromFile( + $input->getArgument('file') + ); + + return 0; + } +} diff --git a/src/Controller/FeedController.php b/src/Controller/FeedController.php new file mode 100644 index 0000000..c96e0eb --- /dev/null +++ b/src/Controller/FeedController.php @@ -0,0 +1,16 @@ +render('feed/show.html.twig', [ + 'feed' => $feed, + ]); + } +} diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php index 8d7db3e..582a16a 100644 --- a/src/Entity/Bucket.php +++ b/src/Entity/Bucket.php @@ -3,6 +3,8 @@ namespace App\Entity; use App\Repository\BucketRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** @@ -28,12 +30,18 @@ class Bucket */ private $slug; + /** + * @ORM\OneToMany(targetEntity=Feed::class, mappedBy="bucket") + */ + private $feeds; + public function __construct( string $name, string $slug ) { $this->name = $name; $this->slug = $slug; + $this->feeds = new ArrayCollection(); } public function getId(): int @@ -50,4 +58,12 @@ class Bucket { return $this->slug; } + + /** + * @return Collection|Feed[] + */ + public function getFeeds(): Collection + { + return $this->feeds; + } } diff --git a/src/Entity/Feed.php b/src/Entity/Feed.php new file mode 100644 index 0000000..422049e --- /dev/null +++ b/src/Entity/Feed.php @@ -0,0 +1,89 @@ +name = $name; + $this->slug = $slug; + $this->url = $url; + $this->bucket = $bucket; + $this->htmlUrl = $htmlUrl; + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getSlug(): string + { + return $this->slug; + } + + public function getBucket(): Bucket + { + return $this->bucket; + } + + public function getHtmlUrl(): string + { + return $this->htmlUrl; + } +} diff --git a/src/Migrations/Version20200526192352.php b/src/Migrations/Version20200526192352.php new file mode 100644 index 0000000..43e6221 --- /dev/null +++ b/src/Migrations/Version20200526192352.php @@ -0,0 +1,37 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'sqlite', 'Migration can only be executed safely on \'sqlite\'.'); + + $this->addSql('CREATE TABLE feed (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, bucket_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, url VARCHAR(255) NOT NULL, html_url VARCHAR(255) NOT NULL)'); + $this->addSql('CREATE INDEX IDX_234044AB84CE584D ON feed (bucket_id)'); + $this->addSql('CREATE UNIQUE INDEX feed_routing ON feed (slug)'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'sqlite', 'Migration can only be executed safely on \'sqlite\'.'); + + $this->addSql('DROP TABLE feed'); + } +} diff --git a/src/Repository/FeedRepository.php b/src/Repository/FeedRepository.php new file mode 100644 index 0000000..54d43d7 --- /dev/null +++ b/src/Repository/FeedRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('f') + ->andWhere('f.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('f.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?Feed + { + return $this->createQueryBuilder('f') + ->andWhere('f.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/src/Service/OpmlImporter.php b/src/Service/OpmlImporter.php new file mode 100644 index 0000000..b88e60d --- /dev/null +++ b/src/Service/OpmlImporter.php @@ -0,0 +1,130 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use App\Entity\Bucket; +use App\Entity\Feed; +use Doctrine\ORM\EntityManagerInterface; +use SimpleXMLElement; +use Symfony\Component\String\Slugger\SluggerInterface; + +class OpmlImporter +{ + /** + * @var SluggerInterface + */ + private $slugger; + + /** + * @var EntityManagerInterface + */ + private $entityManager; + + public function __construct( + SluggerInterface $slugger, + EntityManagerInterface $entityManager + ) { + $this->slugger = $slugger; + $this->entityManager = $entityManager; + } + + public function importFromFile(string $filePath): void + { + $opml = new SimpleXMLElement(file_get_contents($filePath)); + + // TODO: Trigger event + + foreach ($opml->body->outline as $outline) { + $this->importOutline($outline); + } + + $this->entityManager->flush(); + } + + public function isBucket(SimpleXMLElement $outline): bool + { + return is_null($outline['type']) + && ( + isset($outline['text']) + || isset($outline['title']) + ) + ; + } + + public function isFeed(SimpleXMLElement $outline): bool + { + return isset($outline['type']) + && $outline['type']->__toString() === 'rss' + && isset($outline['xmlUrl']) + ; + } + + private function importOutline(SimpleXMLElement $outline, Bucket $bucket = null): void + { + if ($this->isBucket($outline)) { + $this->importBucket($outline); + return; + } + + if ($this->isFeed($outline)) { + $this->importFeed($outline, $bucket); + return; + } + } + + private function importBucket(SimpleXMLElement $outline): void + { + $title = $outline['title'] ?? $outline['text']; + + // Check whether bucket already exists. + + $bucket = new Bucket( + $title, + strtolower($this->slugger->slug($title)) + ); + + // TODO: Trigger event + + $this->entityManager->persist($bucket); + + foreach ($outline->outline as $subOutline) { + $this->importOutline($subOutline, $bucket); + } + } + + private function importFeed(SimpleXMLElement $outline, Bucket $bucket = null): void + { + $title = $outline['title'] ?? $outline['text']; + + $feed = new Feed( + $title, + strtolower($this->slugger->slug($title)), + $outline['xmlUrl'], + $bucket, + $outline['htmlUrl'] ?? '' + ); + + // TODO: Trigger event + + $this->entityManager->persist($feed); + } +} diff --git a/src/Service/Slugger.php b/src/Service/Slugger.php new file mode 100644 index 0000000..0cd1fc0 --- /dev/null +++ b/src/Service/Slugger.php @@ -0,0 +1,38 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use Symfony\Component\String\AbstractUnicodeString; +use Symfony\Component\String\Slugger\AsciiSlugger; + +class Slugger extends AsciiSlugger +{ + public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString + { + $string .= '-' . uniqid(); + + $slug = parent::slug($string, $separator, $locale); + $slug = $slug->lower(); + + return $slug; + } +} diff --git a/symfony.lock b/symfony.lock index 22694b9..eceda3a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -132,6 +132,9 @@ "symfony/asset": { "version": "v5.0.8" }, + "symfony/browser-kit": { + "version": "v5.0.8" + }, "symfony/cache": { "version": "v5.0.8" }, @@ -154,12 +157,18 @@ "config/bootstrap.php" ] }, + "symfony/css-selector": { + "version": "v5.0.8" + }, "symfony/dependency-injection": { "version": "v5.0.8" }, "symfony/doctrine-bridge": { "version": "v5.0.8" }, + "symfony/dom-crawler": { + "version": "v5.0.8" + }, "symfony/dotenv": { "version": "v5.0.8" }, @@ -231,6 +240,21 @@ "symfony/orm-pack": { "version": "v1.0.8" }, + "symfony/phpunit-bridge": { + "version": "4.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "4.3", + "ref": "6d0e35f749d5f4bfe1f011762875275cd3f9874f" + }, + "files": [ + ".env.test", + "bin/phpunit", + "phpunit.xml.dist", + "tests/bootstrap.php" + ] + }, "symfony/polyfill-intl-grapheme": { "version": "v1.17.0" }, @@ -272,6 +296,9 @@ "symfony/string": { "version": "v5.0.8" }, + "symfony/test-pack": { + "version": "v1.0.6" + }, "symfony/translation-contracts": { "version": "v2.0.1" }, diff --git a/templates/base.html.twig b/templates/base.html.twig index 7f8a42d..25c0620 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -2,7 +2,7 @@ - {% block title %}Welcome!{% endblock %} + {% block title %}{{ app_name }}{% endblock %} {% block stylesheets %} {% endblock %} diff --git a/templates/bucket/index.html.twig b/templates/bucket/index.html.twig index 52bb421..e235392 100644 --- a/templates/bucket/index.html.twig +++ b/templates/bucket/index.html.twig @@ -1,9 +1,22 @@ {% extends 'base.html.twig' %} -{% block title %}Hello BucketController!{% endblock %} +{% block title %}{{ bucket.name }} | {{ parent() }}{% endblock %} {% block body %}
-

Bucket: {{ bucket.name }}

+

Bucket: {{ bucket.name }} with {{ bucket.feeds | length }} feeds

+ + {{ include('feed/entries.html.twig') }} + + {% endblock %} diff --git a/templates/feed/entries.html.twig b/templates/feed/entries.html.twig new file mode 100644 index 0000000..420ac38 --- /dev/null +++ b/templates/feed/entries.html.twig @@ -0,0 +1,2 @@ +

TODO: Show latest entries, need to be provided via include twig call

+

TODO: Create command to fetch entries

diff --git a/templates/feed/index.html.twig b/templates/feed/index.html.twig new file mode 100644 index 0000000..ca9a9aa --- /dev/null +++ b/templates/feed/index.html.twig @@ -0,0 +1,20 @@ +{% extends 'base.html.twig' %} + +{% block title %}Hello FeedController!{% endblock %} + +{% block body %} + + +
+

Hello {{ controller_name }}! ✅

+ + This friendly message is coming from: + +
+{% endblock %} diff --git a/templates/feed/show.html.twig b/templates/feed/show.html.twig new file mode 100644 index 0000000..e123c86 --- /dev/null +++ b/templates/feed/show.html.twig @@ -0,0 +1,26 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ feed.name }} {{ feed.bucket.name }}| {{ parent() }}{% endblock %} + +{% block body %} +
+

Feed: {{ feed.name }}

+ + +
+ + {{ include('feed/entries.html.twig') }} + +{% endblock %} diff --git a/tests/Service/OpmlImporterTest.php b/tests/Service/OpmlImporterTest.php new file mode 100644 index 0000000..e779d29 --- /dev/null +++ b/tests/Service/OpmlImporterTest.php @@ -0,0 +1,73 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +use App\Service\OpmlImporter; +use PHPUnit\Framework\TestCase; +use SimpleXMLElement; + +class OpmlImporterTest extends TestCase +{ + /** + * @test + */ + public function detectsBucket() + { + $subject = new OpmlImporter(); + $simpleXmlElement = new SimpleXmlElement(''); + + static::assertTrue($subject->isBucket($simpleXmlElement)); + } + + /** + * @test + */ + public function detectsFeedIsNoBucket() + { + $subject = new OpmlImporter(); + $simpleXmlElement = new SimpleXmlElement(''); + + static::assertFalse($subject->isBucket($simpleXmlElement)); + } + + /** + * @test + */ + public function detectsFeed() + { + $subject = new OpmlImporter(); + $simpleXmlElement = new SimpleXmlElement(''); + + static::assertTrue($subject->isFeed($simpleXmlElement)); + } + + /** + * @test + */ + public function detectsBucketIsNoFeed() + { + $subject = new OpmlImporter(); + $simpleXmlElement = new SimpleXmlElement(''); + + static::assertFalse($subject->isFeed($simpleXmlElement)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..469dcce --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,11 @@ +bootEnv(dirname(__DIR__).'/.env'); +}