]> git.immae.eu Git - github/wallabag/wallabag.git/commitdiff
WIP federation 3163/head
authorThomas Citharel <tcit@tcit.fr>
Wed, 31 May 2017 07:31:18 +0000 (09:31 +0200)
committerThomas Citharel <tcit@tcit.fr>
Fri, 23 Jun 2017 08:08:54 +0000 (10:08 +0200)
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
80 files changed:
app/AppKernel.php
app/DoctrineMigrations/Version20170328185535.php [new file with mode: 0644]
app/Resources/static/themes/material/css/index.scss
app/Resources/static/themes/material/css/layout.scss
app/Resources/static/themes/material/css/profile.scss [new file with mode: 0644]
app/config/config.yml
app/config/parameters.yml.dist
app/config/routing.yml
app/config/security.yml
src/Wallabag/AnnotationBundle/Controller/WallabagAnnotationController.php
src/Wallabag/ApiBundle/Controller/EntryRestController.php
src/Wallabag/CoreBundle/Command/InstallCommand.php
src/Wallabag/CoreBundle/Controller/ConfigController.php
src/Wallabag/CoreBundle/Controller/EntryController.php
src/Wallabag/CoreBundle/Controller/ShareController.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Controller/TagController.php
src/Wallabag/CoreBundle/Entity/Activity.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Entity/Config.php
src/Wallabag/CoreBundle/Entity/Entry.php
src/Wallabag/CoreBundle/Entity/Notification.php
src/Wallabag/CoreBundle/Entity/Share.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationCreatedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationDeletedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEditedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryDeletedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEditedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryFavouriteEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryReadEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntrySavedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryTaggedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FederationEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FollowEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/RecommendedEntryEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/UnfollowEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareAcceptedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCancelledEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCreatedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareDeniedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserDeletedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEditedEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEvent.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/Activity/ActivitySubscriber.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Event/EntryDeletedEvent.php [deleted file]
src/Wallabag/CoreBundle/Event/EntrySavedEvent.php [deleted file]
src/Wallabag/CoreBundle/Event/Subscriber/DownloadImagesSubscriber.php
src/Wallabag/CoreBundle/Repository/ChangeRepository.php [new file with mode: 0644]
src/Wallabag/CoreBundle/Repository/EntryRepository.php
src/Wallabag/CoreBundle/Resources/config/services.yml
src/Wallabag/CoreBundle/Resources/translations/messages.en.yml
src/Wallabag/CoreBundle/Resources/translations/messages.fr.yml
src/Wallabag/CoreBundle/Resources/translations/messages.pt.yml
src/Wallabag/CoreBundle/Resources/views/themes/material/Config/index.html.twig
src/Wallabag/CoreBundle/Resources/views/themes/material/layout.html.twig
src/Wallabag/FederationBundle/Command/CreateAccountsCommand.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/InboxController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/LikedController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/MetadataController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/OutboxController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/ProfileController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Controller/RecommandController.php [new file with mode: 0644]
src/Wallabag/FederationBundle/DependencyInjection/Configuration.php [new file with mode: 0644]
src/Wallabag/FederationBundle/DependencyInjection/WallabagFederationExtension.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Entity/Account.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Entity/Instance.php [new file with mode: 0644]
src/Wallabag/FederationBundle/EventListener/CreateAccountListener.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Federation/CloudId.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Form/Type/AccountType.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Repository/AccountRepository.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Repository/InstanceRepository.php [new file with mode: 0644]
src/Wallabag/FederationBundle/Resources/config/services.yml [new file with mode: 0644]
src/Wallabag/FederationBundle/Resources/views/themes/material/User/followers.html.twig [new file with mode: 0644]
src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile.html.twig [new file with mode: 0644]
src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile_header.html.twig [new file with mode: 0644]
src/Wallabag/FederationBundle/Resources/views/themes/material/User/recommendations.html.twig [new file with mode: 0644]
src/Wallabag/FederationBundle/WallabagFederationBundle.php [new file with mode: 0644]
src/Wallabag/UserBundle/Entity/User.php
tests/Wallabag/CoreBundle/Command/InstallCommandTest.php

index c50783a6d6901fce9a25d12e6cd9b479daf935af..c5109866ebf83c0b1ce9c3cd77d53f4228933bd2 100644 (file)
@@ -32,6 +32,7 @@ class AppKernel extends Kernel
             new WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle(),
             new FOS\JsRoutingBundle\FOSJsRoutingBundle(),
             new BD\GuzzleSiteAuthenticatorBundle\BDGuzzleSiteAuthenticatorBundle(),
+            new OldSound\RabbitMqBundle\OldSoundRabbitMqBundle(),
 
             // wallabag bundles
             new Wallabag\CoreBundle\WallabagCoreBundle(),
@@ -39,7 +40,7 @@ class AppKernel extends Kernel
             new Wallabag\UserBundle\WallabagUserBundle(),
             new Wallabag\ImportBundle\WallabagImportBundle(),
             new Wallabag\AnnotationBundle\WallabagAnnotationBundle(),
-            new OldSound\RabbitMqBundle\OldSoundRabbitMqBundle(),
+            new Wallabag\FederationBundle\WallabagFederationBundle(),
         ];
 
         if (in_array($this->getEnvironment(), ['dev', 'test'], true)) {
diff --git a/app/DoctrineMigrations/Version20170328185535.php b/app/DoctrineMigrations/Version20170328185535.php
new file mode 100644 (file)
index 0000000..f0afb7b
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace Application\Migrations;
+
+use Doctrine\DBAL\Migrations\AbstractMigration;
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\DBAL\Schema\SchemaException;
+use Symfony\Component\DependencyInjection\ContainerAwareInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Doctrine\DBAL\Migrations\SkipMigrationException;
+
+/**
+ * Creates the Change table.
+ */
+class Version20170328185535 extends AbstractMigration implements ContainerAwareInterface
+{
+    /**
+     * @var ContainerInterface
+     */
+    private $container;
+
+    public function setContainer(ContainerInterface $container = null)
+    {
+        $this->container = $container;
+    }
+
+    private function getTable($tableName)
+    {
+        return $this->container->getParameter('database_table_prefix').$tableName;
+    }
+
+    /**
+     * @param Schema $schema
+     */
+    public function up(Schema $schema)
+    {
+        try {
+            $schema->getTable($this->getTable('change'));
+        } catch (SchemaException $e) {
+            // The Change table doesn't exist, we need to create it
+            if (10 == $e->getCode()) {
+                if ($this->connection->getDatabasePlatform()->getName() == 'sqlite') {
+                    $this->addSql('CREATE TABLE '.$this->getTable('change').' (id INTEGER NOT NULL, entry_id INTEGER DEFAULT NULL, type INTEGER NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id), CONSTRAINT FK_133B9D0FBA364942 FOREIGN KEY (entry_id) REFERENCES '.$this->getTable('entry').' (id) NOT DEFERRABLE INITIALLY IMMEDIATE)');
+
+                    return true;
+                }
+
+                $changeTable = $schema->createTable($this->getTable('change'));
+                $changeTable->addColumn(
+                    'id',
+                    'integer',
+                    ['autoincrement' => true]
+                );
+                $changeTable->addColumn(
+                    'type',
+                    'integer',
+                    ['notnull' => false]
+                );
+                $changeTable->addColumn(
+                    'created_at',
+                    'datetime',
+                    ['notnull' => false]
+                );
+                $changeTable->addColumn(
+                    'entry_id',
+                    'integer',
+                    ['notnull' => false]
+                );
+
+                $changeTable->setPrimaryKey(['id']);
+
+                $changeTable->addForeignKeyConstraint(
+                    $this->getTable('entry'),
+                    ['entry_id'],
+                    ['id'],
+                    ['onDelete' => 'CASCADE'],
+                    'IDX_change_entry'
+                );
+
+                return true;
+            }
+        }
+
+        throw new SkipMigrationException('It seems that you already played this migration.');
+    }
+
+    /**
+     * @param Schema $schema
+     */
+    public function down(Schema $schema)
+    {
+        try {
+            $changeTable = $schema->getTable($this->getTable('change'));
+            $schema->dropTable($changeTable->getName());
+        } catch (SchemaException $e) {
+            throw new SkipMigrationException('It seems that you already played this migration.');
+        }
+    }
+}
index 8300e4300ff7a32ba71c709d6a4d27e232b41e34..a360b99b5efc1331d063dd52fb66eebb3de64147 100644 (file)
@@ -9,6 +9,7 @@
 @import 'nav';
 @import 'sidenav';
 @import 'notifications';
+@import 'profile';
 @import 'various';
 
 /* Tools */
index cfdbf2b3147364a4c378f3f1ee799dccbe11614d..6fd14335d6000595f0f299154d819b011db1920c 100755 (executable)
@@ -18,6 +18,12 @@ body {
   border-bottom: 1px solid #ddd;
 }
 
+nav,
+body:not(.reset-left) main,
+footer {
+  padding-left: 240px;
+}
+
 main,
 #content,
 .valign-wrapper {
diff --git a/app/Resources/static/themes/material/css/profile.scss b/app/Resources/static/themes/material/css/profile.scss
new file mode 100644 (file)
index 0000000..d5b9a5f
--- /dev/null
@@ -0,0 +1,54 @@
+.profile {
+  .details {
+    display: flex;
+    flex-direction: row;
+
+    .bio {
+      flex: 1;
+      font-size: 14px;
+      line-height: 18px;
+      padding: 5px 10px;
+      order: 1;
+
+      p {
+        font-size: 14px;
+        font-weight: 400;
+        overflow: hidden;
+        word-break: normal;
+        word-wrap: break-word;
+      }
+    }
+
+    .details-counters {
+      order: 0;
+      display: flex;
+      flex-direction: row;
+    }
+
+    .counter {
+      width: 80px;
+      color: #9baec8;
+      padding: 5px 10px 0;
+      margin-bottom: 10px;
+      border-right: 1px solid #9baec8;
+      cursor: default;
+      position: relative;
+
+      .counter-label {
+        font-size: 12px;
+        text-transform: uppercase;
+        display: block;
+        margin-bottom: 5px;
+      }
+
+      .counter-number {
+        font-weight: 500;
+        font-size: 18px;
+        color: #00bcd4;
+      }
+    }
+  }
+  img.avatar {
+    width: 10em;
+  }
+}
index 2bc5e3b359292de4e5ff477d2fc976b81ab484f0..4a935dfb91967767170a54918e600155aef29f8b 100644 (file)
@@ -6,8 +6,9 @@ imports:
 
 parameters:
     # Allows to use the live reload feature for changes in assets
-    use_webpack_dev_server: false
+    use_webpack_dev_server: true
     craue_config.cache_adapter.class: Craue\ConfigBundle\CacheAdapter\SymfonyCacheComponentAdapter
+    media_directory: '%kernel.root_dir%/../web/uploads/media'
 
 framework:
     #esi:             ~
index b3fe11c87d5a89486e3e8eeeea5b7b07594f4297..d342c72aba718790b97cb3ddfd961f25a6cd253f 100644 (file)
@@ -25,6 +25,9 @@ parameters:
 
     domain_name: https://your-wallabag-url-instance.com
 
+    # Instance URL
+    domain_name: wallabag.tld
+
     mailer_transport: smtp
     mailer_host: 127.0.0.1
     mailer_user: ~
index 0bd2d130675140d54ee3f9c7d4d5b1acb20553e7..f1bbbda9103b3df1674612700dd130a3312cce25 100644 (file)
@@ -17,6 +17,11 @@ wallabag_api:
     type: annotation
     prefix: /
 
+wallabag_federation:
+    resource: "@WallabagFederationBundle/Controller/"
+    type: annotation
+    prefix: /
+
 app:
     resource: "@WallabagCoreBundle/Controller/"
     type: annotation
index e14a0bd19b1924ea34d6f85e787b2c6c7c290948..a8a0a6ae532990464517b5654996a1a09c219bdc 100644 (file)
@@ -66,4 +66,5 @@ security:
         - { path: ^/settings, roles: ROLE_SUPER_ADMIN }
         - { path: ^/annotations, roles: ROLE_USER }
         - { path: ^/users, roles: ROLE_SUPER_ADMIN }
+        - { path: ^/profile, roles: IS_AUTHENTICATED_ANONYMOUSLY }
         - { path: ^/, roles: ROLE_USER }
index 2b4b0e8dd071245afde9e864fca0c119c14a455f..45fd9cf3a0c37b90f9fbc197ab632e3386ee53bd 100644 (file)
@@ -10,6 +10,9 @@ use Wallabag\AnnotationBundle\Entity\Annotation;
 use Wallabag\AnnotationBundle\Form\EditAnnotationType;
 use Wallabag\AnnotationBundle\Form\NewAnnotationType;
 use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationCreatedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationEditedEvent;
 
 class WallabagAnnotationController extends FOSRestController
 {
@@ -64,6 +67,8 @@ class WallabagAnnotationController extends FOSRestController
             $em->persist($annotation);
             $em->flush();
 
+            $this->get('event_dispatcher')->dispatch(AnnotationCreatedEvent::NAME, new AnnotationCreatedEvent($annotation));
+
             $json = $this->get('serializer')->serialize($annotation, 'json');
 
             return JsonResponse::fromJsonString($json);
@@ -100,6 +105,7 @@ class WallabagAnnotationController extends FOSRestController
             $em->flush();
 
             $json = $this->get('serializer')->serialize($annotation, 'json');
+            $this->get('event_dispatcher')->dispatch(AnnotationEditedEvent::NAME, new AnnotationEditedEvent($annotation));
 
             return JsonResponse::fromJsonString($json);
         }
@@ -124,6 +130,8 @@ class WallabagAnnotationController extends FOSRestController
         $em->remove($annotation);
         $em->flush();
 
+        $this->get('event_dispatcher')->dispatch(AnnotationDeletedEvent::NAME, new AnnotationDeletedEvent($annotation));
+
         $json = $this->get('serializer')->serialize($annotation, 'json');
 
         return (new JsonResponse())->setJson($json);
index 768c4fdc3423c7db828a9bca9d854bc2f884e69a..d1c95da665ecd3236592a68af65ef53081183080 100644 (file)
@@ -11,8 +11,10 @@ use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Wallabag\CoreBundle\Entity\Entry;
 use Wallabag\CoreBundle\Entity\Tag;
-use Wallabag\CoreBundle\Event\EntrySavedEvent;
 use Wallabag\CoreBundle\Event\EntryDeletedEvent;
+use Wallabag\CoreBundle\Event\EntrySavedEvent;
+use Wallabag\CoreBundle\Event\EntryTaggedEvent;
+use Wallabag\CoreBundle\Event\EntryUpdatedEvent;
 
 class EntryRestController extends WallabagRestController
 {
@@ -356,6 +358,8 @@ class EntryRestController extends WallabagRestController
 
         $this->upsertEntry($entry, $request, true);
 
+        $this->get('event_dispatcher')->dispatch(EntryUpdatedEvent::NAME, new EntryUpdatedEvent($entry));
+
         return $this->sendResponse($entry);
     }
 
@@ -397,6 +401,7 @@ class EntryRestController extends WallabagRestController
         $em->flush();
 
         // entry saved, dispatch event about it!
+        $this->get('event_dispatcher')->dispatch(EntryUpdatedEvent::NAME, new EntryUpdatedEvent($entry));
         $this->get('event_dispatcher')->dispatch(EntrySavedEvent::NAME, new EntrySavedEvent($entry));
 
         return $this->sendResponse($entry);
@@ -467,6 +472,7 @@ class EntryRestController extends WallabagRestController
         $this->validateUserAccess($entry->getUser()->getId());
 
         $tags = $request->request->get('tags', '');
+        $tagsEntries = [];
         if (!empty($tags)) {
             $this->get('wallabag_core.tags_assigner')->assignTagsToEntry($entry, $tags);
         }
@@ -475,6 +481,8 @@ class EntryRestController extends WallabagRestController
         $em->persist($entry);
         $em->flush();
 
+        $this->get('event_dispatcher')->dispatch(EntryTaggedEvent::NAME, new EntryTaggedEvent($entry, $tagsEntries));
+
         return $this->sendResponse($entry);
     }
 
@@ -668,11 +676,11 @@ class EntryRestController extends WallabagRestController
         }
 
         if (!is_null($isArchived)) {
-            $entry->setArchived((bool) $isArchived);
+            $entry->setArchived((bool)$isArchived);
         }
 
         if (!is_null($isStarred)) {
-            $entry->setStarred((bool) $isStarred);
+            $entry->setStarred((bool)$isStarred);
         }
 
         if (!empty($tags)) {
@@ -680,9 +688,9 @@ class EntryRestController extends WallabagRestController
         }
 
         if (!is_null($isPublic)) {
-            if (true === (bool) $isPublic && null === $entry->getUid()) {
+            if (true === (bool)$isPublic && null === $entry->getUid()) {
                 $entry->generateUid();
-            } elseif (false === (bool) $isPublic) {
+            } elseif (false === (bool)$isPublic) {
                 $entry->cleanUid();
             }
         }
@@ -694,4 +702,28 @@ class EntryRestController extends WallabagRestController
         // entry saved, dispatch event about it!
         $this->get('event_dispatcher')->dispatch(EntrySavedEvent::NAME, new EntrySavedEvent($entry));
     }
