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') }}
+
+
+ Feeds in this bucket
+
+
{% 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 %}
+
+
+ {{ 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');
+}