+
+    /**
+     * Gets history since a date.
+     *
+     * @ApiDoc(
+     *       parameters={
+     *          {"name"="since", "dataType"="integer", "required"=true, "format"="A timestamp", "description"="Timestamp of the history's start"},
+     *       }
+     * )
+     *
+     * @return JsonResponse
+     */
+    public function getEntriesHistoryAction(Request $request)
+    {
+        $this->validateAuthentication();
+
+        $res = $this->getDoctrine()
+            ->getRepository('WallabagCoreBundle:Change')
+            ->findChangesSinceDate($request->query->get('since'));
+
+        $json = $this->get('serializer')->serialize($res, 'json');
+
+        return (new JsonResponse())->setJson($json);
+    }
 }
index eb725a59a90486a89a3d67345cc2dc2b9703a48d..e327bbe586b11708eb9d6926000f541fbe4e04c0 100644 (file)
@@ -286,7 +286,7 @@ class InstallCommand extends ContainerAwareCommand
 
     protected function setupConfig()
     {
-        $this->defaultOutput->writeln('<info><comment>Step 4 of 5.</comment> Config setup.</info>');
+        $this->defaultOutput->writeln('<info><comment>Step 4 of 5.</comment> config setup.</info>');
         $em = $this->getContainer()->get('doctrine.orm.entity_manager');
 
         // cleanup before insert new stuff
index d4170d398e58dd77c1de3f7c287f0ae9b8f8af27..23af98e5ba4eca2c0f02e52aa14a881624e6832d 100644 (file)
@@ -10,12 +10,16 @@ use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
 use Wallabag\CoreBundle\Entity\Config;
 use Wallabag\CoreBundle\Entity\TaggingRule;
+use Wallabag\CoreBundle\Event\Activity\Actions\User\UserDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\User\UserEditedEvent;
 use Wallabag\CoreBundle\Form\Type\ConfigType;
 use Wallabag\CoreBundle\Form\Type\ChangePasswordType;
 use Wallabag\CoreBundle\Form\Type\RssType;
 use Wallabag\CoreBundle\Form\Type\TaggingRuleType;
 use Wallabag\CoreBundle\Form\Type\UserInformationType;
 use Wallabag\CoreBundle\Tools\Utils;
+use Wallabag\FederationBundle\Form\Type\AccountType;
+use Wallabag\UserBundle\Entity\User;
 
 class ConfigController extends Controller
 {
@@ -82,6 +86,50 @@ class ConfigController extends Controller
         if ($userForm->isSubmitted() && $userForm->isValid()) {
             $userManager->updateUser($user, true);
 
+            $this->get('event_dispatcher')->dispatch(UserEditedEvent::NAME, new UserEditedEvent($user->getAccount()));
+
+            $this->get('session')->getFlashBag()->add(
+                'notice',
+                'flashes.config.notice.user_updated'
+            );
+
+            return $this->redirect($this->generateUrl('config').'#set3');
+        }
+
+        // handle account information
+        $account = $user->getAccount();
+        $accountForm = $this->createForm(AccountType::class, $account, [
+            'action' => $this->generateUrl('config').'#set3',
+        ]);
+        $accountForm->handleRequest($request);
+
+        if ($accountForm->isSubmitted() && $accountForm->isValid()) {
+
+            $avatar = $account->getAvatar();
+            $banner = $account->getBanner();
+
+            if (null !== $avatar) {
+                $avatarFileName = md5(uniqid('', true)) . '.' . $avatar->guessExtension();
+
+                $avatar->move(
+                    $this->getParameter('media_directory') . '/avatar',
+                    $avatarFileName
+                );
+                $account->setAvatar($avatarFileName);
+            }
+
+            if (null != $banner) {
+                $bannerFileName = md5(uniqid('', true)) . '.' . $banner->guessExtension();
+
+                $banner->move(
+                    $this->get('media_directory') . '/banner',
+                    $bannerFileName
+                );
+                $account->setBanner($bannerFileName);
+            }
+
+            $this->get('event_dispatcher')->dispatch(UserEditedEvent::NAME, new UserEditedEvent($user));
+
             $this->get('session')->getFlashBag()->add(
                 'notice',
                 'flashes.config.notice.user_updated'
@@ -145,6 +193,7 @@ class ConfigController extends Controller
                 'pwd' => $pwdForm->createView(),
                 'user' => $userForm->createView(),
                 'new_tagging_rule' => $newTaggingRule->createView(),
+                'account' => $accountForm->createView(),
             ],
             'rss' => [
                 'username' => $user->getUsername(),
@@ -400,9 +449,13 @@ class ConfigController extends Controller
         $this->get('security.token_storage')->setToken(null);
         $request->getSession()->invalidate();
 
+        $account = $user->getAccount();
+
         $em = $this->get('fos_user.user_manager');
         $em->deleteUser($user);
 
+        $this->get('event_dispatcher')->dispatch(UserDeletedEvent::NAME, new UserDeletedEvent($account));
+
         return $this->redirect($this->generateUrl('fos_user_security_login'));
     }
 
index fafa49f1b0bb69a156db8318a6ab3e9a9c2bba02..5e4462edd20c18901e7600dbb7306b0d98133db1 100644 (file)
@@ -9,12 +9,14 @@ use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
 use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryEditedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryFavouriteEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryReadEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntrySavedEvent;
 use Wallabag\CoreBundle\Form\Type\EntryFilterType;
 use Wallabag\CoreBundle\Form\Type\EditEntryType;
 use Wallabag\CoreBundle\Form\Type\NewEntryType;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
-use Wallabag\CoreBundle\Event\EntrySavedEvent;
-use Wallabag\CoreBundle\Event\EntryDeletedEvent;
 use Wallabag\CoreBundle\Form\Type\SearchEntryType;
 
 class EntryController extends Controller
@@ -405,6 +407,8 @@ class EntryController extends Controller
         $entry->toggleArchive();
         $this->getDoctrine()->getManager()->flush();
 
+        $this->get('event_dispatcher')->dispatch(EntryReadEvent::NAME, new EntryReadEvent($entry));
+
         $message = 'flashes.entry.notice.entry_unarchived';
         if ($entry->isArchived()) {
             $message = 'flashes.entry.notice.entry_archived';
@@ -437,6 +441,8 @@ class EntryController extends Controller
         $entry->toggleStar();
         $this->getDoctrine()->getManager()->flush();
 
+        $this->get('event_dispatcher')->dispatch(EntryFavouriteEvent::NAME, new EntryFavouriteEvent($entry));
+
         $message = 'flashes.entry.notice.entry_unstarred';
         if ($entry->isStarred()) {
             $message = 'flashes.entry.notice.entry_starred';
@@ -473,9 +479,6 @@ class EntryController extends Controller
             UrlGeneratorInterface::ABSOLUTE_PATH
         );
 
-        // entry deleted, dispatch event about it!
-        $this->get('event_dispatcher')->dispatch(EntryDeletedEvent::NAME, new EntryDeletedEvent($entry));
-
         $em = $this->getDoctrine()->getManager();
         $em->remove($entry);
         $em->flush();
diff --git a/src/Wallabag/CoreBundle/Controller/ShareController.php b/src/Wallabag/CoreBundle/Controller/ShareController.php
new file mode 100644 (file)
index 0000000..d6f83eb
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+
+namespace Wallabag\CoreBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\Security\Core\Exception\AccessDeniedException;
+use Symfony\Component\Security\Core\Exception\InvalidArgumentException;
+use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\CoreBundle\Entity\Notification;
+use Wallabag\CoreBundle\Entity\Share;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntrySavedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareAcceptedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareCancelledEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareCreatedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareDeniedEvent;
+use Wallabag\CoreBundle\Notifications\NoAction;
+use Wallabag\CoreBundle\Notifications\YesAction;
+use Wallabag\UserBundle\Entity\User;
+
+class ShareController extends Controller
+{
+    /**
+     * @Route("/share-user/{entry}/{destination}", name="share-entry-user", requirements={"entry" = "\d+", "destination" = "\d+"})
+     * @param Entry $entry
+     * @param User $destination
+     * @throws AccessDeniedException
+     * @throws InvalidArgumentException
+     */
+    public function shareEntryAction(Entry $entry, User $destination)
+    {
+
+        if ($entry->getUser() !== $this->getUser()) {
+            throw new AccessDeniedException("You can't share this entry");
+        }
+
+        if ($destination === $this->getUser()) {
+            throw new InvalidArgumentException("You can't share entries to yourself");
+        }
+
+        $share = new Share();
+        $share->setUserOrigin($this->getUser())
+            ->setEntry($entry)
+            ->setUserDestination($destination);
+
+        $em = $this->getDoctrine()->getManager();
+        $em->persist($share);
+        $em->flush();
+
+        $this->get('event_dispatcher')->dispatch(ShareCreatedEvent::NAME, new ShareCancelledEvent($share));
+
+        $accept = new YesAction($this->generateUrl('share-entry-user-accept', ['share' => $share->getId()]));
+
+        $deny = new NoAction($this->generateUrl('share-entry-user-refuse', ['share' => $share->getId()]));
+
+        $notification = new Notification($destination);
+        $notification->setType(Notification::TYPE_SHARE)
+            ->setTitle($this->get('translator')->trans('share.notification.new.title'))
+            ->addAction($accept)
+            ->addAction($deny);
+
+        $em->persist($notification);
+        $em->flush();
+
+        $this->redirectToRoute('view', ['id' => $entry->getId()]);
+    }
+
+    /**
+     * @Route("/share-user/accept/{share}", name="share-entry-user-accept")
+     *
+     * @param Share $share
+     * @return RedirectResponse
+     * @throws AccessDeniedException
+     */
+    public function acceptShareAction(Share $share)
+    {
+        if ($share->getUserDestination() !== $this->getUser()) {
+            throw new AccessDeniedException("You can't accept this entry");
+        }
+
+        $entry = new Entry($this->getUser());
+        $entry->setUrl($share->getEntry()->getUrl());
+
+        $em = $this->getDoctrine()->getManager();
+
+        if (false === $this->checkIfEntryAlreadyExists($entry)) {
+            $this->updateEntry($entry);
+
+            $em->persist($entry);
+            $em->flush();
+
+            $this->get('event_dispatcher')->dispatch(ShareAcceptedEvent::NAME, new ShareAcceptedEvent($share));
+
+            // entry saved, dispatch event about it!
+            $this->get('event_dispatcher')->dispatch(EntrySavedEvent::NAME, new EntrySavedEvent($entry));
+        }
+
+        $em->remove($share);
+        $em->flush(); // we keep the previous flush above in case the event dispatcher would lead in using the saved entry
+
+        return $this->redirect($this->generateUrl('homepage'));
+    }
+
+    /**
+     * @Route("/share-user/refuse/{share}", name="share-entry-user-refuse")
+     *
+     * @param Share $share
+     * @return RedirectResponse
+     */
+    public function refuseShareAction(Share $share)
+    {
+        $em = $this->getDoctrine()->getManager();
+        $em->remove($share);
+        $em->flush();
+
+        $this->get('event_dispatcher')->dispatch(ShareDeniedEvent::NAME, new ShareDeniedEvent($share));
+
+        return $this->redirect($this->generateUrl('homepage'));
+    }
+
+    /**
+     * Fetch content and update entry.
+     * In case it fails, entry will return to avod loosing the data.
+     *
+     * @param Entry  $entry
+     * @param string $prefixMessage Should be the translation key: entry_saved or entry_reloaded
+     *
+     * @return Entry
+     */
+    private function updateEntry(Entry $entry, $prefixMessage = 'entry_saved')
+    {
+        // put default title in case of fetching content failed
+        $entry->setTitle('No title found');
+
+        $message = 'flashes.entry.notice.'.$prefixMessage;
+
+        try {
+            $entry = $this->get('wallabag_core.content_proxy')->updateEntry($entry, $entry->getUrl());
+        } catch (\Exception $e) {
+            $this->get('logger')->error('Error while saving an entry', [
+                'exception' => $e,
+                'entry' => $entry,
+            ]);
+
+            $message = 'flashes.entry.notice.'.$prefixMessage.'_failed';
+        }
+
+        $this->get('session')->getFlashBag()->add('notice', $message);
+
+        return $entry;
+    }
+
+    /**
+     * Check for existing entry, if it exists, redirect to it with a message.
+     *
+     * @param Entry $entry
+     *
+     * @return Entry|bool
+     */
+    private function checkIfEntryAlreadyExists(Entry $entry)
+    {
+        return $this->get('wallabag_core.entry_repository')->findByUrlAndUserId($entry->getUrl(), $this->getUser()->getId());
+    }
+}
index a8b1eaddb9a045008fb12adcce30695528c21fc0..6cc78458a49445e567b418664f3a0e5364f91595 100644 (file)
@@ -9,6 +9,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Request;
 use Wallabag\CoreBundle\Entity\Entry;
 use Wallabag\CoreBundle\Entity\Tag;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryTaggedEvent;
 use Wallabag\CoreBundle\Form\Type\NewTagType;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
 
@@ -37,6 +38,8 @@ class TagController extends Controller
             $em->persist($entry);
             $em->flush();
 
+            $this->get('event_dispatcher')->dispatch(EntryTaggedEvent::NAME, new EntryTaggedEvent($entry, $tags));
+
             $this->get('session')->getFlashBag()->add(
                 'notice',
                 'flashes.tag.notice.tag_added'
@@ -64,6 +67,8 @@ class TagController extends Controller
         $em = $this->getDoctrine()->getManager();
         $em->flush();
 
+        $this->get('event_dispatcher')->dispatch(EntryTaggedEvent::NAME, new EntryTaggedEvent($entry, $tag), true);
+
         // remove orphan tag in case no entries are associated to it
         if (count($tag->getEntries()) === 0) {
             $em->remove($tag);
diff --git a/src/Wallabag/CoreBundle/Entity/Activity.php b/src/Wallabag/CoreBundle/Entity/Activity.php
new file mode 100644 (file)
index 0000000..08a3f1f
--- /dev/null
@@ -0,0 +1,295 @@
+<?php
+
+namespace Wallabag\CoreBundle\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+use Wallabag\FederationBundle\Entity\Account;
+
+/**
+ * Change.
+ *
+ * This entity stores a datetime for each activity.
+ *
+ * @ORM\Entity(repositoryClass="Wallabag\CoreBundle\Repository\ChangeRepository")
+ * @ORM\Table(name="`activity`")
+ */
+class Activity
+{
+    /**
+     * Object types
+     */
+    const ENTRY_OBJECT = 1;
+    const TAG_OBJECT = 2;
+    const USER_OBJECT = 3;
+    const SHARE_OBJECT = 4;
+    const GROUP_OBJECT = 5;
+    const ANNOTATION_OBJECT = 6;
+    const CONFIG_OBJECT = 7;
+    const ACCOUNT_OBJECT = 8;
+
+    /**
+     * Events
+     */
+
+    /**
+     * Entry events
+     */
+    const ENTRY_ADD = 10; // done
+    const ENTRY_EDIT = 11; // done
+    const ENTRY_READ = 12; // done
+    const ENTRY_UNREAD = 13; // done
+    const ENTRY_FAVOURITE = 14; // done
+    const ENTRY_UNFAVOURITE = 15; // done
+    const ENTRY_DELETE = 19; // done
+
+    /**
+     * Tag events
+     */
+    const TAG_CREATE = 20; // not yet implemented
+    const TAG_EDIT = 21; // not yet implemented
+    const TAG_REMOVE = 29; // not yet implemented
+
+    /**
+     * Entry - Tag events
+     */
+    const ENTRY_ADD_TAG = 30; // done
+    const ENTRY_REMOVE_TAG = 39; // done
+
+    /**
+     * Entry - Annotation events
+     */
+    const ANNOTATION_ADD = 40; // done
+    const ANNOTATION_EDIT = 41; // done
+    const ANNOTATION_REMOVE = 49; // done
+
+    /**
+     * User events
+     */
+    const USER_CREATE = 50; // done
+    const USER_EDIT = 51; // done
+    const USER_REMOVE = 59; // done
+
+    /**
+     * Federation events
+     */
+    const FOLLOW_ACCOUNT = 61;
+    const UNFOLLOW_ACCOUNT = 62;
+    const RECOMMEND_ENTRY = 63;
+
+    /**
+     * Share events
+     */
+    const USER_SHARE_CREATED = 70; // done
+    const USER_SHARE_ACCEPTED = 71; // done
+    const USER_SHARE_REFUSED = 72; // done
+    const USER_SHARE_CANCELLED = 79; // not implemented yet
+
+    /**
+     * Group events
+     */
+    const GROUP_CREATE = 80;
+    const GROUP_EDIT = 81;
+    const GROUP_ADD_MEMBER = 82;
+    const GROUP_EDIT_MEMBER = 83;
+    const GROUP_REMOVE_MEMBER = 84;
+    const GROUP_SHARE_ENTRY = 85;
+    const GROUP_DELETE = 89;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer")
+     * @ORM\Id
+     * @ORM\GeneratedValue(strategy="AUTO")
+     */
+    private $id;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer")
+     */
+    private $activityType;
+
+    /**
+     * @var Account
+     */
+    private $user;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer")
+     */
+    private $primaryObjectType;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer")
+     */
+    private $primaryObjectId;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer", nullable=true)
+     */
+    private $secondaryObjectType;
+
+    /**
+     * @var int
+     *
+     * @ORM\Column(type="integer", nullable=true)
+     */
+    private $secondaryObjectId;
+
+    /**
+     * @var \DateTime
+     *
+     * @ORM\Column(name="created_at", type="datetime")
+     */
+    private $createdAt;
+
+    public function __construct($activityType, $primaryObjectType, $primaryObjectId)
+    {
+        $this->activityType = $activityType;
+        $this->primaryObjectType = $primaryObjectType;
+        $this->primaryObjectId = $primaryObjectId;
+        $this->createdAt = new \DateTime();
+    }
+
+    /**
+     * @return int
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return int
+     */
+    public function getActivityType()
+    {
+        return $this->activityType;
+    }
+
+    /**
+     * @param int $activityType
+     * @return Activity
+     */
+    public function setActivityType($activityType)
+    {
+        $this->activityType = $activityType;
+        return $this;
+    }
+
+    /**
+     * @return \DateTime
+     */
+    public function getCreatedAt()
+    {
+        return $this->createdAt;
+    }
+
+    /**
+     * @param \DateTime $createdAt
+     * @return Activity
+     */
+    public function setCreatedAt(\DateTime $createdAt)
+    {
+        $this->createdAt = $createdAt;
+        return $this;
+    }
+
+    /**
+     * @return int
+     */
+    public function getPrimaryObjectId()
+    {
+        return $this->primaryObjectId;
+    }
+
+    /**
+     * @param $primaryObjectId
+     * @return Activity
+     */
+    public function setPrimaryObjectId($primaryObjectId)
+    {
+        $this->primaryObjectId = $primaryObjectId;
+        return $this;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getUser()
+    {
+        return $this->user;
+    }
+
+    /**
+     * @param Account $user
+     * @return Activity
+     */
+    public function setUser($user)
+    {
+        $this->user = $user;
+        return $this;
+    }
+
+    /**
+     * @return int
+     */
+    public function getPrimaryObjectType()
+    {
+        return $this->primaryObjectType;
+    }
+
+    /**
+     * @param int $primaryObjectType
+     * @return Activity
+     */
+    public function setPrimaryObjectType($primaryObjectType)
+    {
+        $this->primaryObjectType = $primaryObjectType;
+        return $this;
+    }
+
+    /**
+     * @return int
+     */
+    public function getSecondaryObjectType()
+    {
+        return $this->secondaryObjectType;
+    }
+
+    /**
+     * @param int $secondaryObjectType
+     * @return Activity
+     */
+    public function setSecondaryObjectType($secondaryObjectType)
+    {
+        $this->secondaryObjectType = $secondaryObjectType;
+        return $this;
+    }
+
+    /**
+     * @return int
+     */
+    public function getSecondaryObjectId()
+    {
+        return $this->secondaryObjectId;
+    }
+
+    /**
+     * @param int $secondaryObjectId
+     * @return Activity
+     */
+    public function setSecondaryObjectId($secondaryObjectId)
+    {
+        $this->secondaryObjectId = $secondaryObjectId;
+        return $this;
+    }
+}
index b902ae2cb2ef00d055d5c6ae5605c413ea8519d2..f42a49b3f71e30f95877ee7805ec8aa9b90bd323 100644 (file)
@@ -8,7 +8,7 @@ use Symfony\Component\Validator\Constraints as Assert;
 use Wallabag\UserBundle\Entity\User;
 
 /**
- * Config.
+ * config.
  *
  * @ORM\Entity(repositoryClass="Wallabag\CoreBundle\Repository\ConfigRepository")
  * @ORM\Table(name="`config`")
index a0503c3918170dfbb9fc8bb29a0e8a4f22f72f4c..6ca17126db72650c0be2fd5918666da9a191fbac 100644 (file)
@@ -233,13 +233,21 @@ class Entry
      */
     private $tags;
 
-    /*
+    /**
+     * @var boolean
+     *
+     * @ORM\Column(name="recommended", type="boolean", nullable=true)
+     */
+    private $recommended;
+
+    /**
      * @param User     $user
      */
     public function __construct(User $user)
     {
         $this->user = $user;
         $this->tags = new ArrayCollection();
+        $this->changes = new ArrayCollection();
     }
 
     /**
@@ -778,4 +786,20 @@ class Entry
 
         return $this;
     }
+
+    /**
+     * @return bool
+     */
+    public function isRecommended()
+    {
+        return $this->recommended;
+    }
+
+    /**
+     * @param bool $recommended
+     */
+    public function setRecommended($recommended)
+    {
+        $this->recommended = $recommended;
+    }
 }
index aa4c03c335ccace6d8c820bc04a0a5a0cb7f9694..d4304f39f13279561dbb994d683c8cd06f4f304d 100644 (file)
@@ -91,6 +91,7 @@ class Notification implements NotificationInterface
     const TYPE_ADMIN = 0;
     const TYPE_USER = 1;
     const TYPE_RELEASE = 2;
+    const TYPE_SHARE = 3;
 
     public function __construct(User $user = null)
     {
diff --git a/src/Wallabag/CoreBundle/Entity/Share.php b/src/Wallabag/CoreBundle/Entity/Share.php
new file mode 100644 (file)
index 0000000..a55b4e6
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace Wallabag\CoreBundle\Entity;
+
+use Wallabag\FederationBundle\Entity\Account;
+use Wallabag\UserBundle\Entity\User;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * Share.
+ *
+ * @ORM\Entity
+ */
+class Share
+{
+    /**
+     * @var int
+     *
+     * @ORM\Column(name="id", type="integer")
+     * @ORM\Id
+     * @ORM\GeneratedValue(strategy="AUTO")
+     */
+    private $id;
+
+    /**
+     * @var Account
+     *
+     * @ORM\ManyToOne(targetEntity="Wallabag\FederationBundle\Entity\Account")
+     */
+    private $userOrigin;
+
+    /**
+     * @var Account
+     *
+     * @ORM\ManyToOne(targetEntity="Wallabag\FederationBundle\Entity\Account")
+     */
+    private $userDestination;
+
+    /**
+     * @var Entry
+     *
+     * @ORM\ManyToOne(targetEntity="Wallabag\CoreBundle\Entity\Entry")
+     */
+    private $entry;
+
+    /**
+     * @var boolean
+     *
+     * @ORM\Column(name="accepted", type="boolean")
+     */
+    private $accepted;
+
+    /**
+     * Share constructor.
+     */
+    public function __construct()
+    {
+        $this->accepted = false;
+    }
+
+    /**
+     * @return int
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getUserOrigin()
+    {
+        return $this->userOrigin;
+    }
+
+    /**
+     * @param User $userOrigin
+     * @return Share
+     */
+    public function setUserOrigin(User $userOrigin)
+    {
+        $this->userOrigin = $userOrigin;
+        return $this;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getUserDestination()
+    {
+        return $this->userDestination;
+    }
+
+    /**
+     * @param User $userDestination
+     * @return Share
+     */
+    public function setUserDestination(User $userDestination)
+    {
+        $this->userDestination = $userDestination;
+        return $this;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isAccepted()
+    {
+        return $this->accepted;
+    }
+
+    /**
+     * @param bool $accepted
+     * @return Share
+     */
+    public function setAccepted($accepted)
+    {
+        $this->accepted = $accepted;
+        return $this;
+    }
+
+    /**
+     * @return Entry
+     */
+    public function getEntry()
+    {
+        return $this->entry;
+    }
+
+    /**
+     * @param Entry $entry
+     * @return Share
+     */
+    public function setEntry(Entry $entry)
+    {
+        $this->entry = $entry;
+        return $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationCreatedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationCreatedEvent.php
new file mode 100644 (file)
index 0000000..b366770
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Annotation;
+
+class AnnotationCreatedEvent extends AnnotationEvent
+{
+    const NAME = 'annotation.created';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationDeletedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationDeletedEvent.php
new file mode 100644 (file)
index 0000000..60d5384
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Annotation;
+
+class AnnotationDeletedEvent extends AnnotationEvent
+{
+    const NAME = 'annotation.deleted';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEditedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEditedEvent.php
new file mode 100644 (file)
index 0000000..385b802
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Annotation;
+
+class AnnotationEditedEvent extends AnnotationEvent
+{
+    const NAME = 'annotation.edited';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Annotation/AnnotationEvent.php
new file mode 100644 (file)
index 0000000..b4cb93a
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Annotation;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\AnnotationBundle\Entity\Annotation;
+
+/**
+ * This event is fired when annotation-relative stuff is made.
+ */
+abstract class AnnotationEvent extends Event
+{
+    protected $annotation;
+
+    /**
+     * AnnotationEvent constructor.
+     * @param Annotation $annotation
+     */
+    public function __construct(Annotation $annotation)
+    {
+        $this->annotation = $annotation;
+    }
+
+    /**
+     * @return Annotation
+     */
+    public function getAnnotation()
+    {
+        return $this->annotation;
+    }
+
+    /**
+     * @param Annotation $annotation
+     */
+    public function setAnnotation(Annotation $annotation)
+    {
+        $this->annotation = $annotation;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryDeletedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryDeletedEvent.php
new file mode 100644 (file)
index 0000000..1d413d4
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+/**
+ * This event is fired as soon as an entry is deleted.
+ */
+class EntryDeletedEvent extends EntryEvent
+{
+    const NAME = 'entry.deleted';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEditedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEditedEvent.php
new file mode 100644 (file)
index 0000000..f7528bb
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+/**
+ * This event is fired as soon as an entry was edited.
+ */
+class EntryEditedEvent extends EntryEvent
+{
+    const NAME = 'entry.edited';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryEvent.php
new file mode 100644 (file)
index 0000000..0e0c90d
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\CoreBundle\Entity\Entry;
+
+/**
+ * This event is fired when entry-related stuff is made.
+ */
+abstract class EntryEvent extends Event
+{
+    protected $entry;
+
+    /**
+     * EntryEvent constructor.
+     * @param Entry $entry
+     */
+    public function __construct(Entry $entry)
+    {
+        $this->entry = $entry;
+    }
+
+    /**
+     * @return Entry
+     */
+    public function getEntry()
+    {
+        return $this->entry;
+    }
+
+    /**
+     * @param Entry $entry
+     * @return EntryEvent
+     */
+    public function setEntry(Entry $entry)
+    {
+        $this->entry = $entry;
+        return $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryFavouriteEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryFavouriteEvent.php
new file mode 100644 (file)
index 0000000..98edb00
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\CoreBundle\Entity\Entry;
+
+/**
+ * This event is fired as soon as an entry was favourited.
+ */
+class EntryFavouriteEvent extends EntryEvent
+{
+    const NAME = 'entry.favourite';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryReadEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryReadEvent.php
new file mode 100644 (file)
index 0000000..be6e6b4
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\CoreBundle\Entity\Entry;
+
+/**
+ * This event is fired as soon as an entry was favourited.
+ */
+class EntryReadEvent extends EntryEvent
+{
+    const NAME = 'entry.read';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntrySavedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntrySavedEvent.php
new file mode 100644 (file)
index 0000000..20c623c
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+/**
+ * This event is fired as soon as an entry was saved.
+ */
+class EntrySavedEvent extends EntryEvent
+{
+    const NAME = 'entry.saved';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryTaggedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Entry/EntryTaggedEvent.php
new file mode 100644 (file)
index 0000000..1ea8a7f
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Entry;
+
+use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\CoreBundle\Entity\Tag;
+
+/**
+ * This event is fired as soon as a tag is added on an entry.
+ */
+class EntryTaggedEvent extends EntryEvent
+{
+    const NAME = 'entry.tagged';
+
+    /** @var Tag[] */
+    protected $tags;
+
+    /**
+     * @var boolean
+     */
+    protected $remove;
+
+    /**
+     * EntryTaggedEvent constructor.
+     * @param Entry $entry
+     * @param $tags
+     * @param bool $remove
+     */
+    public function __construct(Entry $entry, $tags, $remove = false)
+    {
+        parent::__construct($entry);
+
+        if (false === is_array($tags)) {
+            $tags = [$tags];
+        }
+
+        $this->tags = $tags;
+    }
+
+    /**
+     * @return Tag[]
+     */
+    public function getTags()
+    {
+        return $this->tags;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isRemove()
+    {
+        return $this->remove;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FederationEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FederationEvent.php
new file mode 100644 (file)
index 0000000..da18330
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Federation;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\FederationBundle\Entity\Account;
+
+abstract class FederationEvent extends Event
+{
+    protected $account;
+
+    /**
+     * FederationEvent constructor.
+     * @param Account $account
+     */
+    public function __construct(Account $account)
+    {
+        $this->account = $account;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getAccount()
+    {
+        return $this->account;
+    }
+
+    /**
+     * @param Account $account
+     * @return FederationEvent
+     */
+    public function setAccount(Account $account)
+    {
+        $this->account = $account;
+        return $this;
+    }
+
+
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FollowEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/FollowEvent.php
new file mode 100644 (file)
index 0000000..4004932
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Federation;
+
+use Wallabag\FederationBundle\Entity\Account;
+
+/**
+ * This event is fired as soon as an account was followed.
+ */
+class FollowEvent extends FederationEvent
+{
+    const NAME = 'federation.follow';
+
+    protected $follower;
+
+    public function __construct(Account $accountFollowed, Account $follower)
+    {
+        parent::__construct($accountFollowed);
+        $this->follower = $follower;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getFollower()
+    {
+        return $this->follower;
+    }
+
+    /**
+     * @param Account $follower
+     * @return FollowEvent
+     */
+    public function setFollower(Account $follower)
+    {
+        $this->follower = $follower;
+        return  $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/RecommendedEntryEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/RecommendedEntryEvent.php
new file mode 100644 (file)
index 0000000..998b56d
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Federation;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\CoreBundle\Entity\Entry;
+
+/**
+ * This event is fired as soon as an entry was recommended.
+ */
+class RecommendedEntryEvent extends Event
+{
+    const NAME = 'federation.recommend';
+
+    protected $entry;
+
+    /**
+     * FederationEvent constructor.
+     * @param Entry $entry
+     */
+    public function __construct(Entry $entry)
+    {
+        $this->entry = $entry;
+    }
+
+    /**
+     * @return Entry
+     */
+    public function getEntry()
+    {
+        return $this->entry;
+    }
+
+    /**
+     * @param Entry $entry
+     * @return RecommendedEntryEvent
+     */
+    public function setEntry(Entry $entry)
+    {
+        $this->entry = $entry;
+        return $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/UnfollowEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Federation/UnfollowEvent.php
new file mode 100644 (file)
index 0000000..bf9a35f
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Federation;
+
+use Wallabag\FederationBundle\Entity\Account;
+
+/**
+ * This event is fired as soon as an account is being unfollowed
+ */
+class UnfollowEvent extends FederationEvent
+{
+    const NAME = 'federation.unfollow';
+
+    protected $follower;
+
+    public function __construct(Account $accountFollowed, Account $follower)
+    {
+        parent::__construct($accountFollowed);
+        $this->follower = $follower;
+    }
+
+    /**
+     * @return Account
+     */
+    public function getFollower()
+    {
+        return $this->follower;
+    }
+
+    /**
+     * @param Account $follower
+     * @return UnfollowEvent
+     */
+    public function setFollower(Account $follower)
+    {
+        $this->follower = $follower;
+        return $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareAcceptedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareAcceptedEvent.php
new file mode 100644 (file)
index 0000000..e171ef0
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Share;
+
+/**
+ * This event is fired as soon as an share is accepted
+ */
+class ShareAcceptedEvent extends ShareEvent
+{
+    const NAME = 'share.accepted';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCancelledEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCancelledEvent.php
new file mode 100644 (file)
index 0000000..26bee89
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Share;
+
+/**
+ * This event is fired as soon as an share is cancelled
+ */
+class ShareCancelledEvent extends ShareEvent
+{
+    const NAME = 'share.cancelled';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCreatedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareCreatedEvent.php
new file mode 100644 (file)
index 0000000..c2cb72d
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Share;
+
+/**
+ * This event is fired as soon as a share is created.
+ */
+class ShareCreatedEvent extends ShareEvent
+{
+    const NAME = 'share.created';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareDeniedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareDeniedEvent.php
new file mode 100644 (file)
index 0000000..fcdfd1c
--- /dev/null
@@ -0,0 +1,11 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Share;
+
+/**
+ * This event is fired as soon as an share is denied
+ */
+class ShareDeniedEvent extends ShareEvent
+{
+    const NAME = 'share.denied';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/Share/ShareEvent.php
new file mode 100644 (file)
index 0000000..0022a39
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\Share;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\CoreBundle\Entity\Share;
+
+/**
+ * This event is fired when share-related stuff is made.
+ */
+abstract class ShareEvent extends Event
+{
+    protected $share;
+
+    /**
+     * ShareEvent constructor.
+     * @param Share $share
+     */
+    public function __construct(Share $share)
+    {
+        $this->share = $share;
+    }
+
+    /**
+     * @return Share
+     */
+    public function getShare()
+    {
+        return $this->share;
+    }
+
+    /**
+     * @param Share $share
+     */
+    public function setShare(Share $share)
+    {
+        $this->share = $share;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserDeletedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserDeletedEvent.php
new file mode 100644 (file)
index 0000000..df06db8
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\User;
+
+class UserDeletedEvent extends UserEvent
+{
+    const NAME = 'user.deleted';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEditedEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEditedEvent.php
new file mode 100644 (file)
index 0000000..27f8f2d
--- /dev/null
@@ -0,0 +1,8 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\User;
+
+class UserEditedEvent extends UserEvent
+{
+    const NAME = 'user.edited';
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEvent.php b/src/Wallabag/CoreBundle/Event/Activity/Actions/User/UserEvent.php
new file mode 100644 (file)
index 0000000..e3807ab
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity\Actions\User;
+
+use Symfony\Component\EventDispatcher\Event;
+use Wallabag\UserBundle\Entity\User;
+
+/**
+ * This event is fired when user-related stuff is made.
+ */
+abstract class UserEvent extends Event
+{
+    protected $user;
+
+    /**
+     * UserEvent constructor.
+     * @param User $user
+     */
+    public function __construct(User $user)
+    {
+        $this->user = $user;
+    }
+
+    /**
+     * @return User
+     */
+    public function getUser()
+    {
+        return $this->user;
+    }
+
+    /**
+     * @param User $user
+     * @return UserEvent
+     */
+    public function setUser(User $user)
+    {
+        $this->user = $user;
+        return $this;
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/Activity/ActivitySubscriber.php b/src/Wallabag/CoreBundle/Event/Activity/ActivitySubscriber.php
new file mode 100644 (file)
index 0000000..81379ff
--- /dev/null
@@ -0,0 +1,224 @@
+<?php
+
+namespace Wallabag\CoreBundle\Event\Activity;
+
+use Doctrine\ORM\EntityManager;
+use FOS\UserBundle\Event\UserEvent;
+use FOS\UserBundle\FOSUserEvents;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\EventDispatcher\Event;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Wallabag\CoreBundle\Entity\Activity;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationCreatedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationEditedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Annotation\AnnotationEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryEditedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryFavouriteEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryReadEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntrySavedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryTaggedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Federation\FollowEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Federation\RecommendedEntryEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Federation\UnfollowEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareAcceptedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareCancelledEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareCreatedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareDeniedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Share\ShareEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\User\UserDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\User\UserEditedEvent;
+use Wallabag\CoreBundle\Notifications\ActionInterface;
+
+/**
+ * This listener will create the associated configuration when a user register.
+ * This configuration will be created right after the registration (no matter if it needs an email validation).
+ */
+class ActivitySubscriber implements EventSubscriberInterface
+{
+
+    /**
+     * @var EntityManager
+     */
+    private $em;
+
+    /**
+     * @var LoggerInterface $logger
+     */
+    private $logger;
+
+    public function __construct(EntityManager $em, LoggerInterface $logger)
+    {
+        $this->em = $em;
+        $this->logger = $logger;
+    }
+
+    public static function getSubscribedEvents()
+    {
+        return [
+            EntrySavedEvent::NAME => 'entryActivity',
+            EntryDeletedEvent::NAME => 'entryActivity',
+            EntryEditedEvent::NAME => 'entryActivity',
+            EntryTaggedEvent::NAME => 'taggedEntry',
+            EntryFavouriteEvent::NAME => 'entryActivity',
+            EntryReadEvent::NAME => 'entryActivity',
+
+            AnnotationCreatedEvent::NAME => 'annotationActivity',
+            AnnotationEditedEvent::NAME => 'annotationActivity',
+            AnnotationDeletedEvent::NAME => 'annotationActivity',
+
+            FollowEvent::NAME => 'followedAccount',
+            UnfollowEvent::NAME => 'unfollowedAccount',
+            RecommendedEntryEvent::NAME => 'recommendedEntry',
+
+            ShareCreatedEvent::NAME => 'shareActivity',
+            ShareAcceptedEvent::NAME => 'shareActivity',
+            ShareDeniedEvent::NAME => 'shareActivity',
+            ShareCancelledEvent::NAME => 'shareActivity',
+
+            // when a user register using the normal form
+            FOSUserEvents::REGISTRATION_COMPLETED => 'userActivity',
+            // when we manually create a user using the command line
+            // OR when we create it from the config UI
+            FOSUserEvents::USER_CREATED => 'userActivity',
+            UserEditedEvent::NAME => 'userActivity',
+            UserDeletedEvent::NAME => 'userActivity',
+        ];
+    }
+
+    public function userActivity(Event $event)
+    {
+        $activityType = 0;
+        if ($event instanceof UserEvent) {
+            $activityType = Activity::USER_CREATE;
+        } elseif ($event instanceof UserEditedEvent) {
+            $activityType = Activity::USER_EDIT;
+        } elseif ($event instanceof UserDeletedEvent) {
+            $activityType = Activity::USER_REMOVE;
+        }
+
+        $user = $event->getUser();
+        $activity = new Activity($activityType, Activity::USER_OBJECT, $user->getId());
+        $activity->setUser($user->getAccount());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function entryActivity(EntryEvent $event)
+    {
+        $entry = $event->getEntry();
+
+        $activityType = 0;
+        if ($event instanceof EntrySavedEvent) {
+            $activityType = Activity::ENTRY_ADD;
+        } elseif ($event instanceof EntryDeletedEvent) {
+            $activityType = Activity::ENTRY_DELETE;
+        } elseif ($event instanceof EntryEditedEvent) {
+            $activityType = Activity::ENTRY_EDIT;
+        } elseif ($event instanceof EntryFavouriteEvent) {
+            if ($entry->isStarred()) {
+                $activityType = Activity::ENTRY_FAVOURITE;
+            } else {
+                $activityType = Activity::ENTRY_UNFAVOURITE;
+            }
+        } elseif ($event instanceof EntryReadEvent) {
+            if ($entry->isArchived()) {
+                $activityType = Activity::ENTRY_READ;
+            } else {
+                $activityType = Activity::ENTRY_UNREAD;
+            }
+        }
+
+        $activity = new Activity($activityType, Activity::ENTRY_OBJECT, $entry->getId());
+        $activity->setUser($entry->getUser()->getAccount());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function taggedEntry(EntryTaggedEvent $event)
+    {
+        $entry = $event->getEntry();
+        $activity = new Activity($event->isRemove() ? Activity::ENTRY_REMOVE_TAG : Activity::ENTRY_ADD_TAG, Activity::ENTRY_OBJECT, $entry->getId());
+        $activity->setUser($entry->getUser()->getAccount());
+        $activity->setSecondaryObjectType(Activity::TAG_OBJECT)
+            ->setSecondaryObjectId($event->getTags()[0]->getId());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function annotationActivity(AnnotationEvent $event)
+    {
+        $annotation = $event->getAnnotation();
+
+        $activityType = 0;
+        if ($event instanceof AnnotationCreatedEvent) {
+            $activityType = Activity::ANNOTATION_ADD;
+        } elseif ($event instanceof AnnotationEditedEvent) {
+            $activityType = Activity::ANNOTATION_EDIT;
+        } elseif ($event instanceof AnnotationDeletedEvent) {
+            $activityType = Activity::ANNOTATION_REMOVE;
+        }
+
+        $activity = new Activity($activityType, Activity::ANNOTATION_OBJECT, $annotation->getId());
+        $activity->setUser($annotation->getUser()->getAccount());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function followedAccount(FollowEvent $event)
+    {
+        $activity = new Activity(Activity::FOLLOW_ACCOUNT, Activity::ACCOUNT_OBJECT, $event->getAccount()->getId());
+        $activity->setUser($event->getAccount());
+        $activity->setSecondaryObjectType(Activity::ACCOUNT_OBJECT)
+            ->setSecondaryObjectId($event->getFollower()->getId());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function unfollowedAccount(UnfollowEvent $event)
+    {
+        $activity = new Activity(Activity::UNFOLLOW_ACCOUNT, Activity::ACCOUNT_OBJECT, $event->getAccount()->getId());
+        $activity->setUser($event->getAccount());
+        $activity->setSecondaryObjectType(Activity::ACCOUNT_OBJECT)
+            ->setSecondaryObjectId($event->getFollower()->getId());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function recommendedEntry(RecommendedEntryEvent $event)
+    {
+        $entry = $event->getEntry();
+        $account = $entry->getUser()->getAccount();
+        $activity = new Activity(Activity::RECOMMEND_ENTRY, Activity::ACCOUNT_OBJECT, $account->getId());
+        $activity->setUser($account);
+        $activity->setSecondaryObjectType(Activity::ENTRY_OBJECT)
+            ->setSecondaryObjectId($entry->getId());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+
+    public function shareActivity(ShareEvent $event)
+    {
+        $share = $event->getShare();
+
+        $activityType = 0;
+        if ($event instanceof ShareCreatedEvent) {
+            $activityType = Activity::USER_SHARE_CREATED;
+        } elseif ($event instanceof ShareAcceptedEvent) {
+            $activityType = Activity::USER_SHARE_ACCEPTED;
+        } elseif ($event instanceof ShareDeniedEvent) {
+            $activityType = Activity::USER_SHARE_REFUSED;
+        } elseif ($event instanceof ShareCancelledEvent) {
+            $activityType = Activity::USER_SHARE_CANCELLED;
+        }
+
+        $activity = new Activity($activityType, Activity::SHARE_OBJECT, $share->getId());
+        $activity->setUser($share->getUserOrigin());
+        $activity->setSecondaryObjectType(Activity::ACCOUNT_OBJECT)
+            ->setSecondaryObjectId($share->getUserDestination()->getId());
+        $this->em->persist($activity);
+        $this->em->flush();
+    }
+}
diff --git a/src/Wallabag/CoreBundle/Event/EntryDeletedEvent.php b/src/Wallabag/CoreBundle/Event/EntryDeletedEvent.php
deleted file mode 100644 (file)
index e9061d0..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-namespace Wallabag\CoreBundle\Event;
-
-use Symfony\Component\EventDispatcher\Event;
-use Wallabag\CoreBundle\Entity\Entry;
-
-/**
- * This event is fired as soon as an entry is deleted.
- */
-class EntryDeletedEvent extends Event
-{
-    const NAME = 'entry.deleted';
-
-    protected $entry;
-
-    public function __construct(Entry $entry)
-    {
-        $this->entry = $entry;
-    }
-
-    public function getEntry()
-    {
-        return $this->entry;
-    }
-}
diff --git a/src/Wallabag/CoreBundle/Event/EntrySavedEvent.php b/src/Wallabag/CoreBundle/Event/EntrySavedEvent.php
deleted file mode 100644 (file)
index 5fdb522..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-namespace Wallabag\CoreBundle\Event;
-
-use Symfony\Component\EventDispatcher\Event;
-use Wallabag\CoreBundle\Entity\Entry;
-
-/**
- * This event is fired as soon as an entry was saved.
- */
-class EntrySavedEvent extends Event
-{
-    const NAME = 'entry.saved';
-
-    protected $entry;
-
-    public function __construct(Entry $entry)
-    {
-        $this->entry = $entry;
-    }
-
-    public function getEntry()
-    {
-        return $this->entry;
-    }
-}
index 4ebe837b69ec56fd5ba4de21e0a2054e79647650..5c3550c2d59e364fedcd9c3ebcf3706e5d715717 100644 (file)
@@ -4,10 +4,10 @@ namespace Wallabag\CoreBundle\Event\Subscriber;
 
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Psr\Log\LoggerInterface;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntryDeletedEvent;
+use Wallabag\CoreBundle\Event\Activity\Actions\Entry\EntrySavedEvent;
 use Wallabag\CoreBundle\Helper\DownloadImages;
 use Wallabag\CoreBundle\Entity\Entry;
-use Wallabag\CoreBundle\Event\EntrySavedEvent;
-use Wallabag\CoreBundle\Event\EntryDeletedEvent;
 use Doctrine\ORM\EntityManager;
 
 class DownloadImagesSubscriber implements EventSubscriberInterface
diff --git a/src/Wallabag/CoreBundle/Repository/ChangeRepository.php b/src/Wallabag/CoreBundle/Repository/ChangeRepository.php
new file mode 100644 (file)
index 0000000..18d015a
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace Wallabag\CoreBundle\Repository;
+
+use Doctrine\ORM\EntityRepository;
+
+class ChangeRepository extends EntityRepository
+{
+    /**
+     * Used only in test case to get a tag for our entry.
+     *
+     * @param int $timestamp
+     *
+     * @return Tag
+     */
+    public function findChangesSinceDate($timestamp)
+    {
+        $date = new \DateTime();
+        $date->setTimestamp($timestamp);
+
+        return $this->createQueryBuilder('c')
+            ->where('c.createdAt >= :timestamp')->setParameter('timestamp', $date)
+            ->getQuery()
+            ->getResult();
+    }
+}
index 9bda4e15e81fcfd8d7e33635607fefa7db01c723..4bbd05ff1b8a23ec753c895751208dde684c6d4d 100644 (file)
@@ -4,6 +4,7 @@ namespace Wallabag\CoreBundle\Repository;
 
 use Doctrine\ORM\EntityRepository;
 use Doctrine\ORM\Query;
+use Doctrine\ORM\QueryBuilder;
 use Pagerfanta\Adapter\DoctrineORMAdapter;
 use Pagerfanta\Pagerfanta;
 use Wallabag\CoreBundle\Entity\Tag;
@@ -89,7 +90,7 @@ class EntryRepository extends EntityRepository
      *
      * @param int    $userId
      * @param string $term
-     * @param strint $currentRoute
+     * @param string $currentRoute
      *
      * @return QueryBuilder
      */
@@ -414,4 +415,15 @@ class EntryRepository extends EntityRepository
             ->getQuery()
             ->getResult();
     }
+
+    /**
+     * @param $userId
+     * @return QueryBuilder
+     */
+    public function getBuilderForRecommendationsByUser($userId)
+    {
+        return $this->getBuilderByUser($userId)
+            ->andWhere('e.recommended = true')
+            ;
+    }
 }
index 183b6690c271bfbd223cadb447ae88cd52a4b6b2..6864993c5f9761d0e173f39280849afe3ddb33c8 100644 (file)
@@ -222,3 +222,11 @@ services:
         arguments:
             - "%wallabag_core.site_credentials.encryption_key_path%"
             - "@logger"
+
+    wallabag_core.activity.subscriber:
+        class: Wallabag\CoreBundle\Event\Activity\ActivitySubscriber
+        arguments:
+            - "@doctrine.orm.default_entity_manager"
+            - "@logger"
+        tags:
+            - { name: kernel.event_subscriber }
index 98f1b48ce28772141d293ae3a0df2ae8aadd82dd..4f19556f8f0df1f09cde61e912e1b844ced56ef0 100644 (file)
@@ -20,7 +20,7 @@ menu:
         starred: 'Starred'
         archive: 'Archive'
         all_articles: 'All entries'
-        config: 'Config'
+        config: 'config'
         tags: 'Tags'
         internal_settings: 'Internal Settings'
         import: 'Import'
@@ -51,7 +51,7 @@ footer:
     stats: Since %user_creation% you read %nb_archives% articles. That is about %per_day% a day!
 
 config:
-    page_title: 'Config'
+    page_title: 'config'
     tab_menu:
         settings: 'Settings'
         rss: 'RSS'
@@ -108,6 +108,11 @@ config:
             description: If you remove your account, ALL your articles, ALL your tags, ALL your annotations and your account will be PERMANENTLY removed (it can't be UNDONE). You'll then be logged out.
             confirm: Are you really sure? (THIS CAN'T BE UNDONE)
             button: Delete my account
+    form_account:
+        title: "Profile"
+        description_label: "Description de votre compte. Ce texte sera affiché sur votre page de profil."
+        avatar_label: "Avatar"
+        banner_label: "Bannière"
     reset:
         title: Reset area (a.k.a danger zone)
         description: By hitting buttons below you'll have ability to remove some information from your account. Be aware that these actions are IRREVERSIBLE.
@@ -562,7 +567,7 @@ error:
 flashes:
     config:
         notice:
-            config_saved: 'Config saved.'
+            config_saved: 'config saved.'
             password_updated: 'Password updated'
             password_not_updated_demo: "In demonstration mode, you can't change password for this user."
             user_updated: 'Information updated'
index 277f2f63cd3fc3badf6b7882edaf05567cfc4f66..2c53c12b25a14964d08236d6e837a1aa6cea877c 100644 (file)
@@ -108,6 +108,11 @@ config:
             description: "Si vous confirmez la suppression de votre compte, TOUS les articles, TOUS les tags, TOUTES les annotations et votre compte seront DÉFINITIVEMENT supprimé (c’est IRRÉVERSIBLE). Vous serez ensuite déconnecté."
             confirm: "Vous êtes vraiment sûr ? (C’EST IRRÉVERSIBLE)"
             button: "Supprimer mon compte"
+    form_account:
+        title: "Profil"
+        description_label: "Description de votre compte. Ce texte sera affiché sur votre page de profil."
+        avatar_label: "Avatar"
+        banner_label: "Bannière"
     reset:
         title: "Réinitialisation (attention danger !)"
         description: "En cliquant sur les boutons ci-dessous vous avez la possibilité de supprimer certaines informations de votre compte. Attention, ces actions sont IRRÉVERSIBLES !"
index 2d71cb9d63e3404859e1f9900775b5873f9d40d9..b77bfd7ff8f8848ae866c52e1d59ce74b2e9e1bb 100644 (file)
@@ -51,7 +51,7 @@ footer:
     stats: 'Desde %user_creation% você leu %nb_archives% artigos. Isso é %per_day% por dia!'
 
 config:
-    page_title: 'Config'
+    page_title: 'config'
     tab_menu:
         settings: 'Configurações'
         rss: 'RSS'
index bd5932b0c3811be98d972f73e8bb09ce33e8e331..c2beb35aa9e5c62a5c4a1074a6613ea4677a95ef 100644 (file)
                             {{ form_widget(form.user._token) }}
                         </form>
 
+                        <br /><hr /><br />
+                        <h5>{{ 'config.form_account.title'|trans }}</h5>
+
+                        {{ form_start(form.account) }}
+                        {{ form_errors(form.account) }}
+
+                        <div class="row">
+                            <div class="input-field col s12">
+                                {{ form_errors(form.account.description) }}
+                                {{ form_widget(form.account.description, {'attr': {'class': 'materialize-textarea'}}) }}
+                                {{ form_label(form.account.description) }}
+                            </div>
+                        </div>
+
+                        <div class="row">
+                            <div class="file-field input-field col s12">
+                                {{ form_errors(form.account.avatar) }}
+                                <div class="btn">
+                                    <span>{{ 'config.form_account.avatar_label'|trans }}</span>
+                                    {{ form_widget(form.account.avatar) }}
+                                </div>
+                                <div class="file-path-wrapper">
+                                    <input class="file-path validate" type="text">
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="row">
+                            <div class="file-field input-field col s12">
+                                {{ form_errors(form.account.banner) }}
+                                <div class="btn">
+                                    <span>{{ 'config.form_account.banner_label'|trans }}</span>
+                                    {{ form_widget(form.account.banner) }}
+                                </div>
+                                <div class="file-path-wrapper">
+                                    <input class="file-path validate" type="text">
+                                </div>
+                            </div>
+                        </div>
+
+                        {{ form_widget(form.account.save, {'attr': {'class': 'btn waves-effect waves-light'}}) }}
+                        <a href="{{ path('user-profile', {'user': app.user.account.username}) }}" class="btn">{{ 'config.form_account.view_profile' | trans }}</a>
+                        {{ form_widget(form.account._token) }}
+                        </form>
+
                         <br /><hr /><br />
 
                         <div class="row">
index ccc449312740b96fecd8051d77d130aa04620807..f93b635f9b7c53d72a2736f089134d19fcdc88ac 100644 (file)
                         {% if unreadNotifs > 0 %}<span id="notifications-count" class="red-text text-accent-2">{{ unreadNotifs }}</span>{% endif %}
                     </a>
                 </li>
+                <li id="button_profile">
+                    <a class="nav-panel-menu button-collapse-right tooltipped" data-position="bottom" data-delay="50" data-tooltip="{{ 'menu.top.profile' | trans }}" href="{{ path('user-profile', {'user': app.user.account.username}) }}">
+                        <i class="material-icons">person</i>
+                    </a>
+                </li>
                 <li id="button_filters">
                     <a class="nav-panel-menu button-collapse-right tooltipped js-filters-action" data-position="bottom" data-delay="50" data-tooltip="{{ 'menu.top.filter_entries'|trans }}" href="#" data-activates="filters">
                         <i class="material-icons">filter_list</i>
diff --git a/src/Wallabag/FederationBundle/Command/CreateAccountsCommand.php b/src/Wallabag/FederationBundle/Command/CreateAccountsCommand.php
new file mode 100644 (file)
index 0000000..f2ca3b0
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+
+namespace Wallabag\FederationBundle\Command;
+
+use Doctrine\ORM\NoResultException;
+use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Wallabag\FederationBundle\Entity\Account;
+use Wallabag\FederationBundle\Entity\Instance;
+use Wallabag\UserBundle\Entity\User;
+
+class CreateAccountsCommand extends ContainerAwareCommand
+{
+    /** @var OutputInterface */
+    protected $output;
+
+    protected $created = 0;
+
+    protected function configure()
+    {
+        $this
+            ->setName('wallabag:federation:create-accounts')
+            ->setDescription('Creates missing federation accounts')
+            ->setHelp('This command creates accounts for federation for missing users')
+            ->addArgument(
+                'username',
+                InputArgument::OPTIONAL,
+                'User to create an account for'
+            );
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $this->output = $output;
+
+        $domainName = $this->getContainer()->getParameter('domain_name');
+        $instance = $this->checkInstance($domainName);
+
+        $username = $input->getArgument('username');
+
+        if ($username) {
+            try {
+                $user = $this->getUser($username);
+                $this->createAccount($user, $instance);
+            } catch (NoResultException $e) {
+                $output->writeln(sprintf('<error>User "%s" not found.</error>', $username));
+
+                return 1;
+            }
+        } else {
+            $users = $this->getDoctrine()->getRepository('WallabagUserBundle:User')->findAll();
+
+            $output->writeln(sprintf('Creating through %d user federated accounts', count($users)));
+
+            foreach ($users as $user) {
+                $output->writeln(sprintf('Processing user %s', $user->getUsername()));
+                $this->createAccount($user, $instance);
+            }
+            $output->writeln(sprintf('Creating user federated accounts. %d accounts created in total', $this->created));
+        }
+
+        return 0;
+    }
+
+    /**
+     * @param User $user
+     * @param $instance
+     */
+    private function createAccount(User $user, Instance $instance)
+    {
+        $em = $this->getContainer()->get('doctrine.orm.entity_manager');
+        $repo = $em->getRepository('WallabagFederationBundle:Account');
+
+        if ($repo->findBy(['user' => $user->getId()])) {
+            return;
+        }
+
+        $account = new Account();
+        $account->setUsername($user->getUsername())
+            ->setUser($user)
+            ->setServer($instance);
+
+        $em->persist($account);
+        $em->flush();
+
+        $user->setAccount($account);
+        $em->persist($account);
+        $em->flush();
+
+        ++$this->created;
+    }
+
+    private function checkInstance($domainName)
+    {
+        $em = $this->getContainer()->get('doctrine.orm.entity_manager');
+        $repo = $em->getRepository('WallabagFederationBundle:Instance');
+
+        $instance = $repo->findOneByDomain($domainName);
+        if (!$instance) {
+            $instance = new Instance($domainName);
+
+            $em->persist($instance);
+            $em->flush();
+        }
+        return $instance;
+    }
+
+    /**
+     * Fetches a user from its username.
+     *
+     * @param string $username
+     *
+     * @return \Wallabag\UserBundle\Entity\User
+     */
+    private function getUser($username)
+    {
+        return $this->getDoctrine()->getRepository('WallabagUserBundle:User')->findOneByUserName($username);
+    }
+
+    private function getDoctrine()
+    {
+        return $this->getContainer()->get('doctrine');
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Controller/InboxController.php b/src/Wallabag/FederationBundle/Controller/InboxController.php
new file mode 100644 (file)
index 0000000..99cfead
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Wallabag\FederationBundle\Entity\Account;
+use Wallabag\FederationBundle\Entity\Instance;
+use Wallabag\FederationBundle\Federation\CloudId;
+
+class InboxController extends Controller
+{
+    /**
+     * @Route("/profile/inbox", name="user-inbox")
+     *
+     * @param Request $request
+     * @return Response
+     */
+    public function userInboxAction(Request $request)
+    {
+        $em = $this->getDoctrine()->getManager();
+        $response = new Response();
+
+        if ($activity = json_decode($request->getContent())) {
+            if ($activity->type === 'Follow' && isset($activity->actor->id)) {
+                $cloudId = new CloudId($activity->actor->id);
+                $account = new Account();
+                $account->setServer($cloudId->getRemote())
+                    ->setUsername($cloudId->getUser());
+                $em->persist($account);
+                $em->flush();
+
+                $response->setStatusCode(201);
+            } else {
+                $response->setStatusCode(400);
+            }
+        }
+        return $response;
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Controller/LikedController.php b/src/Wallabag/FederationBundle/Controller/LikedController.php
new file mode 100644 (file)
index 0000000..3e86d50
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Wallabag\CoreBundle\Entity\Entry;
+
+class LikedController extends Controller
+{
+    /**
+     * @Route("/like/{entry}", name="like")
+     * @param Entry $entry
+     */
+    public function likeAction(Entry $entry)
+    {
+        
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Controller/MetadataController.php b/src/Wallabag/FederationBundle/Controller/MetadataController.php
new file mode 100644 (file)
index 0000000..90d3eb4
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Wallabag\FederationBundle\Federation\CloudId;
+
+class MetadataController extends Controller
+{
+    /**
+     * @Route("/.well-known/host-meta", name="webfinger-host-meta")
+     *
+     * @return Response
+     */
+    public function getHostMeta()
+    {
+        $response = new Response();
+        $response->setContent('<XRD xmlns=\"http://docs.oasis-open.org/ns/xri/xrd-1.0\">
+<Link rel=\"lrdd\" type=\"application/xrd+xml\" template=\"' . $this->generateUrl('webfinger') . '?resource={uri}\"/>
+</XRD>');
+        $response->headers->set('Content-Type', 'application/xrd+xml');
+        return $response;
+    }
+
+    /**
+     * @Route("/.well-known/webfinger", name="webfinger")
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function getWebfingerData(Request $request)
+    {
+        $subject = $request->query->get('resource');
+
+        if (!$subject || strlen($subject) < 12 || 0 !== strpos($subject, 'acct:') || false !== strpos($subject, '@')) {
+            return new JsonResponse([]);
+        }
+        $subjectId = substr($subject, 5);
+
+        $cloudId = $this->getCloudId($subjectId);
+
+
+        $data = [
+            'subject' => $subject,
+            'aliases' => [$this->generateUrl('profile', $cloudId->getUser())],
+            'links' => [
+                [
+                    'rel' => 'http://webfinger.net/rel/profile-page',
+                    'type' => 'text/html',
+                    'href' => $this->generateUrl('profile', $cloudId->getUser())
+                ],
+            ]
+        ];
+        return new JsonResponse($data);
+    }
+
+    private function getCloudId($subjectId)
+    {
+        return new CloudId($subjectId);
+    }
+
+
+    #
+    # {"subject":"acct:tcit@social.tcit.fr","aliases":["https://social.tcit.fr/@tcit","https://social.tcit.fr/users/tcit"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://social.tcit.fr/@tcit"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://social.tcit.fr/users/tcit.atom"},{"rel":"self","type":"application/activity+json","href":"https://social.tcit.fr/@tcit"},{"rel":"salmon","href":"https://social.tcit.fr/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.pXwYMUdFg3XUd-bGsh8CyiMRGpRGAWuCdM5pDWx5uM4pW2pM3xbHbcI21j9h8BmlAiPg6hbZD73KGly2N8Rt5iIS0I-l6i8kA1JCCdlAaDTRd41RKMggZDoQvjVZQtsyE1VzMeU2kbqqTFN6ew7Hvbd6O0NhixoKoZ5f3jwuBDZoT0p1TAcaMdmG8oqHD97isizkDnRn8cOBA6wtI-xb5xP2zxZMsLpTDZLiKU8XcPKZCw4OfQfmDmKkHtrFb77jCAQj_s_FxjVnvxRwmfhNnWy0D-LUV_g63nHh_b5zXIeV92QZLvDYbgbezmzUzv9UeA1s70GGbaDqCIy85gw9-w==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://social.tcit.fr/authorize_follow?acct={uri}"}]}
+    #
+}
diff --git a/src/Wallabag/FederationBundle/Controller/OutboxController.php b/src/Wallabag/FederationBundle/Controller/OutboxController.php
new file mode 100644 (file)
index 0000000..87ebdba
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\Request;
+use Wallabag\FederationBundle\Entity\Account;
+use Wallabag\UserBundle\Entity\User;
+
+class OutboxController extends Controller
+{
+    /**
+     * @Route("/profile/outbox", name="user-outbox")
+     *
+     * @param Request $request
+     * @param Account $user
+     */
+    public function userOutboxAction(Request $request, Account $user)
+    {
+
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Controller/ProfileController.php b/src/Wallabag/FederationBundle/Controller/ProfileController.php
new file mode 100644 (file)
index 0000000..7e472e1
--- /dev/null
@@ -0,0 +1,425 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Pagerfanta\Adapter\DoctrineORMAdapter;
+use Pagerfanta\Pagerfanta;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
+use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\FederationBundle\Entity\Account;
+use Wallabag\FederationBundle\Federation\CloudId;
+
+class ProfileController extends Controller
+{
+    /**
+     * @Route("/profile/@{user}", name="user-profile")
+     * @ParamConverter("user", class="WallabagFederationBundle:Account", options={
+     *     "repository_method" = "findOneByUsername"})
+     *
+     * @param Request $request
+     * @param Account $user
+     * @return JsonResponse|Response
+     */
+    public function getUserProfile(Request $request, Account $user)
+    {
+        if (in_array('application/ld+json; profile="https://www.w3.org/ns/activitystreams', $request->getAcceptableContentTypes(), true)) {
+            $data = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Person',
+                'id' => CloudId::getCloudIdFromAccount($user, $this->generateUrl('homepage', [], UrlGeneratorInterface::ABSOLUTE_URL))->getDisplayId(),
+                'following' => $this->generateUrl('following', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'followers' => $this->generateUrl('followers', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                //'liked' => $this->generateUrl('recommended', ['user' => $user], UrlGeneratorInterface::ABSOLUTE_URL),
+                'inbox' => $this->generateUrl('user-inbox', ['user' => $user], UrlGeneratorInterface::ABSOLUTE_URL),
+                'outbox' => $this->generateUrl('user-outbox', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'preferredUsername' => $user->getUser()->getName(),
+                'name' => $user->getUsername(),
+                //'oauthAuthorizationEndpoint' => $this->generateUrl('fos_oauth_server_authorize', [], UrlGeneratorInterface::ABSOLUTE_URL),
+                'oauthTokenEndpoint' => $this->generateUrl('fos_oauth_server_token', [], UrlGeneratorInterface::ABSOLUTE_URL),
+                //'publicInbox' => $this->generateUrl('public_inbox', [], UrlGeneratorInterface::ABSOLUTE_URL),
+            ];
+            return new JsonResponse($data);
+        }
+        return $this->render(
+            'WallabagFederationBundle:User:profile.html.twig', [
+                'user' => $user,
+                'registration_enabled' => $this->getParameter('wallabag_user.registration_enabled'),
+            ]
+        );
+    }
+
+    /**
+     * @Route("/profile/@{user}/followings/{page}", name="following", defaults={"page" : 0})
+     * @ParamConverter("user", class="WallabagFederationBundle:Account", options={
+     *     "repository_method" = "findOneByUsername"})
+     *
+     * @param Request $request
+     * @param Account $user
+     * @param int $page
+     * @return JsonResponse|Response
+     */
+    public function getUsersFollowing(Request $request, Account $user, $page = 0)
+    {
+        $qb = $this->getDoctrine()->getRepository('WallabagFederationBundle:Account')->getBuilderForFollowingsByAccount($user->getId());
+
+        $pagerAdapter = new DoctrineORMAdapter($qb->getQuery(), true, false);
+
+        $following = new Pagerfanta($pagerAdapter);
+        $totalFollowing = $following->getNbResults();
+
+        $activityStream = in_array('application/ld+json; profile="https://www.w3.org/ns/activitystreams', $request->getAcceptableContentTypes(), true);
+
+        if ($page === 0 && $activityStream) {
+            /** Home page */
+            $dataPrez = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'summary' => $user->getUsername() . " followings'",
+                'type' => 'Collection',
+                'id' => $this->generateUrl('following', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'totalItems' => $totalFollowing,
+                'first' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'First page of ' . $user->getUsername() . ' followings'
+                ],
+                'last' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => $following->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'Last page of ' . $user->getUsername() . ' followings'
+                ]
+            ];
+            return new JsonResponse($dataPrez);
+            //}
+        }
+
+        $following->setMaxPerPage(30);
+        $following->setCurrentPage($page);
+
+        if (!$activityStream) {
+            return $this->render('WallabagFederationBundle:User:followers.html.twig', [
+                'users' => $following,
+                'user' => $user,
+                'registration_enabled' => $this->getParameter('wallabag_user.registration_enabled'),
+            ]);
+        }
+
+        $items = [];
+
+        foreach ($following->getCurrentPageResults() as $follow) {
+            /** @var Account $follow */
+            /** Items in the page */
+            $items[] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Person',
+                'name' => $follow->getUsername(),
+                'id' => CloudId::getCloudIdFromAccount($follow),
+            ];
+        }
+
+        $data = [
+            'summary' => 'Page ' . $page . ' of ' . $user->getUsername() . ' followers',
+            'partOf' => $this->generateUrl('following', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+            'type' => 'OrderedCollectionPage',
+            'startIndex' => ($page - 1) * 30,
+            'orderedItems' => $items,
+            'first' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'First page of ' . $user->getUsername() . ' followings'
+            ],
+            'last' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => $following->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Last page of ' . $user->getUsername() . ' followings'
+            ],
+        ];
+
+        /** Previous page */
+        if ($page > 1) {
+            $data['prev'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => $page - 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Previous page of ' . $user->getUsername() . ' followings'
+            ];
+        }
+
+        /** Next page */
+        if ($page < $following->getNbPages()) {
+            $data['next'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('following', ['user' => $user->getUsername(), 'page' => $page + 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Next page of ' . $user->getUsername() . ' followings'
+            ];
+        }
+
+        return new JsonResponse($data);
+    }
+
+    /**
+     * @Route("/profile/@{user}/followers/{page}", name="followers", defaults={"page" : 0})
+     * @ParamConverter("user", class="WallabagFederationBundle:Account", options={
+     *     "repository_method" = "findOneByUsername"})
+     *
+     * @param Request $request
+     * @param Account $user
+     * @return JsonResponse
+     */
+    public function getUsersFollowers(Request $request, Account $user, $page)
+    {
+        $qb = $this->getDoctrine()->getRepository('WallabagFederationBundle:Account')->getBuilderForFollowersByAccount($user->getId());
+
+        $pagerAdapter = new DoctrineORMAdapter($qb->getQuery(), true, false);
+
+        $followers = new Pagerfanta($pagerAdapter);
+        $totalFollowers = $followers->getNbResults();
+
+        $activityStream = in_array('application/ld+json; profile="https://www.w3.org/ns/activitystreams', $request->getAcceptableContentTypes(), true);
+
+        if ($page === 0  && $activityStream) {
+            /** Home page */
+            $dataPrez = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'summary' => $user->getUsername() . " followers'",
+                'type' => 'Collection',
+                'id' => $this->generateUrl('followers', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'totalItems' => $totalFollowers,
+                'first' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'First page of ' . $user->getUsername() . ' followers'
+                ],
+                'last' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => $followers->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'Last page of ' . $user->getUsername() . ' followers'
+                ]
+            ];
+            return new JsonResponse($dataPrez);
+        }
+
+        $followers->setMaxPerPage(30);
+        if (!$activityStream && $page === 0) {
+            $followers->setCurrentPage(1);
+        } else {
+            $followers->setCurrentPage($page);
+        }
+
+        if (!$activityStream) {
+            return $this->render('WallabagFederationBundle:User:followers.html.twig', [
+                'users' => $followers,
+                'user' => $user,
+                'registration_enabled' => $this->getParameter('wallabag_user.registration_enabled'),
+            ]);
+        }
+
+        $items = [];
+
+        foreach ($followers->getCurrentPageResults() as $follow) {
+            /** @var Account $follow */
+            /** Items in the page */
+            $items[] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Person',
+                'name' => $follow->getUsername(),
+                'id' => CloudId::getCloudIdFromAccount($follow)->getDisplayId(),
+            ];
+        }
+        $data = [
+            'summary' => 'Page ' . $page . ' of ' . $user->getUsername() . ' followers',
+            'partOf' => $this->generateUrl('followers', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+            'type' => 'OrderedCollectionPage',
+            'startIndex' => ($page - 1) * 30,
+            'orderedItems' => $items,
+            'first' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'First page of ' . $user->getUsername() . ' followers'
+            ],
+            'last' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => $followers->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Last page of ' . $user->getUsername() . ' followers'
+            ],
+        ];
+
+        /** Previous page */
+        if ($page > 1) {
+            $data['prev'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => $page - 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Previous page of ' . $user->getUsername() . ' followers'
+            ];
+        }
+
+        /** Next page */
+        if ($page < $followers->getNbPages()) {
+            $data['next'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('followers', ['user' => $user->getUsername(), 'page' => $page + 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Next page of ' . $user->getUsername() . ' followers'
+            ];
+        }
+
+        return new JsonResponse($data);
+    }
+
+    /**
+     * @Route("/profile/@{userToFollow}/follow", name="follow-user")
+     * @ParamConverter("userToFollow", class="WallabagFederationBundle:Account", options={
+     *     "repository_method" = "findOneByUsername"})
+     * @param Account $userToFollow
+     */
+    public function followAccountAction(Account $userToFollow)
+    {
+        // if we're on our own instance
+        if ($this->get('security.authorization_checker')->isGranted('IS_AUTHENTICATED_FULLY')) {
+
+            /** @var Account $userAccount */
+            $userAccount = $this->getUser()->getAccount();
+
+            if ($userToFollow === $userAccount) {
+                $this->createAccessDeniedException("You can't follow yourself");
+            }
+
+            $em = $this->getDoctrine()->getManager();
+
+            $userAccount->addFollowing($userToFollow);
+            $userToFollow->addFollower($userAccount);
+
+            $em->persist($userAccount);
+            $em->persist($userToFollow);
+
+            $em->flush();
+        } else {
+            // ask cloud id and redirect to instance
+        }
+    }
+
+    /**
+     * @Route("/profile/@{user}/recommendations", name="user-recommendations", defaults={"page" : 0})
+     * @ParamConverter("user", class="WallabagFederationBundle:Account", options={
+     *     "repository_method" = "findOneByUsername"})
+     *
+     * @param Request $request
+     * @param Account $user
+     * @param int $page
+     * @return JsonResponse|Response
+     */
+    public function getUsersRecommendationsAction(Request $request, Account $user, $page = 0)
+    {
+        $qb = $this->getDoctrine()->getRepository('WallabagCoreBundle:Entry')->getBuilderForRecommendationsByUser($user->getUser()->getId());
+
+        $pagerAdapter = new DoctrineORMAdapter($qb->getQuery(), true, false);
+
+        $recommendations = new Pagerfanta($pagerAdapter);
+        $totalRecommendations = $recommendations->getNbResults();
+
+        $activityStream = in_array('application/ld+json; profile="https://www.w3.org/ns/activitystreams', $request->getAcceptableContentTypes(), true);
+
+        if ($page === 0  && $activityStream) {
+            $dataPrez = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'summary' => $user->getUsername() . " recommendations'",
+                'type' => 'Collection',
+                'id' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'totalItems' => $totalRecommendations,
+                'first' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'First page of ' . $user->getUsername() . ' followers'
+                ],
+                'last' => [
+                    '@context' => 'https://www.w3.org/ns/activitystreams',
+                    'type' => 'Link',
+                    'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => $recommendations->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                    'name' => 'Last page of ' . $user->getUsername() . ' followers'
+                ]
+            ];
+            return new JsonResponse($dataPrez);
+        }
+
+        $recommendations->setMaxPerPage(30);
+        if (!$activityStream && $page === 0) {
+            $recommendations->setCurrentPage(1);
+        } else {
+            $recommendations->setCurrentPage($page);
+        }
+
+        if (!$activityStream) {
+            return $this->render('WallabagFederationBundle:User:recommendations.html.twig', [
+                'recommendations' => $recommendations,
+                'registration_enabled' => $this->getParameter('wallabag_user.registration_enabled'),
+            ]);
+        }
+
+        $items = [];
+
+        foreach ($recommendations->getCurrentPageResults() as $recommendation) {
+            $items[] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Person',
+                'name' => $recommendation->getTitle(),
+                'id' => $recommendation->getUrl(),
+            ];
+        }
+        $data = [
+            'summary' => 'Page ' . $page . ' of ' . $user->getUsername() . ' recommendations',
+            'partOf' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername()], UrlGeneratorInterface::ABSOLUTE_URL),
+            'type' => 'OrderedCollectionPage',
+            'startIndex' => ($page - 1) * 30,
+            'orderedItems' => $items,
+            'first' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'First page of ' . $user->getUsername() . ' recommendations'
+            ],
+            'last' => [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => $recommendations->getNbPages()], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Last page of ' . $user->getUsername() . ' recommendations'
+            ],
+        ];
+
+        if ($page > 1) {
+            $data['prev'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => $page - 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Previous page of ' . $user->getUsername() . ' recommendations'
+            ];
+        }
+
+        if ($page < $recommendations->getNbPages()) {
+            $data['next'] = [
+                '@context' => 'https://www.w3.org/ns/activitystreams',
+                'type' => 'Link',
+                'href' => $this->generateUrl('user-recommendations', ['user' => $user->getUsername(), 'page' => $page + 1], UrlGeneratorInterface::ABSOLUTE_URL),
+                'name' => 'Next page of ' . $user->getUsername() . ' recommendations'
+            ];
+        }
+
+        return new JsonResponse($data);
+    }
+
+}
diff --git a/src/Wallabag/FederationBundle/Controller/RecommandController.php b/src/Wallabag/FederationBundle/Controller/RecommandController.php
new file mode 100644 (file)
index 0000000..ea5a666
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+namespace Wallabag\FederationBundle\Controller;
+
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\CoreBundle\Event\Activity\Actions\Federation\RecommendedEntryEvent;
+use Wallabag\FederationBundle\Entity\Account;
+
+class RecommandController extends Controller
+{
+    /**
+     * @Route("/recommend/{entry}", name="recommend-entry")
+     *
+     * @param Entry $entry
+     */
+    public function postRecommendAction(Entry $entry)
+    {
+        if ($entry->getUser() !== $this->getUser()) {
+            $this->createAccessDeniedException("You can't recommend entries which are not your own");
+        }
+        $em = $this->getDoctrine()->getManager();
+
+        $entry->setRecommended(true);
+
+        $em->persist($entry);
+        $em->flush();
+
+        $this->get('event_dispatcher')->dispatch(RecommendedEntryEvent::NAME, new RecommendedEntryEvent($entry));
+
+        $this->redirectToRoute('view', ['id' => $entry->getId()]);
+    }
+}
diff --git a/src/Wallabag/FederationBundle/DependencyInjection/Configuration.php b/src/Wallabag/FederationBundle/DependencyInjection/Configuration.php
new file mode 100644 (file)
index 0000000..7754e8e
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace Wallabag\FederationBundle\DependencyInjection;
+
+use Symfony\Component\Config\Definition\Builder\TreeBuilder;
+use Symfony\Component\Config\Definition\ConfigurationInterface;
+
+class Configuration implements ConfigurationInterface
+{
+    public function getConfigTreeBuilder()
+    {
+        $treeBuilder = new TreeBuilder();
+        $rootNode = $treeBuilder->root('wallabag_user');
+
+        $rootNode
+            ->children()
+                ->booleanNode('registration_enabled')
+                    ->defaultValue(true)
+                ->end()
+            ->end()
+        ;
+
+        return $treeBuilder;
+    }
+}
diff --git a/src/Wallabag/FederationBundle/DependencyInjection/WallabagFederationExtension.php b/src/Wallabag/FederationBundle/DependencyInjection/WallabagFederationExtension.php
new file mode 100644 (file)
index 0000000..ceeb837
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace Wallabag\FederationBundle\DependencyInjection;
+
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\HttpKernel\DependencyInjection\Extension;
+use Symfony\Component\DependencyInjection\Loader;
+
+class WallabagFederationExtension extends Extension
+{
+    public function load(array $configs, ContainerBuilder $container)
+    {
+        $configuration = new Configuration();
+        $config = $this->processConfiguration($configuration, $configs);
+
+        $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
+        $loader->load('services.yml');
+        $container->setParameter('wallabag_user.registration_enabled', $config['registration_enabled']);
+    }
+
+    public function getAlias()
+    {
+        return 'wallabag_federation';
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Entity/Account.php b/src/Wallabag/FederationBundle/Entity/Account.php
new file mode 100644 (file)
index 0000000..c44050d
--- /dev/null
@@ -0,0 +1,307 @@
+<?php
+
+namespace Wallabag\FederationBundle\Entity;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Validator\Constraints as Assert;
+use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\UserBundle\Entity\User;
+
+/**
+ * Account.
+ *
+ * @ORM\Entity(repositoryClass="Wallabag\FederationBundle\Repository\AccountRepository")
+ * @UniqueEntity(fields={"username", "server"}).
+ * @ORM\Table(name="`account`")
+ */
+class Account
+{
+    /**
+     * @var int
+     *
+     * @ORM\Column(name="id", type="integer")
+     * @ORM\Id
+     * @ORM\GeneratedValue(strategy="AUTO")
+     *
+     */
+    private $id;
+
+    /**
+     * @var string
+     *
+     * @ORM\Column(name="username", type="string")
+     */
+    private $username;
+
+    /**
+     * @var Instance
+     *
+     * @ORM\ManyToOne(targetEntity="Wallabag\FederationBundle\Entity\Instance", inversedBy="users")
+     */
+    private $server;
+
+    /**
+     * @var User
+     *
+     * @ORM\OneToOne(targetEntity="Wallabag\UserBundle\Entity\User", inversedBy="account")
+     * @ORM\JoinColumn(nullable=true)
+     */
+    private $user;
+
+    /**
+     * @var ArrayCollection
+     *
+     * @ORM\ManyToMany(targetEntity="Wallabag\FederationBundle\Entity\Account", mappedBy="following")
+     */
+    private $followers;
+
+    /**
+     * @var ArrayCollection
+     *
+     * @ORM\ManyToMany(targetEntity="Wallabag\FederationBundle\Entity\Account", inversedBy="followers")
+     * @ORM\JoinTable(name="follow",
+     *      joinColumns={@ORM\JoinColumn(name="account_id", referencedColumnName="id")},
+     *      inverseJoinColumns={@ORM\JoinColumn(name="follow_account_id", referencedColumnName="id")}
+     *      )
+     */
+    private $following;
+
+    /**
+     * @var string
+     *
+     * @ORM\Column(type="string", nullable=true)
+     *
+     * @Assert\File(mimeTypes={ "image/gif", "image/jpeg", "image/svg+xml", "image/webp", "image/png" })
+     */
+    private $avatar;
+
+    /**
+     * @var string
+     *
+     * @ORM\Column(type="string", nullable=true)
+     *
+     * @Assert\File(mimeTypes={ "image/gif", "image/jpeg", "image/svg+xml", "image/webp", "image/png" })
+     */
+    private $banner;
+
+    /**
+     * @var string
+     *
+     * @ORM\Column(type="text", nullable=true)
+     */
+    private $description;
+
+    /**
+     * Account constructor.
+     */
+    public function __construct()
+    {
+        $this->followers = new ArrayCollection();
+        $this->following = new ArrayCollection();
+        $this->liked = new ArrayCollection();
+    }
+
+    /**
+     * @return int
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return string
+     */
+    public function getUsername()
+    {
+        return $this->username;
+    }
+
+    /**
+     * @param string $username
+     * @return Account
+     */
+    public function setUsername($username)
+    {
+        $this->username = $username;
+        return $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function getServer()
+    {
+        return $this->server;
+    }
+
+    /**
+     * @param string $server
+     * @return Account
+     */
+    public function setServer($server)
+    {
+        $this->server = $server;
+        return $this;
+    }
+
+    /**
+     * @return User
+     */
+    public function getUser()
+    {
+        return $this->user;
+    }
+
+    /**
+     * @param User $user
+     * @return Account
+     */
+    public function setUser(User $user)
+    {
+        $this->user = $user;
+        return $this;
+    }
+
+    /**
+     * @return Collection
+     */
+    public function getFollowers()
+    {
+        return $this->followers;
+    }
+
+    /**
+     * @param Collection $followers
+     * @return Account
+     */
+    public function setFollowers($followers)
+    {
+        $this->followers = $followers;
+        return $this;
+    }
+
+    /**
+     * @param Account $account
+     * @return Account
+     */
+    public function addFollower(Account $account)
+    {
+        $this->followers->add($account);
+        return $this;
+    }
+
+    /**
+     * @return Collection
+     */
+    public function getFollowing()
+    {
+        return $this->following;
+    }
+
+    /**
+     * @param Collection $following
+     * @return Account
+     */
+    public function setFollowing(Collection $following)
+    {
+        $this->following = $following;
+        return $this;
+    }
+
+    /**
+     * @param Account $account
+     * @return Account
+     */
+    public function addFollowing(Account $account)
+    {
+        $this->following->add($account);
+        return $this;
+    }
+
+    /**
+     * @return Collection
+     */
+    public function getLiked()
+    {
+        return $this->liked;
+    }
+
+    /**
+     * @param Collection $liked
+     * @return Account
+     */
+    public function setLiked(Collection $liked)
+    {
+        $this->liked = $liked;
+        return $this;
+    }
+
+    /**
+     * @param Entry $entry
+     * @return Account
+     */
+    public function addLiked(Entry $entry)
+    {
+        $this->liked->add($entry);
+        return $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function getAvatar()
+    {
+        return $this->avatar;
+    }
+
+    /**
+     * @param string $avatar
+     * @return Account
+     */
+    public function setAvatar($avatar)
+    {
+        $this->avatar = $avatar;
+        return $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function getBanner()
+    {
+        return $this->banner;
+    }
+
+    /**
+     * @param string $banner
+     * @return Account
+     */
+    public function setBanner($banner)
+    {
+        $this->banner = $banner;
+        return $this;
+    }
+
+    /**
+     * @return string
+     */
+    public function getDescription()
+    {
+        return $this->description;
+    }
+
+    /**
+     * @param string $description
+     * @return Account
+     */
+    public function setDescription($description)
+    {
+        $this->description = $description;
+        return $this;
+    }
+
+}
diff --git a/src/Wallabag/FederationBundle/Entity/Instance.php b/src/Wallabag/FederationBundle/Entity/Instance.php
new file mode 100644 (file)
index 0000000..ff8960c
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+namespace Wallabag\FederationBundle\Entity;
+
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+
+/**
+ * Account.
+ *
+ * @ORM\Entity
+ * @UniqueEntity(fields={"domain"}).
+ * @ORM\Table(name="`instance`")
+ */
+class Instance {
+    /**
+     * @var int
+     *
+     * @ORM\Column(name="id", type="integer")
+     * @ORM\Id
+     * @ORM\GeneratedValue(strategy="AUTO")
+     *
+     */
+    private $id;
+
+    /**
+     * @var string
+     *
+     * @ORM\Column(name="domain", type="string")
+     */
+    private $domain;
+
+    /**
+     * @var float
+     *
+     * @ORM\Column(name="score", type="float")
+     */
+    private $score = 0;
+
+    /**
+     * @var array
+     *
+     * @ORM\OneToMany(targetEntity="Wallabag\FederationBundle\Entity\Account", mappedBy="server")
+     */
+    private $users;
+
+    /**
+     * Instance constructor.
+     * @param string $domain
+     */
+    public function __construct($domain)
+    {
+        $this->domain = $domain;
+    }
+
+    /**
+     * @return int
+     */
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    /**
+     * @return string
+     */
+    public function getDomain()
+    {
+        return $this->domain;
+    }
+
+    /**
+     * @param string $domain
+     */
+    public function setDomain($domain)
+    {
+        $this->domain = $domain;
+    }
+
+    /**
+     * @return float
+     */
+    public function getScore()
+    {
+        return $this->score;
+    }
+
+    /**
+     * @param float $score
+     */
+    public function setScore($score)
+    {
+        $this->score = $score;
+    }
+
+    /**
+     * @return array
+     */
+    public function getUsers()
+    {
+        return $this->users;
+    }
+
+    /**
+     * @param array $users
+     */
+    public function setUsers($users)
+    {
+        $this->users = $users;
+    }
+}
diff --git a/src/Wallabag/FederationBundle/EventListener/CreateAccountListener.php b/src/Wallabag/FederationBundle/EventListener/CreateAccountListener.php
new file mode 100644 (file)
index 0000000..92626b1
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+namespace Wallabag\FederationBundle\EventListener;
+
+use Doctrine\ORM\EntityManager;
+use FOS\UserBundle\Event\UserEvent;
+use FOS\UserBundle\FOSUserEvents;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Wallabag\CoreBundle\Entity\Config;
+use Wallabag\FederationBundle\Entity\Account;
+
+/**
+ * This listener will create the associated configuration when a user register.
+ * This configuration will be created right after the registration (no matter if it needs an email validation).
+ */
+class CreateAccountListener implements EventSubscriberInterface
+{
+    private $em;
+    private $domainName;
+
+    public function __construct(EntityManager $em, $domainName)
+    {
+        $this->em = $em;
+        $this->domainName = $domainName;
+    }
+
+    public static function getSubscribedEvents()
+    {
+        return [
+            // when a user register using the normal form
+            FOSUserEvents::REGISTRATION_COMPLETED => 'createAccount',
+            // when we manually create a user using the command line
+            // OR when we create it from the config UI
+            FOSUserEvents::USER_CREATED => 'createAccount',
+        ];
+    }
+
+    public function createAccount(UserEvent $event)
+    {
+        $user = $event->getUser();
+        $account = new Account();
+        $account->setUser($user)
+            ->setUsername($user->getUsername())
+            ->setServer($this->domainName);
+
+        $this->em->persist($account);
+
+        $user->setAccount($account);
+
+        $this->em->persist($user);
+        $this->em->flush();
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Federation/CloudId.php b/src/Wallabag/FederationBundle/Federation/CloudId.php
new file mode 100644 (file)
index 0000000..038ea5e
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace Wallabag\FederationBundle\Federation;
+
+use Wallabag\FederationBundle\Entity\Account;
+
+class CloudId {
+
+    /** @var string */
+    private $id;
+
+    /** @var string */
+    private $user;
+
+    /** @var string */
+    private $remote;
+
+    /**
+     * CloudId constructor.
+     *
+     * @param string $id
+     */
+    public function __construct($id) {
+        $this->id = $id;
+
+        $atPos = strpos($id, '@');
+        $user = substr($id, 0, $atPos);
+        $remote = substr($id, $atPos + 1);
+        if (!empty($user) && !empty($remote)) {
+            $this->user = $user;
+            $this->remote = $remote;
+        }
+    }
+
+    /**
+     * The full remote cloud id
+     *
+     * @return string
+     */
+    public function getId() {
+        return $this->id;
+    }
+
+    public function getDisplayId() {
+        return str_replace('https://', '', str_replace('http://', '', $this->getId()));
+    }
+
+    /**
+     * The username on the remote server
+     *
+     * @return string
+     */
+    public function getUser() {
+        return $this->user;
+    }
+
+    /**
+     * The base address of the remote server
+     *
+     * @return string
+     */
+    public function getRemote() {
+        return $this->remote;
+    }
+
+    /**
+     * @param Account $account
+     * @param string $domain
+     * @return CloudId
+     */
+    public static function getCloudIdFromAccount(Account $account, $domain = '')
+    {
+        if ($account->getServer() !== null) {
+            return new self($account->getUsername() . '@' . $account->getServer());
+        }
+        return new self($account->getUsername() . '@' . $domain);
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Form/Type/AccountType.php b/src/Wallabag/FederationBundle/Form/Type/AccountType.php
new file mode 100644 (file)
index 0000000..af291ce
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+namespace Wallabag\FederationBundle\Form\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
+use Symfony\Component\Form\Extension\Core\Type\TextareaType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+use Wallabag\FederationBundle\Entity\Account;
+
+class AccountType extends AbstractType
+{
+    public function buildForm(FormBuilderInterface $builder, array $options)
+    {
+        $builder
+            ->add('description', TextareaType::class, ['label' => 'config.form_account.description_label'])
+            ->add('avatar', FileType::class, [
+                'label' => 'config.form_account.avatar_label',
+                'required' => false,
+                'data_class' => null,
+            ])
+            ->add('banner', FileType::class, [
+                'label' => 'config.form_account.banner_label',
+                'required' => false,
+                'data_class' => null,
+            ])
+            ->add('save', SubmitType::class, [
+                'label' => 'config.form.save',
+            ])
+        ;
+    }
+
+    public function configureOptions(OptionsResolver $resolver)
+    {
+        $resolver->setDefaults(array(
+            'data_class' => Account::class,
+        ));
+    }
+
+    public function getBlockPrefix()
+    {
+        return 'update_account';
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Repository/AccountRepository.php b/src/Wallabag/FederationBundle/Repository/AccountRepository.php
new file mode 100644 (file)
index 0000000..e39bc58
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+
+namespace Wallabag\FederationBundle\Repository;
+
+use Doctrine\ORM\EntityRepository;
+use Doctrine\ORM\QueryBuilder;
+
+class AccountRepository extends EntityRepository
+{
+    /**
+     * @param $accountId
+     * @return QueryBuilder
+     */
+    public function getBuilderForFollowingsByAccount($accountId)
+    {
+        return $this->createQueryBuilder('a')
+            ->select('f.id, f.username')
+            ->innerJoin('a.following', 'f')
+            ->where('a.id = :accountId')->setParameter('accountId', $accountId)
+            ;
+    }
+
+    /**
+     * @param $accountId
+     * @return QueryBuilder
+     */
+    public function getBuilderForFollowersByAccount($accountId)
+    {
+        return $this->createQueryBuilder('a')
+            ->innerJoin('a.followers', 'f')
+            ->where('a.id = :accountId')->setParameter('accountId', $accountId)
+            ;
+    }
+
+    /**
+     * @param $username
+     * @return QueryBuilder
+     * @throws \Doctrine\ORM\NonUniqueResultException
+     */
+    public function findAccountByUsername($username)
+    {
+        return $this->createQueryBuilder('a')
+            ->where('a.username = :username')->setParameter('username', $username)
+            ->andWhere('a.server = null')
+            ->getQuery()
+            ->getOneOrNullResult();
+    }
+}
diff --git a/src/Wallabag/FederationBundle/Repository/InstanceRepository.php b/src/Wallabag/FederationBundle/Repository/InstanceRepository.php
new file mode 100644 (file)
index 0000000..6365d5c
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace Wallabag\FederationBundle\Repository;
+
+use Doctrine\ORM\EntityRepository;
+
+class InstanceRepository extends EntityRepository
+{
+
+}
diff --git a/src/Wallabag/FederationBundle/Resources/config/services.yml b/src/Wallabag/FederationBundle/Resources/config/services.yml
new file mode 100644 (file)
index 0000000..1b337bb
--- /dev/null
@@ -0,0 +1,8 @@
+services:
+    wallabag_federation.listener.create_accounts:
+        class: Wallabag\FederationBundle\EventListener\CreateAccountListener
+        arguments:
+            - "@doctrine.orm.entity_manager"
+            - "%domain_name%"
+        tags:
+            - { name: kernel.event_subscriber }
diff --git a/src/Wallabag/FederationBundle/Resources/views/themes/material/User/followers.html.twig b/src/Wallabag/FederationBundle/Resources/views/themes/material/User/followers.html.twig
new file mode 100644 (file)
index 0000000..d31ccc3
--- /dev/null
@@ -0,0 +1,88 @@
+{% extends "WallabagCoreBundle::base.html.twig" %}
+
+{% block css %}
+    {{ parent() }}
+    {% if not app.debug %}
+        <link rel="stylesheet" href="{{ asset('bundles/wallabagcore/material.css') }}">
+    {% endif %}
+{% endblock %}
+
+{% block scripts %}
+    {{ parent() }}
+    <script src="{{ asset('bundles/wallabagcore/material' ~ (app.debug ? '.dev' : '') ~ '.js') }}"></script>
+{% endblock %}
+
+{% block header %}
+{% endblock %}
+
+{% block body_class %}reset-left{% endblock %}
+
+{% block messages %}
+    {% for flashMessage in app.session.flashbag.get('notice') %}
+        <script>
+            Materialize.toast('{{ flashMessage|trans }}', 4000);
+        </script>
+    {% endfor %}
+{% endblock %}
+
+{% block menu %}
+    <nav class="cyan darken-1">
+        <div class="nav-wrapper nav-panels">
+            <a href="#" data-activates="slide-out" class="nav-panel-menu button-collapse"><i class="material-icons">menu</i></a>
+            <div class="left action">
+                <a href="{{ path('homepage') }}">wallabag</a>
+            </div>
+            <ul class="input-field nav-panel-buttom">
+                {% if not is_granted('IS_AUTHENTICATED_FULLY') %}
+                    <li>
+                        <a href="{{ path('fos_user_security_login') }}">Login</a>
+                    </li>
+                    {% if registration_enabled %}
+                        <li>
+                            <a href="{{ path('fos_user_registration_register') }}">Register</a>
+                        </li>
+                    {% endif %}
+                {% else %}
+                    <li>
+                        <a href="{{ path('fos_user_security_logout') }}">Logout</a>
+                    </li>
+                {% endif %}
+            </ul>
+        </div>
+    </nav>
+
+{% endblock %}
+
+{% block content %}
+<div class="container">
+    <div class="row">
+        <div class="col offset-s2 s8">
+            {{ include('@WallabagFederation/themes/material/User/profile_header.html.twig') }}
+            <ul class="collection">
+            {% for account in users %}
+                <li class="collection-item avatar">
+                    <i class="material-icons circle">folder</i>
+                    <span class="title">{{ account.username }}@{{ account.server }}</span>
+                    <p>First Line</p>
+                    <a href="#!" class="secondary-content"><i class="material-icons">grade</i></a>
+                </li>
+            {% endfor %}
+            </ul>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
+{% block footer %}
+    <footer class="page-footer cyan darken-2">
+        <div class="footer-copyright">
+            <div class="container">
+                <div class="row right">
+                    <p>
+                        {{ 'footer.wallabag.powered_by'|trans }} <a target="_blank" href="https://wallabag.org" class="grey-text text-lighten-4">wallabag</a>
+                    </p>
+                </div>
+            </div>
+        </div>
+    </footer>
+{% endblock %}
diff --git a/src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile.html.twig b/src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile.html.twig
new file mode 100644 (file)
index 0000000..c52d0ca
--- /dev/null
@@ -0,0 +1,77 @@
+{% extends "WallabagCoreBundle::base.html.twig" %}
+
+{% block css %}
+    {{ parent() }}
+    {% if not app.debug %}
+        <link rel="stylesheet" href="{{ asset('bundles/wallabagcore/material.css') }}">
+    {% endif %}
+{% endblock %}
+
+{% block scripts %}
+    {{ parent() }}
+    <script src="{{ asset('bundles/wallabagcore/material' ~ (app.debug ? '.dev' : '') ~ '.js') }}"></script>
+{% endblock %}
+
+{% block header %}
+{% endblock %}
+
+{% block body_class %}reset-left{% endblock %}
+
+{% block messages %}
+    {% for flashMessage in app.session.flashbag.get('notice') %}
+        <script>
+            Materialize.toast('{{ flashMessage|trans }}', 4000);
+        </script>
+    {% endfor %}
+{% endblock %}
+
+{% block menu %}
+<nav class="cyan darken-1">
+    <div class="nav-wrapper nav-panels">
+        <a href="#" data-activates="slide-out" class="nav-panel-menu button-collapse"><i class="material-icons">menu</i></a>
+        <div class="left action">
+            <a href="{{ path('homepage') }}">wallabag</a>
+        </div>
+        <ul class="input-field nav-panel-buttom">
+            {% if not is_granted('IS_AUTHENTICATED_FULLY') %}
+                <li>
+                    <a href="{{ path('fos_user_security_login') }}">Login</a>
+                </li>
+                {% if registration_enabled %}
+                    <li>
+                        <a href="{{ path('fos_user_registration_register') }}">Register</a>
+                    </li>
+                {% endif %}
+            {% else %}
+                <li>
+                    <a href="{{ path('fos_user_security_logout') }}">Logout</a>
+                </li>
+            {% endif %}
+        </ul>
+    </div>
+</nav>
+
+{% endblock %}
+
+{% block content %}
+    <div class="container">
+        <div class="row">
+            <div class="col offset-l2 l8">
+                {{ include('@WallabagFederation/themes/material/User/profile_header.html.twig') }}
+        </div>
+    </div>
+{% endblock %}
+
+{% block footer %}
+    <footer class="page-footer cyan darken-2">
+        <div class="footer-copyright">
+            <div class="container">
+                <div class="row right">
+                    <p>
+                        {{ 'footer.wallabag.powered_by'|trans }} <a target="_blank" href="https://wallabag.org" class="grey-text text-lighten-4">wallabag</a>
+                    </p>
+                </div>
+            </div>
+        </div>
+    </footer>
+{% endblock %}
diff --git a/src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile_header.html.twig b/src/Wallabag/FederationBundle/Resources/views/themes/material/User/profile_header.html.twig
new file mode 100644 (file)
index 0000000..703de79
--- /dev/null
@@ -0,0 +1,35 @@
+{% set usershow = user.username %}
+{% if user.user.name is not null %}
+    {% set usershow = user.user.name %}
+{% endif %}
+
+<p>
+    {{ usershow }} utilise wallabag pour lire et archiver son contenu. Vous pouvez le/la suivre et interagir si vous possédez un compte quelque part dans le "fediverse". Si ce n'est pas le cas, vous pouvez en créer un ici.
+</p>
+<div class="card grey lighten-5 z-depth-1 profile">
+    <div class="card-content">
+        <img src="{{ asset('uploads/media/avatar/') ~ user.avatar }}" alt="" class="circle responsive-img center-block avatar">
+        <h3 class="center-align">{{ usershow }}</h3>
+        <h6 class="center-align">@{{ user.username }}</h6>
+        <a class="btn-floating btn-large halfway-fab waves-effect waves-light red" href="{{ path('follow-user', {'userToFollow': user.username}) }}"><i class="material-icons">person_add</i></a>
+        <div class="details">
+            <div class="bio">
+                <p>{{ user.description }}</p>
+            </div>
+            <div class="details-counters">
+                <div class="counter">
+                    <a href="{{ path('followers', {'user': user.username}) }}">
+                        <span class="counter-label">Followers</span>
+                        <span class="counter-number">{{ user.followers | length }}</span>
+                    </a>
+                </div>
+                <div class="counter">
+                    <a href="{{ path('following', {'user': user.username}) }}">
+                        <span class="counter-label">Following</span>
+                        <span class="counter-number">{{ user.following | length }}</span>
+                    </a>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/src/Wallabag/FederationBundle/Resources/views/themes/material/User/recommendations.html.twig b/src/Wallabag/FederationBundle/Resources/views/themes/material/User/recommendations.html.twig
new file mode 100644 (file)
index 0000000..b3d0d2c
--- /dev/null
@@ -0,0 +1,4 @@
+{{ dump(recommendations) }}
+{% for entry in recommendations %}
+{{ include('@WallabagCore/themes/material/Entry/_card_list.html.twig') }}
+{% endfor %}
diff --git a/src/Wallabag/FederationBundle/WallabagFederationBundle.php b/src/Wallabag/FederationBundle/WallabagFederationBundle.php
new file mode 100644 (file)
index 0000000..f9bd665
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace Wallabag\FederationBundle;
+
+use Symfony\Component\HttpKernel\Bundle\Bundle;
+
+class WallabagFederationBundle extends Bundle
+{
+}
index aed5c73e6cd33977696e8a58e9b48109f8679818..f11a277dfc48b0c58dd6867674729720f694259d 100644 (file)
@@ -15,6 +15,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
 use Wallabag\ApiBundle\Entity\Client;
 use Wallabag\CoreBundle\Entity\Config;
 use Wallabag\CoreBundle\Entity\Entry;
+use Wallabag\FederationBundle\Entity\Account;
 
 /**
  * User.
@@ -129,6 +130,14 @@ class User extends BaseUser implements TwoFactorInterface, TrustedComputerInterf
      */
     protected $default_client;
 
+    /**
+     * @ORM\OneToOne(targetEntity="Wallabag\FederationBundle\Entity\Account", mappedBy="user", cascade={"remove"})
+     */
+    protected $account;
+
+    /**
+     * User constructor.
+     */
     public function __construct()
     {
         parent::__construct();
@@ -333,4 +342,22 @@ class User extends BaseUser implements TwoFactorInterface, TrustedComputerInterf
     {
         $this->notifications = $notifications;
     }
+
+    /**
+     * @return Account
+     */
+    public function getAccount()
+    {
+        return $this->account;
+    }
+
+    /**
+     * @param mixed $account
+     * @return User
+     */
+    public function setAccount(Account $account)
+    {
+        $this->account = $account;
+        return $this;
+    }
 }
index 94fc0b9490679a87e039d78b41328587c5afa033..3f219b2f6db95087636cb1d06a1ef815ca306563 100644 (file)
@@ -97,7 +97,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Checking system requirements.', $tester->getDisplay());
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
     }
 
@@ -124,7 +124,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Dropping database, creating database and schema, clearing the cache', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
 
         // we force to reset everything
@@ -170,7 +170,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Checking system requirements.', $tester->getDisplay());
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
 
         // the current database doesn't already exist
@@ -197,7 +197,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Checking system requirements.', $tester->getDisplay());
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
 
         $this->assertContains('Dropping schema and creating schema', $tester->getDisplay());
@@ -241,7 +241,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Checking system requirements.', $tester->getDisplay());
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
 
         $this->assertContains('Creating schema', $tester->getDisplay());
@@ -264,7 +264,7 @@ class InstallCommandTest extends WallabagCoreTestCase
         $this->assertContains('Checking system requirements.', $tester->getDisplay());
         $this->assertContains('Setting up database.', $tester->getDisplay());
         $this->assertContains('Administration setup.', $tester->getDisplay());
-        $this->assertContains('Config setup.', $tester->getDisplay());
+        $this->assertContains('config setup.', $tester->getDisplay());
         $this->assertContains('Run migrations.', $tester->getDisplay());
     }
 }