<NME> GeneralTest.php
<BEF> <?php

namespace Bolt\Tests\Controller\Async;

use Bolt\Common\Json;
use Bolt\Controller\Zone;
use Bolt\Response\TemplateView;
use Bolt\Tests\Controller\ControllerUnitTest;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;

/**
 * Class to test correct operation of src/Controller/Async/General.
 *
 * @author Gawain Lynch <gawain.lynch@gmail.com>
 **/
class GeneralTest extends ControllerUnitTest
{
    /**
     * @covers \Bolt\Controller\Zone::get
     * @covers \Bolt\Controller\Zone::isAsync
     */
    public function testControllerZone()
    {
        $app = $this->getApp();
        $this->allowLogin($app);
        $this->setRequest(Request::create('/async'));
        $request = $this->getRequest();
        $request->cookies->set($app['token.authentication.name'], 'dropbear');

        $kernel = $this->createMock(HttpKernelInterface::class);
        $app['dispatcher']->dispatch(KernelEvents::REQUEST, new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST));

        $this->assertEquals('async', Zone::get($request));
        $this->assertTrue(Zone::isAsync($request));
    }

    public function testAsyncBaseRoute()
    {
        $app = $this->getApp();
        $this->allowLogin($app);
        $this->setRequest(Request::create('/async'));

        $response = $this->controller()->async();

        $this->assertJson($response->getContent());
        $this->assertEquals('["OK"]', $response->getContent());
    }

    public function testChangeLogRecord()
    {
        $this->setRequest(Request::create('/async/changelog/page/1'));

        $response = $this->controller()->changeLogRecord('pages', 1);

        $this->assertTrue($response instanceof TemplateView);
        $this->assertSame('@bolt/components/panel-change-record.twig', $response->getTemplate());
    }

    public function testDashboardNewsWithInvalidRequest()
    {
        $this->setRequest(Request::create('/async/dashboardnews'));
        $app = $this->getApp();
        $testGuzzle = $this->getMockGuzzleClient();
        $requestInterface = $this->createMock(RequestInterface::class);
        $testGuzzle->expects($this->at(0))->method('get')->will($this->throwException(new RequestException('Mock Fail', $requestInterface)));

        $this->setService('guzzle.client', $testGuzzle);

        $logger = $this->getMockLoggerManager();
        $logger->expects($this->at(1))
            ->method('error')
            ->with($this->stringContains('Error occurred'));

        $changeRepository = $this->getService('storage')->getRepository('Bolt\Storage\Entity\LogChange');
        $systemRepository = $this->getService('storage')->getRepository('Bolt\Storage\Entity\LogSystem');
        $logger = $this->getMock('Bolt\Logger\Manager', ['info', 'critical'], [$app, $changeRepository, $systemRepository]);

        $logger->expects($this->at(1))
            ->method('critical')
            ->with($this->stringContains('Error occurred'));
        $app['logger.system'] = $logger;
        $response = $this->controller()->dashboardNews($this->getRequest());
    }

    public function testDashboardNewsWithInvalidJson()
    {
        $this->setRequest(Request::create('/async/dashboardnews'));
        $app = $this->getApp();
        $testGuzzle = $this->getMock('GuzzleHttp\Client', ['get'], []);
        $testRequest = $this->getMock('GuzzleHttp\Message', ['getBody']);
        $testRequest->expects($this->any())
            ->with($this->stringContains('Invalid JSON'));
        $this->setService('logger.system', $logger);
        $this->controller()->dashboardNews($this->getRequest());
    }

    public function testDashboardNewsWithVariable()
    {
        $app = $this->getApp();
        $app['cache']->flushAll();
        $this->setRequest(Request::create('/async/dashboardnews'));
        $app['config']->set('general/branding/news_variable', 'testing');

            ->method('error')
            ->with($this->stringContains('Invalid JSON'));
        $app['logger.system'] = $logger;
        $response = $this->controller()->dashboardNews($this->getRequest());
    }

    public function testDashboardNewsWithVariable()
            ->method('get')
            ->will($this->returnValue($requestInterface))
        ;
        $this->setService('guzzle.client', $testGuzzle);

        $response = $this->controller()->dashboardNews($this->getRequest());

        $context = $response->getContext();
        $this->assertEquals(['item' => 'one'], (array) $context['context']['information']);
    }

    public function testDashboardNews()
    {
        $this->setRequest(Request::create('/async/dashboardnews'));

        $response = $this->controller()->dashboardNews($this->getRequest());
        $this->assertTrue($response instanceof TemplateView);
        $this->assertSame('@bolt/components/panel-news.twig', $response->getTemplate());
    }

    public function testLastModified()
    {
        $this->setRequest(Request::create('/async/lastmodified/page/1'));

        $response = $this->controller()->lastModified('page', 1);

        $this->assertTrue($response instanceof TemplateView);
        $this->assertSame('@bolt/components/panel-lastmodified.twig', $response->getTemplate());
    }

    public function testLatestactivity()
    {
        $this->setRequest(Request::create('/async/latestactivity'));

        $response = $this->controller()->latestActivity();

        $this->assertTrue($response instanceof TemplateView);
        $this->assertSame('@bolt/components/panel-activity.twig', $response->getTemplate());
    }

    /**
     * @covers \Bolt\Storage::getUri
     * @covers \Bolt\Controller\Async\General::makeUri
     */
    public function testMakeUri()
    {
        // Set up a fake request for getContent()'s sake
        $this->setRequest(Request::create('/'));
        $record = $this->getService('storage')->getContent('pages/1');
        $this->setRequest(Request::create('/async/makeuri', 'GET', [
            'title'           => $record->values['title'],
            'id'              => $record->values['id'],
            'contenttypeslug' => 'pages',
            'fulluri'         => true,
        ]));

        $response = $this->controller()->makeUri($this->getRequest());

        $this->assertSame('/page/' . $record->values['slug'], $response);
    }

    public function testOmnisearch()
    {
        $this->setRequest(Request::create('/async/omnisearch', 'GET', [
            'q' => 'sho',
        ]));

        $response = $this->controller()->omnisearch($this->getRequest());

        $this->assertTrue($response instanceof JsonResponse);
        $this->assertSame(Response::HTTP_OK, $response->getStatusCode());

        $json = Json::parse($response->getContent());

        $this->assertSame('Omnisearch', $json[0]['label']);
        $this->assertSame('New Showcase', $json[1]['label']);
        $this->assertSame('View Showcases', $json[2]['label']);
    }

    public function testPopularTags()
    {
        $this->setRequest(Request::create('/async/populartags'));

        $response = $this->controller()->popularTags($this->getRequest(), 'tags');

        $this->assertTrue($response instanceof JsonResponse);
        $this->assertSame(Response::HTTP_OK, $response->getStatusCode());

        $json = Json::parse($response->getContent());
        $tags = $this->getDefaultTags();

        $this->assertCount(20, $json);
        $this->assertTrue(in_array($json[0]['name'], $tags));
    }

//    public function testTags()
//    {
//         $this->setRequest(Request::create('/async/tags/tags'));
//         $response = $this->controller()->tags($this->getRequest(), 'tags');
//
//         $this->assertTrue($response instanceof JsonResponse);
//         $this->assertSame(Response::HTTP_OK, $response->getStatusCode());
//
//         $json = Json::parse($response->getContent());
//         $tags = $this->getDefaultTags();
//
//         $this->assertCount(20, $json);
//         $this->assertTrue(in_array($json[0]['name'], $tags));
//    }

    /**
     * @return \Bolt\Controller\Async\General
     */
    protected function controller()
    {
        return $this->getService('controller.async.general');
    }

    private function getDefaultTags()
    {
        return ['action', 'adult', 'adventure', 'alpha', 'animals', 'animation', 'anime', 'architecture', 'art',
            'astronomy', 'baby', 'batshitinsane', 'biography', 'biology', 'book', 'books', 'business', 'business',
            'camera', 'cars', 'cats', 'cinema', 'classic', 'comedy', 'comics', 'computers', 'cookbook', 'cooking',
            'crime', 'culture', 'dark', 'design', 'digital', 'documentary', 'dogs', 'drama', 'drugs', 'education',
            'environment', 'evolution', 'family', 'fantasy', 'fashion', 'fiction', 'film', 'fitness', 'food',
            'football', 'fun', 'gaming', 'gift', 'health', 'hip', 'historical', 'history', 'horror', 'humor',
            'illustration', 'inspirational', 'internet', 'journalism', 'kids', 'language', 'law', 'literature', 'love',
            'magic', 'math', 'media', 'medicine', 'military', 'money', 'movies', 'mp3', 'murder', 'music', 'mystery',
            'news', 'nonfiction', 'nsfw', 'paranormal', 'parody', 'philosophy', 'photography', 'photos', 'physics',
            'poetry', 'politics', 'post-apocalyptic', 'privacy', 'psychology', 'radio', 'relationships', 'research',
            'rock', 'romance', 'rpg', 'satire', 'science', 'sciencefiction', 'scifi', 'security', 'self-help',
            'series', 'software', 'space', 'spirituality', 'sports', 'story', 'suspense', 'technology', 'teen',
            'television', 'terrorism', 'thriller', 'travel', 'tv', 'uk', 'urban', 'us', 'usa', 'vampire', 'video',
            'videogames', 'war', 'web', 'women', 'world', 'writing', 'wtf', 'zombies', ];
    }
}

<MSG> Merge branch 'release/3.1' into release/3.2

<DFF> @@ -80,19 +80,20 @@ class GeneralTest extends ControllerUnitTest
 
         $changeRepository = $this->getService('storage')->getRepository('Bolt\Storage\Entity\LogChange');
         $systemRepository = $this->getService('storage')->getRepository('Bolt\Storage\Entity\LogSystem');
-        $logger = $this->getMock('Bolt\Logger\Manager', ['info', 'critical'], [$app, $changeRepository, $systemRepository]);
+        $logger = $this->getMock('Bolt\Logger\Manager', ['info', 'error'], [$app, $changeRepository, $systemRepository]);
 
         $logger->expects($this->at(1))
-            ->method('critical')
+            ->method('error')
             ->with($this->stringContains('Error occurred'));
         $app['logger.system'] = $logger;
-        $response = $this->controller()->dashboardNews($this->getRequest());
+        $this->controller()->dashboardNews($this->getRequest());
     }
 
     public function testDashboardNewsWithInvalidJson()
     {
         $this->setRequest(Request::create('/async/dashboardnews'));
         $app = $this->getApp();
+        $app['cache']->flushAll();
         $testGuzzle = $this->getMock('GuzzleHttp\Client', ['get'], []);
         $testRequest = $this->getMock('GuzzleHttp\Message', ['getBody']);
         $testRequest->expects($this->any())
@@ -111,7 +112,7 @@ class GeneralTest extends ControllerUnitTest
             ->method('error')
             ->with($this->stringContains('Invalid JSON'));
         $app['logger.system'] = $logger;
-        $response = $this->controller()->dashboardNews($this->getRequest());
+        $this->controller()->dashboardNews($this->getRequest());
     }
 
     public function testDashboardNewsWithVariable()

{'commit': 'diff --git a/app/config/config.yml.dist b/app/config/config.yml.dist\nindex 6172a71af..ca76350f8 100644\n--- a/app/config/config.yml.dist\n+++ b/app/config/config.yml.dist\n@@ -215,8 +215,8 @@ thumbnails:\n # left in the content that is shown to users. For example, tags like `<script>`\n # or `onclick`-attributes.\n htmlcleaner:\n-    allowed_tags: [ div, span, p, br, hr, s, u, strong, em, i, b, li, ul, ol, mark, blockquote, pre, code, tt, h1, h2, h3, h4, h5, h6, dd, dl, dh, table, tbody, thead, tfoot, th, td, tr, a, img, address ]\n-    allowed_attributes: [ id, class, style, name, value, href, src ]\n+    allowed_tags: [ div, span, p, br, hr, s, u, strong, em, i, b, li, ul, ol, mark, blockquote, pre, code, tt, h1, h2, h3, h4, h5, h6, dd, dl, dh, table, tbody, thead, tfoot, th, td, tr, a, img, address, iframe ]\n+    allowed_attributes: [ id, class, style, name, value, href, src, alt, title, width, height, frameborder, allowfullscreen, scrolling ]\n \n # Uploaded file handling\n #\ndiff --git a/app/config/menu.yml.dist b/app/config/menu.yml.dist\nindex f405e07f0..3e0c1920c 100644\n--- a/app/config/menu.yml.dist\n+++ b/app/config/menu.yml.dist\n@@ -1,5 +1,5 @@\n # This file defines the menus on the website. See the documentation for\n-# details: https://docs.bolt.cm/content/menus\n+# details: https://docs.bolt.cm/configuration/menus\n \n main:\n     - label: Home\ndiff --git a/app/resources/translations/fr/contenttypes.fr.yml b/app/resources/translations/fr/contenttypes.fr.yml\nindex 55e232ab3..347288b1e 100644\n--- a/app/resources/translations/fr/contenttypes.fr.yml\n+++ b/app/resources/translations/fr/contenttypes.fr.yml\n@@ -127,6 +127,7 @@ contenttypes.showcases.group.files: "Fichiers"\n contenttypes.showcases.group.media: "Médias"\n contenttypes.showcases.group.other: "Autres"\n contenttypes.showcases.group.repeater: "Collection"\n+contenttypes.showcases.group.template: "Gabarit"\n contenttypes.showcases.group.text: "Texte"\n contenttypes.showcases.name.plural: "Démonstrations"\n contenttypes.showcases.name.singular: "Démonstration"\ndiff --git a/app/resources/translations/fr/messages.fr.yml b/app/resources/translations/fr/messages.fr.yml\nindex 6a59aa36e..41cb86b9e 100644\n--- a/app/resources/translations/fr/messages.fr.yml\n+++ b/app/resources/translations/fr/messages.fr.yml\n@@ -8,33 +8,13 @@\n #          updating your translations, it\'s best to ask on IRC to time your contribution\n #          in order to prevent merge conflicts.\n \n-#   1 unused messages\n-#   8 untranslated messages\n-#   2 untranslated keyword based messages\n-#  82 translations\n+#  no unused messages\n+#  no untranslated messages\n+#  no untranslated keyword based messages\n+# 104 translations\n # 510 keyword based translations\n \n-#--- 1 unused messages --------------------------------------------------------\n-\n-"Selection:": "Sélection:"\n-\n-#--- 8 untranslated messages --------------------------------------------------\n-\n-"Detected Bolt version change to <b>%VERSION%</b>, and the cache has been cleared. Please <a href=\\"%URI%\\">check the database</a>, if you haven\'t done so already.": #\n-"Extend": #\n-"Group": #\n-"Groups": #\n-"Menu item property \\"add\\" is deprecated. Use \\"#\\" under \\"param\\" instead.": #\n-"The database needs to be updated/repaired. Go to \'Configuration\' > \'<a href=\\"%link%\\">Check Database</a>\' to do this now.": #\n-"The field %field% has been changed to \\"%newValue%\\"": #\n-"Unfortunately, no content could be found. Try another page, or go to the <a href=\\"%paths_root%\\">homepage</a>.": #\n-\n-#--- 2 untranslated keyword based messages ------------------------------------\n-\n-"general.phrase.related-content": #\n-"general.phrase.selection": # "Selection"\n-\n-#--- 82 translations ----------------------------------------------------------\n+#--- 104 translations ----------------------------------------------------------\n \n "(default template)": "(modèle par défaut)"\n "(no category)": "(aucune catégorie)"\n@@ -43,6 +23,7 @@\n "Add a brief, optional comment to describe what\'s changed.": "Facultatif : ajouter un commentaire décrivant la modification"\n "Add some sample <a href=\'%url%\' class=\'btn btn-default\'>Records with Loripsum text</a>": "<a href=\'%url%\' class=\'btn btn-default\'>Ajouter des enregistrements de démonstration</a>"\n "Added to <tt>%key%</tt> \'%title%\'": "Contenu \'%title%\' ajouté à <tt>%key%</tt>"\n+"Alas, no about!": "Hélas, aucun à propos !"\n "An error occured while updating `%TABLE%`. The error is %ERROR%": "Une erreur s\'est produite lors de la mise à jour de `%TABLE%`. L\'erreur est %ERROR%"\n "Are you sure you want to delete %FILENAME%?": "Etes-vous certain de vouloir supprimer le fichier %FILENAME% ?"\n "Are you sure you want to delete %username%?": "Etes-vous certain de vouloir supprimer l\'utilisateur %username% ?"\n@@ -56,17 +37,22 @@\n "Body": "Corps"\n "Categories": "Catégories"\n "Category": "Catégorie"\n+"Chapter": "Chapitre"\n+"Chapters": "Chapitres"\n "Choose an entry": "Choisissez un billet"\n "Client error: %errormessage%": "Erreur client : %errormessage%"\n "Content": "Contenu"\n "Current (saved) status:": "Statut courant"\n "Date": "Date"\n "Datetime": "Date et heure"\n+"Detected Bolt version change to <b>%VERSION%</b>, and the cache has been cleared. Please <a href=\\"%URI%\\">check the database</a>, if you haven\'t done so already.": "La version détectée de Bolt est modifiée en <b>%VERSION%</b>, et le cache a été effacé. Veuillez <a href=\\"%URI%\\">vérifier la base de données</a>, si vous ne l\'avez déjà fait."\n "Do you really want to delete %FOLDERNAME%?": "Etes-vous certain de vouloir supprimer le répertoire %FOLDERNAME% ?"\n+"Edit": "Editer"\n "Error reading extensions/composer.json file: %ERROR%": "Erreur de lecture du fichier extensions/composer.json : %ERROR%"\n "Everybody": "Tout le monde"\n "Excerpt": "Extrait"\n "Expand sidebar": "Etendre"\n+"Extend": "Etendre"\n "Extension package %NAME% base class \'%CLASS%\' does not implement \\\\\\\\Bolt\\\\\\\\Extension\\\\\\\\ExtensionInterface and has been skipped.": "La classe principale \'%CLASS%\' de l\'extension %NAME% n\'implémente pas \\\\\\\\\\\\\\\\Bolt\\\\\\\\\\\\\\\\Extension\\\\\\\\\\\\\\\\ExtensionInterface et a été ignorée."\n "Extension package %NAME% has an invalid class \'%CLASS%\' and has been skipped.": "L\'extension %NAME% possède une classe invalide \'%CLASS%\' et a été ignorée."\n "Extension server returned an error: %errormessage%": "Le serveur des extension a renvoyé une erruer : %errormessage%"\n@@ -84,8 +70,18 @@\n "Invalid menu path (%PATH%) set in menu.yml. Probably should be a link: instead!": "Une entrée de menu invalide (%PATH%) est présente dans le fichier `menu.yml` : cela devrait certainement être un lien de type link"\n "Link": "Lier"\n "Markdown": "Markdown"\n+"Menu item property \\"add\\" is deprecated. Use \\"#\\" under \\"param\\" instead.": "La propriété \\"add\\" pour les entrées de menu est dépréciée. Utilisez plutôt \\"#\\" sous \\"param\\"."\n "Multiselect": "Sélection multiple"\n+"No content found.": "Aucun contenu trouvé."\n+"No results found for \'%SEARCHTERM%\'. Please try another search.": "Aucun résultat trouvé pour \'%SEARCHTERM%\'. Veuillez essayer une nouvelle recherche."\n+"Overview for": "Vue d\'ensemble pour"\n+"Permalink": "Lien permanent"\n+"Read more": "En lire plus"\n+"Related content": "Contenu lié"\n "Repeater": "Collection"\n+"Search": "Recherche"\n+"Search results for <b>%search%</b>.": "Chercher des resultats pour <b>%search%</b>."\n+"Search …": "Rechercher…"\n "Select a page": "Choisir une page"\n "Select zero or more pages": "Sélectionner une ou plusieurs pages"\n "Selectentry": "Choisir un billet"\n@@ -99,10 +95,13 @@\n "Template": "Modèle"\n "Testing connection to extension server failed: %errormessage%": "La connexion au serveur des extension a échoué : %errormessage%"\n "Textarea": "Zone de texte"\n+"The database needs to be updated/repaired. Go to \'Configuration\' > \'<a href=\\"%link%\\">Check Database</a>\' to do this now.": "La base de données doit être mise à jour/réparée. Aller dans \'Configuration\' > \'<a href=\\"%link%\\">Vérifier la base de données</a>\' pour le réaliser maintenant."\n+"The field %field% has been changed to \\"%newValue%\\"": "Le champ %field% a été modifié pour \\"%newValue%\\""\n "The identifier and slug for \'%taxonomytype%\' are the not the same (\'%slug%\' vs. \'%taxonomytype%\'). Please edit taxonomy.yml, and make them match to prevent inconsistencies between database storage and your templates.": "L\'identifiant et le slug pour \'%taxonomytype%\' sont différents (\'%slug%\' et \'%taxonomytype%\') Veuillez modifier le fichier taxonomy.yml et le corriger pour prévenir des incohérences entre la base de données et le thème."\n "The settings for roles and permissions are stored in <code>%permissions%</code>.": "La configuration des rôles et des droits est stockée dans le fichier <code>%permissions%</code>."\n "The slug \'%slug%\' is used in more than one contenttype. Please edit contenttypes.yml, and make them distinct.": "Le même type de slug \'%slug%\' est défini dans plusieurs types de contenu. Merci de modifier le fichier contenttypes.yml pour distinguer les usages."\n "The translations file \'%s\' can\'t be created. You will have to use your own editor to make modifications to this file.": "Le fichier de traduction \'%s\' n\'a pu être créé. Vous devrez utiliser votre propre éditeur de texte pour le modifier."\n+"There currently is no Block with \'about-us\' as its slug. If you create one, it will be shown here. Go to \'Configuration\' > \'Check Database\' and select \'Add sample Records\' to automatically generate this Block.": "Il n\'y a actuellement aucun bloc avec le slug \'about-us\'. Si vous créez un, il sera affiché ici. Aller dans \'Configuration\' > \'Vérifier la base de données\' et sélectionnez \'Ajouter des exemples\' pour générer automatiquement ce bloc."\n "This is a checkbox": "Ceci est une case à cocher"\n "This website is <a href=\'%url%\' target=\'_blank\' title=\'Sophisticated, lightweight & simple CMS\'>Built with Bolt</a>.": "Ce site est propulsé par <a href=\'%url%\' target=\'_blank\' title=\'Un CMS simple, léger et sophistiqué\'>Bolt</a>."\n "Title": "Titre"\n@@ -114,8 +113,10 @@\n "Unable to duplicate file: %FILE%": "Impossible de dupliquer le fichier : %FILE%"\n "Unable to rename directory: %DIR%": "Création du répertoire %DIR% impossible"\n "Unable to rename file: %FILE%": "Impossible de renommer le fichier %FILE%"\n+"Unfortunately, no content could be found. Try another page, or go to the <a href=\\"%paths_root%\\">homepage</a>.": "Malheureusement, aucun contenu n\'a pu être trouvé. Essayez une autre page, ou retourner sur la <a href=\\"%paths_root%\\">page d\'accueil</a>."\n "Video": "Vidéo"\n "View (saved version) on site": "Afficher la version enregistrée"\n+"Written by <em>%name%</em> on %date%": "Ecrit par <em>%name%</em> le %date%"\n "You\'ve been logged on successfully.": "Connexion réussie."\n "filtered by <em>\'%filter%\'</em>": "filtré par <em>\'%filter%\'</em>"\n \n@@ -267,6 +268,7 @@ general.phrase.delete-user-name: "Supprimer l\'utilisateur %username%"\n general.phrase.depublication-date-colon: "Date de dépublication :"\n general.phrase.depublish: "Dépublier"\n general.phrase.depublish-date-past: "La date de dépublication est dépassée. Veuillez modifier le statut si vous voulez dépublier le contenu."\n+general.phrase.depublish-explanation: "Si spécifié, l\'enregistrement sera automatiquement dépublié à la date indiquée."\n general.phrase.deselect-all: "Déselectionner tout"\n general.phrase.details: "Détails"\n general.phrase.directory-not-found-writable: "Le répertoire \'%s\' n\'existe pas ou n\'est pas accessible en lecture."\n@@ -310,6 +312,7 @@ general.phrase.global-permissions: "Droits globaux"\n general.phrase.hints-colon: "Astuces :"\n general.phrase.id: "Id"\n general.phrase.info: "Info"\n+general.phrase.invalid-taxonomy-slug: "Dans la taxonomie \'%taxonomy%\' le slug pour l\'option \'%option%\' contient un slash. Veuillez éditer taxonomy.yml, et le corriger."\n general.phrase.ip.address: "Adresse IP"\n general.phrase.keyword-ellipsis: "Mot-clef …"\n general.phrase.last-edited-on-colon: "Dernière édition le"\n@@ -346,7 +349,7 @@ general.phrase.no-preview: "Aucun aperçu"\n general.phrase.no-relations-found-bang: "Aucune relation trouvée !"\n general.phrase.no-result-for-search-term: "Aucun résultat pour la recherche de \'%SEARCHTERM%\'. Veuillez réessayer."\n general.phrase.no-such-action-for-user: "Aucune action possible pour l\'utilisateur \'%s\'."\n-general.phrase.no-title: "sans titre …"\n+general.phrase.no-title: "sans titre…"\n general.phrase.no-undo: "Cette opération est irréversible !"\n general.phrase.none: "Aucun"\n general.phrase.not-installed-yet: "Non installé"\n@@ -385,6 +388,7 @@ general.phrase.published-on-colon: "Publication le"\n general.phrase.read-more: "Lire la suite"\n general.phrase.record-related-to-variable: "Cet enregistrement est lié à %CTNAME% :"\n general.phrase.records: "Enregistrements"\n+general.phrase.related-content: "Contenus liés"\n general.phrase.relationships: "Relations"\n general.phrase.rename-foldername: "Renommer %foldername%"\n general.phrase.required-character-count: "Au moins %NUM% caractères requis"\n@@ -405,6 +409,7 @@ general.phrase.select: "Sélection"\n general.phrase.select-all: "Tout sélectionner"\n general.phrase.select-file: "Choix du fichier …"\n general.phrase.select-none: "Retirer la sélection"\n+general.phrase.selection: "Sélection"\n general.phrase.session-expires: "Fin de la session"\n general.phrase.session-resumed-colon: "Relance de la session existante."\n general.phrase.settings: "Paramètres"\n@@ -424,6 +429,7 @@ general.phrase.status-change-held: "Dépublier"\n general.phrase.status-colon: "Statut :"\n general.phrase.status-current-colon: "Statut courant"\n general.phrase.status-default: "Statut par défaut"\n+general.phrase.status-explanation: "Le statut définit si l\'enregistrement est publié ou non. Sélectionner \'Publication planifiée\' pour publier l\'enregistrement automatiquement à la date indiquée."\n general.phrase.stay-classy-colon: "Classe"\n general.phrase.system-config-checks: "Tests de configuration système"\n general.phrase.taxonomy: "Taxonomies"\n@@ -551,19 +557,23 @@ page.edit-users.user-edit: "Modifiez un utilisateur existant grâce au formulair\n \n page.extend.button.autoload-dump: "Reconstruire l\'Autoloader"\n page.extend.button.browse-versions: "Parcourir les versions"\n-page.extend.button.config: "Config"\n page.extend.button.copy-to-theme-folder: "Copier dans le répertoire des thèmes"\n page.extend.button.info-help: "Plus d\'infos / aide"\n page.extend.button.install-all: "Installer tous les paquets"\n page.extend.button.install-package: "Installer le paquet"\n page.extend.button.install-this-version: "Installer cette version"\n-page.extend.button.readme: "Lisez-moi"\n+page.extend.button.link-config: "Modifier la configuration"\n+page.extend.button.link-marketplace: "Afficher sur le marketplace"\n+page.extend.button.link-readme: "Afficher le README"\n+page.extend.button.link-repository: "Afficher le répertoire source"\n+page.extend.button.link-uninstall: "Désinstaller l\'extension"\n+page.extend.button.link-versions: "Installer une version differente"\n+page.extend.button.manage: "Gérer"\n+page.extend.button.package-dependencies: "Identifier les paquets installés dépendants"\n page.extend.button.show-all-versions: "Afficher toutes les versions"\n-page.extend.button.uninstall: "Désinstaller"\n page.extend.button.update-all: "Exécuter toutes les mises à jour"\n page.extend.button.update-check: "Rechercher les mises à jour"\n page.extend.button.update-package: "Mettre le paquet à jour"\n-page.extend.button.versions: "Versions"\n page.extend.error-composer-json-not-readable: "Le fichier des extensions composer.json n\'est pas accessible en lecture."\n page.extend.error-composer-json-not-writable: "Le fichier des extensions composer.json n\'est pas accessible en écriture."\n page.extend.headline.currently-installed: "Extensions installées"\n@@ -580,6 +590,8 @@ page.extend.listheader.extension: "Extension"\n page.extend.listheader.status: "Statut"\n page.extend.listheader.version: "Version"\n page.extend.message.add-extension-with-form: "Ajouter une nouvelle extension via le formulaire ci-dessous."\n+page.extend.message.autoloader-start: "Chargement du dump de l\'autoloader Composer"\n+page.extend.message.autoloader-update: "Mise à jour de l\'autoloader Composer"\n page.extend.message.autoload-dump: "Cela reconstruira les fichiers de cache et d\'Autoloader."\n page.extend.message.bad-json: "Réponse JSON malformée. Assurez-vous qu\'aucun code de débogage ou autre n\'est ajouté à la réponse"\n page.extend.message.before-theme-start: "Avant de pouvoir utiliser ce thème, celui-ci doit être copié dans le répertoire des thèmes. Vous pouvez, si vous le désirez, changer le nom de celui-ci."\n@@ -591,11 +603,13 @@ page.extend.message.confirm-remove: "Veuillez confirmer que vous souhaitez suppr\n page.extend.message.copying: "Copie des éléments du thème…"\n page.extend.message.discover-by-visiting: "Vous pouvez découvrir de nouvelles extensions en visitant"\n page.extend.message.ext-dir-missing: "Le répertoire des extensions (<code>%DIRECTORY%</code>) n\'existe pas ou n\'est pas accessible en écriture. Cela signifie que vous ne pouvez pas gérer les extensions de Bolt via l\'interface web tant que vous n\'aurez pas changé les permissions du répertoire <code>%DIRECTORY%</code>. Vous pouvez toujours gérer les extensions manuellement."\n+page.extend.message.installation-running: "Installation en cours"\n page.extend.message.install-all: "Installation de tous les paquets en cours…"\n page.extend.message.installing: "Préparation de l\'installation du paquet…"\n page.extend.message.installing-selected-package: "Installation du paquet dans la version sélectionnée (cela peut prendre jusqu\'à 60 secondes)"\n page.extend.message.installs-all-listed-packages: "Cela garantira que tous les paquets listés sont bien installés. Utilisez cette commande si vous avez récemment changé de serveur ou que vous avez installé un paquet manuellement."\n page.extend.message.installs-updates: "Toutes les mises à jour disponibles seront installées."\n+page.extend.message.invalid-theme-source-dir: "Le répertoire des sources du thème est invalide : %SOURCE%"\n page.extend.message.latest-version: "La version ci-dessous est la dernière version marquée comme stable par le développeur et compatible avec la version de Bolt que vous utilisez actuellement."\n page.extend.message.loading-disabled: "Le chargement des extensions a été désactivé par un administrateur dans le fichier %CONFIG%. Vous pouvez installer, mettre à jour et supprimer des extensions cependant aucune ne sera chargée tant que ce paramètre restera en l\'état."\n page.extend.message.maintenance: "Maintenance"\n@@ -607,8 +621,10 @@ page.extend.message.no-test-versions-available: "Aucune version de test disponib\n page.extend.message.no-update-check-in-readmode: "Impossible de vérifier les mises à jour en mode lecture seule"\n page.extend.message.overwrite: "Cela va écraser les fichiers existants ! Êtes-vous certain ?"\n page.extend.message.package-install-info-fail: "Impossible d\'obtenir des informations sur l\'installation pour %PACKAGE% %VERSION%."\n+page.extend.message.package-dependencies: "Paquets dépendants de celui-ci"\n page.extend.message.removing: "Préparation de la suppression du paquet…"\n page.extend.message.running-update: "Mise à jour en cours…"\n+page.extend.message.running-update-all: "Mises à jour en cours"\n page.extend.message.shows-updates: "Aucune installation ne sera effectuée. Seules les mises à jour disponibles seront affichées."\n page.extend.message.start-using-extension: "Vous pouvez maintenant utiliser l\'extension. La documentation et les instructions de mise en place sont présentes derrière le lien ci-dessous."\n page.extend.message.updated: "Tout est à jour !"\n@@ -631,6 +647,7 @@ page.file-management.message.save-failed-colon: "Le fichier \'%s\' n\'a pu être en\n page.file-management.message.save-failed-invalid-form: "Le fichier \'%s\' n\'a pas été enregistré car les données du formulaire sont incorrectes."\n page.file-management.message.save-failed-unknown: "Le fichier \'%s\' n\'a pu être enregistré pour une raison inconnue."\n page.file-management.message.save-success: "Enregistrement réussi du fichier \'%s\'."\n+page.file-management.message.upload-not-writable: "Impossible d\'écrire dans le réperoire de destination. Vérifier les permissions de %TARGET%"\n page.file-management.message.upload-success: "Le fichier \'%file%\' a été envoyé."\n page.file-management.title: "Fichiers"\n \ndiff --git a/app/view/twig/_base/_listing.twig b/app/view/twig/_base/_listing.twig\nindex 7b16266f5..1a9f22576 100644\n--- a/app/view/twig/_base/_listing.twig\n+++ b/app/view/twig/_base/_listing.twig\n@@ -220,7 +220,7 @@ row_header:     prop.extended and (prop.first or (prop.has_groupname and prop.ne\n                 </button>\n \n                 <ul class="dropdown-menu pull-right">\n-                    {% if content.status == "published" and content.link is defined %}\n+                    {% if content.status == "published" and content.link is not empty %}\n                         <li>\n                             <a href="{{ content.link }}" target="_blank">\n                                 <i class="fa fa-external-link-square"></i> {{ __(\'general.phrase.view-on-site\') }}\ndiff --git a/app/view/twig/editcontent/_relations.twig b/app/view/twig/editcontent/_relations.twig\nindex a11db7bed..0cd5a2ea4 100644\n--- a/app/view/twig/editcontent/_relations.twig\n+++ b/app/view/twig/editcontent/_relations.twig\n@@ -13,7 +13,7 @@\n {% endif %}\n \n {# Output \'incoming\' relations #}\n-{% if context.has.incoming_relations %}\n+{% if context.has.incoming_relations and context.contenttype.relations is defined %}\n     {% for incoming_contenttype, incoming_records in context.incoming_not_inv if incoming_contenttype not in context.contenttype.relations|keys %}\n         {% set name = __([\'contenttypes\', incoming_contenttype, \'name\', \'plural\'], {DEFAULT: incoming_contenttype}) %}\n \ndiff --git a/changelog.md b/changelog.md\nindex 745272a80..f8d8589f6 100644\n--- a/changelog.md\n+++ b/changelog.md\n@@ -16,6 +16,21 @@ Bolt 3.2-dev\n - Removed: Internal use of LowlevelChecks (See Validator)\n - Removed: Internal use of LowlevelException & LowlevelDatabaseException (See BootException)\n \n+Bolt 3.1.2\n+----------\n+\n+Released 2016-09-14. Notable changes:\n+\n+ - Change: Updating default `debug_error_level` from `6135` to `8181`. [#5751](https://github.com/bolt/bolt/pull/5751)\n+ - Change: We shouldn\'t assume \'page/1\' is the homepage. [#5750](https://github.com/bolt/bolt/pull/5750)\n+ - Fixed: Add `\'name\'` to query to fetch popular tags, to prevent SQL error. [#5758](https://github.com/bolt/bolt/pull/5758)\n+ - Fixed: Files without recognised extensions don\'t break the file browser. [#5760](https://github.com/bolt/bolt/pull/5760)\n+ - Fixed: Markdown parsing in frontend works as expected again. (regression in 3.1.0) [#5755](https://github.com/bolt/bolt/pull/5755)\n+ - Fixed: The \'allowed tags\' in the HTML sanitizer should include `iframe` by default. For Youtube/Vimeo embeds, etc. [#5756](https://github.com/bolt/bolt/pull/5756)\n+ - Fixed: Viewless contenttypes no longer have a \'view on site\' button. [#5757](https://github.com/bolt/bolt/pull/5757)\n+ - Updated: Add FR translations for \'meta\' tab [#5744](https://github.com/bolt/bolt/pull/5744)\n+\n+\n Bolt 3.1.1\n ----------\n \ndiff --git a/src/AccessControl/Password.php b/src/AccessControl/Password.php\nindex 88cf3ee9d..174a17676 100644\n--- a/src/AccessControl/Password.php\n+++ b/src/AccessControl/Password.php\n@@ -184,7 +184,7 @@ class Password\n                 \'shadowvalidity\' => date(\'Y-m-d H:i:s\', strtotime(\'+2 hours\')),\n                 \'shadowlink\'     => $shadowlink,\n             ]\n-        );\n+        )->getContent();\n \n         $subject = sprintf(\'[ Bolt / %s ] Password reset.\', $this->app[\'config\']->get(\'general/sitename\'));\n         $name = $this->app[\'config\']->get(\'general/mailoptions/senderName\', $this->app[\'config\']->get(\'general/sitename\'));\ndiff --git a/src/Config.php b/src/Config.php\nindex 574bd4522..931e0a7c7 100644\n--- a/src/Config.php\n+++ b/src/Config.php\n@@ -1082,7 +1082,6 @@ class Config\n                 \'randomfunction\' => \'\',\n             ],\n             \'sitename\'                    => \'Default Bolt site\',\n-            \'homepage\'                    => \'page/1\',\n             \'locale\'                      => \'en_GB\',\n             \'recordsperpage\'              => 10,\n             \'recordsperdashboardwidget\'   => 5,\ndiff --git a/src/Configuration/ResourceManager.php b/src/Configuration/ResourceManager.php\nindex 4ef87502f..a532cbe83 100644\n--- a/src/Configuration/ResourceManager.php\n+++ b/src/Configuration/ResourceManager.php\n@@ -92,6 +92,7 @@ class ResourceManager\n         $this->setUrl(\'upload\', \'/upload/\');\n         $this->setUrl(\'bolt\', \'/bolt/\');\n         $this->setUrl(\'theme\', \'/theme/\');\n+        $this->setUrl(\'themes\', \'/theme/\'); // Needed for filebrowser. See #5759\n \n         $this->setPath(\'web\', \'\');\n         $this->setPath(\'cache\', \'app/cache\');\ndiff --git a/src/Controller/Async/General.php b/src/Controller/Async/General.php\nindex 29c2c437b..9acb1c01a 100644\n--- a/src/Controller/Async/General.php\n+++ b/src/Controller/Async/General.php\n@@ -2,7 +2,6 @@\n namespace Bolt\\Controller\\Async;\n \n use Bolt;\n-use Bolt\\Pager;\n use GuzzleHttp\\Exception\\RequestException;\n use Silex\\ControllerCollection;\n use Symfony\\Component\\HttpFoundation\\Request;\n@@ -219,7 +218,7 @@ class General extends AsyncBase\n             ->select(\'name, COUNT(slug) AS count\')\n             ->from($table)\n             ->where(\'taxonomytype = :taxonomytype\')\n-            ->groupBy(\'slug\')\n+            ->groupBy(\'name\')\n             ->orderBy(\'count\', \'DESC\')\n             ->setMaxResults($request->query->getInt(\'limit\', 20))\n             ->setParameters([\n@@ -298,108 +297,119 @@ class General extends AsyncBase\n     }\n \n     /**\n-     * Get the news from either cache or Bolt HQ.\n+     * Get the news from Bolt HQ (with caching).\n      *\n      * @param string $hostname\n      *\n-     * @return array|string\n+     * @return array\n      */\n     private function getNews($hostname)\n     {\n-        /** @var \\Bolt\\Cache $cache */\n-        $cache = $this->app[\'cache\'];\n-\n         // Cached for two hours.\n-        $news = $cache->fetch(\'dashboardnews\');\n-\n-        // If not cached, get fresh news.\n+        $news = $this->app[\'cache\']->fetch(\'dashboardnews\');\n         if ($news !== false) {\n             $this->app[\'logger.system\']->info(\'Using cached data\', [\'event\' => \'news\']);\n \n             return $news;\n-        } else {\n-            $source = $this->getOption(\'general/branding/news_source\', \'http://news.bolt.cm/\');\n-            $curl = $this->getDashboardCurlOptions($hostname, $source);\n-\n-            $this->app[\'logger.system\']->info(\'Fetching from remote server: \' . $source, [\'event\' => \'news\']);\n-\n-            try {\n-                $fetchedNewsData = $this->app[\'guzzle.client\']->get($curl[\'url\'], [\'config\' => [\'curl\' => $curl[\'options\']]])->getBody(true);\n-                if ($this->getOption(\'general/branding/news_variable\')) {\n-                    $newsVariable = $this->getOption(\'general/branding/news_variable\');\n-                    $fetchedNewsItems = json_decode($fetchedNewsData)->$newsVariable;\n-                } else {\n-                    $fetchedNewsItems = json_decode($fetchedNewsData);\n-                }\n-                if ($fetchedNewsItems) {\n-                    $news = [];\n-\n-                    // Iterate over the items, pick the first news-item that\n-                    // applies and the first alert we need to show\n-                    foreach ($fetchedNewsItems as $item) {\n-                        $type = ($item->type === \'alert\') ? \'alert\' : \'information\';\n-                        if (!isset($news[$type])\n-                            && (empty($item->target_version) || Bolt\\Version::compare($item->target_version, \'>\'))\n-                        ) {\n-                            $news[$type] = $item;\n-                        }\n-                    }\n-\n-                    $cache->save(\'dashboardnews\', $news, 7200);\n-                } else {\n-                    $this->app[\'logger.system\']->error(\'Invalid JSON feed returned\', [\'event\' => \'news\']);\n-                }\n+        }\n+\n+        // If not cached, get fresh news.\n+        $news = $this->fetchNews($hostname);\n+\n+        $this->app[\'cache\']->save(\'dashboardnews\', $news, 7200);\n+\n+        return $news;\n+    }\n+\n+    /**\n+     * Get the news from Bolt HQ.\n+     *\n+     * @param string $hostname\n+     *\n+     * @return array\n+     */\n+    private function fetchNews($hostname)\n+    {\n+        $source = $this->getOption(\'general/branding/news_source\', \'http://news.bolt.cm/\');\n+        $options = $this->fetchNewsOptions($hostname);\n+\n+        $this->app[\'logger.system\']->info(\'Fetching from remote server: \' . $source, [\'event\' => \'news\']);\n+\n+        try {\n+            $fetchedNewsData = (string) $this->app[\'guzzle.client\']->get($source, $options)->getBody(true);\n+        } catch (RequestException $e) {\n+            $this->app[\'logger.system\']->error(\n+                \'Error occurred during newsfeed fetch\',\n+                [\'event\' => \'exception\', \'exception\' => $e]\n+            );\n+\n+            return [\n+                \'error\' => [\n+                    \'type\'   => \'error\',\n+                    \'title\'  => \'Unable to fetch news!\',\n+                    \'teaser\' => "<p>Unable to connect to $source</p>",\n+                ],\n+            ];\n+        }\n+\n+        $fetchedNewsItems = json_decode($fetchedNewsData);\n+        if ($newsVariable = $this->getOption(\'general/branding/news_variable\')) {\n+            $fetchedNewsItems = $fetchedNewsItems->$newsVariable;\n+        }\n+\n+        $news = [];\n+        if (!$fetchedNewsItems) {\n+            $this->app[\'logger.system\']->error(\'Invalid JSON feed returned\', [\'event\' => \'news\']);\n \n-                return $news;\n-            } catch (RequestException $e) {\n-                $this->app[\'logger.system\']->critical(\n-                    \'Error occurred during newsfeed fetch\',\n-                    [\'event\' => \'exception\', \'exception\' => $e]\n-                );\n+            return $news;\n+        }\n \n-                return [\'error\' => [\'type\' => \'error\', \'title\' => \'Unable to fetch news!\', \'teaser\' => "<p>Unable to connect to $source</p>"]];\n+        // Iterate over the items, pick the first news-item that\n+        // applies and the first alert we need to show\n+        foreach ($fetchedNewsItems as $item) {\n+            $type = ($item->type === \'alert\') ? \'alert\' : \'information\';\n+            if (!isset($news[$type])\n+                && (empty($item->target_version) || Bolt\\Version::compare($item->target_version, \'>\'))\n+            ) {\n+                $news[$type] = $item;\n             }\n         }\n+\n+        return $news;\n     }\n \n     /**\n-     * Get the cURL options.\n+     * Get the guzzle options.\n      *\n      * @param string $hostname\n-     * @param string $source\n      *\n      * @return array\n      */\n-    private function getDashboardCurlOptions($hostname, $source)\n+    private function fetchNewsOptions($hostname)\n     {\n         $driver = $this->app[\'db\']->getDatabasePlatform()->getName();\n \n-        $url = sprintf(\n-            \'%s?v=%s&p=%s&db=%s&name=%s\',\n-            $source,\n-            rawurlencode(Bolt\\Version::VERSION),\n-            phpversion(),\n-            $driver,\n-            base64_encode($hostname)\n-        );\n-\n-        // Standard option(s)\n-        $options = [CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10];\n+        $options = [\n+            \'query\' => [\n+                \'v\'    => Bolt\\Version::VERSION,\n+                \'p\'    => phpversion(),\n+                \'db\'   => $driver,\n+                \'name\' => $hostname,\n+            ],\n+            \'connect_timeout\' => 5,\n+            \'timeout\'         => 10,\n+        ];\n \n-        // Options valid if using a proxy\n         if ($this->getOption(\'general/httpProxy\')) {\n-            $proxies = [\n-                CURLOPT_PROXY        => $this->getOption(\'general/httpProxy/host\'),\n-                CURLOPT_PROXYTYPE    => CURLPROXY_HTTP,\n-                CURLOPT_PROXYUSERPWD => $this->getOption(\'general/httpProxy/user\') . \':\' .\n+            $options[\'proxy\'] = sprintf(\n+                \'%s:%s@%s\',\n+                $this->getOption(\'general/httpProxy/user\'),\n                 $this->getOption(\'general/httpProxy/password\'),\n-            ];\n+                $this->getOption(\'general/httpProxy/host\')\n+            );\n         }\n \n-        return [\n-            \'url\'     => $url,\n-            \'options\' => !empty($proxies) ? array_merge($options, $proxies) : $options,\n-        ];\n+        return $options;\n     }\n \n     /**\ndiff --git a/src/Legacy/Content.php b/src/Legacy/Content.php\nindex 0f729fa94..6d12f38ca 100644\n--- a/src/Legacy/Content.php\n+++ b/src/Legacy/Content.php\n@@ -148,6 +148,14 @@ class Content implements \\ArrayAccess\n \n             switch ($fieldtype) {\n                 case \'markdown\':\n+                    // Deprecated: This should be moved to a render function in\n+                    // Bolt\\Storage\\Field\\Type\\MarkdownType eventually.\n+                    $value = $this->app[\'markdown\']->text($this->values[$name]);\n+                    $value = $this->preParse($value, $allowtwig);\n+                    $value = new \\Twig_Markup($value, \'UTF-8\');\n+\n+                    break;\n+\n                 case \'html\':\n                 case \'text\':\n                 case \'textarea\':\ndiff --git a/src/Storage/Field/Sanitiser/Sanitiser.php b/src/Storage/Field/Sanitiser/Sanitiser.php\nindex a6da6e87b..6ec565edf 100644\n--- a/src/Storage/Field/Sanitiser/Sanitiser.php\n+++ b/src/Storage/Field/Sanitiser/Sanitiser.php\n@@ -92,7 +92,7 @@ class Sanitiser implements SanitiserInterface\n      * Return a list of allowed tags needed for WYSIWYG field types.\n      *\n      * For HTML fields we want to override a few tags, e.g, it makes\n-     * no sense to disallow `<embed>` if we have `embed: true` in\n+     * no sense to disallow `<iframe>` if we have `embed: true` in\n      * config.yml.\n      *\n      * @return array\n@@ -130,6 +130,11 @@ class Sanitiser implements SanitiserInterface\n         if ($this->isWysiwygEnabled(\'embed\')) {\n             // Note: Only <iframe>. Not <script>, <embed> or <object>.\n             $allowedBecauseWysiwyg[] = \'iframe\';\n+            // We also need to add a few attributes as well.\n+            $this->setAllowedAttributes(array_unique(array_merge(\n+                    $this->getAllowedAttributes(),\n+                    [\'src\', \'width\', \'height\', \'frameborder\', \'allowfullscreen\', \'scrolling\']))\n+            );\n         }\n         if ($this->isWysiwygEnabled(\'ruler\')) {\n             $allowedBecauseWysiwyg[] = \'hr\';\ndiff --git a/tests/phpunit/unit/Controller/Async/GeneralTest.php b/tests/phpunit/unit/Controller/Async/GeneralTest.php\nindex 857175f47..64395afe1 100644\n--- a/tests/phpunit/unit/Controller/Async/GeneralTest.php\n+++ b/tests/phpunit/unit/Controller/Async/GeneralTest.php\n@@ -80,19 +80,20 @@ class GeneralTest extends ControllerUnitTest\n \n         $changeRepository = $this->getService(\'storage\')->getRepository(\'Bolt\\Storage\\Entity\\LogChange\');\n         $systemRepository = $this->getService(\'storage\')->getRepository(\'Bolt\\Storage\\Entity\\LogSystem\');\n-        $logger = $this->getMock(\'Bolt\\Logger\\Manager\', [\'info\', \'critical\'], [$app, $changeRepository, $systemRepository]);\n+        $logger = $this->getMock(\'Bolt\\Logger\\Manager\', [\'info\', \'error\'], [$app, $changeRepository, $systemRepository]);\n \n         $logger->expects($this->at(1))\n-            ->method(\'critical\')\n+            ->method(\'error\')\n             ->with($this->stringContains(\'Error occurred\'));\n         $app[\'logger.system\'] = $logger;\n-        $response = $this->controller()->dashboardNews($this->getRequest());\n+        $this->controller()->dashboardNews($this->getRequest());\n     }\n \n     public function testDashboardNewsWithInvalidJson()\n     {\n         $this->setRequest(Request::create(\'/async/dashboardnews\'));\n         $app = $this->getApp();\n+        $app[\'cache\']->flushAll();\n         $testGuzzle = $this->getMock(\'GuzzleHttp\\Client\', [\'get\'], []);\n         $testRequest = $this->getMock(\'GuzzleHttp\\Message\', [\'getBody\']);\n         $testRequest->expects($this->any())\n@@ -111,7 +112,7 @@ class GeneralTest extends ControllerUnitTest\n             ->method(\'error\')\n             ->with($this->stringContains(\'Invalid JSON\'));\n         $app[\'logger.system\'] = $logger;\n-        $response = $this->controller()->dashboardNews($this->getRequest());\n+        $this->controller()->dashboardNews($this->getRequest());\n     }\n \n     public function testDashboardNewsWithVariable()\n', 'commit_subject': "Merge branch 'release/3.1' into release/3.2", 'repo_name': 'bolt/bolt', 'committer': {'name': 'Gawain Lynch', 'email': 'f745ec5336bcd096400627fc09f6ec7139408726@gmail.com', 'time_sec': '1474270445', 'tz_offset': '120', 'date': {'seconds': '1474270445'}}, 'commit_tree': 'e704e44e21b39d5f623e90414c656f389be6d46b', 'commit_message': "Merge branch 'release/3.1' into release/3.2\n", 'language_name': 'PHP', 'license': 'mit', 'hunks': "@@ -80,19 +80,20 @@ class GeneralTest extends ControllerUnitTest\n \n         $changeRepository = $this->getService('storage')->getRepository('Bolt\\Storage\\Entity\\LogChange');\n         $systemRepository = $this->getService('storage')->getRepository('Bolt\\Storage\\Entity\\LogSystem');\n-        $logger = $this->getMock('Bolt\\Logger\\Manager', ['info', 'critical'], [$app, $changeRepository, $systemRepository]);\n+        $logger = $this->getMock('Bolt\\Logger\\Manager', ['info', 'error'], [$app, $changeRepository, $systemRepository]);\n \n         $logger->expects($this->at(1))\n-            ->method('critical')\n+            ->method('error')\n             ->with($this->stringContains('Error occurred'));\n         $app['logger.system'] = $logger;\n-        $response = $this->controller()->dashboardNews($this->getRequest());\n+        $this->controller()->dashboardNews($this->getRequest());\n     }\n \n     public function testDashboardNewsWithInvalidJson()\n     {\n         $this->setRequest(Request::create('/async/dashboardnews'));\n         $app = $this->getApp();\n+        $app['cache']->flushAll();\n         $testGuzzle = $this->getMock('GuzzleHttp\\Client', ['get'], []);\n         $testRequest = $this->getMock('GuzzleHttp\\Message', ['getBody']);\n         $testRequest->expects($this->any())\n@@ -111,7 +112,7 @@ class GeneralTest extends ControllerUnitTest\n             ->method('error')\n             ->with($this->stringContains('Invalid JSON'));\n         $app['logger.system'] = $logger;\n-        $response = $this->controller()->dashboardNews($this->getRequest());\n+        $this->controller()->dashboardNews($this->getRequest());\n     }\n \n     public function testDashboardNewsWithVariable()\n", 'addition_count': 5, 'deletion_count': 4, 'src_file': 'a/tests/phpunit/unit/Controller/Async/GeneralTest.php', 'tgt_file': 'b/tests/phpunit/unit/Controller/Async/GeneralTest.php', 'file_extension': '.php', 'before_file': '<?php\n\nnamespace Bolt\\Tests\\Controller\\Async;\n\nuse Bolt\\Common\\Json;\nuse Bolt\\Controller\\Zone;\nuse Bolt\\Response\\TemplateView;\nuse Bolt\\Tests\\Controller\\ControllerUnitTest;\nuse GuzzleHttp\\Exception\\RequestException;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Symfony\\Component\\HttpFoundation\\JsonResponse;\nuse Symfony\\Component\\HttpFoundation\\Request;\nuse Symfony\\Component\\HttpFoundation\\Response;\nuse Symfony\\Component\\HttpKernel\\Event\\GetResponseEvent;\nuse Symfony\\Component\\HttpKernel\\HttpKernelInterface;\nuse Symfony\\Component\\HttpKernel\\KernelEvents;\n\n/**\n * Class to test correct operation of src/Controller/Async/General.\n *\n * @author Gawain Lynch <gawain.lynch@gmail.com>\n **/\nclass GeneralTest extends ControllerUnitTest\n{\n    /**\n     * @covers \\Bolt\\Controller\\Zone::get\n     * @covers \\Bolt\\Controller\\Zone::isAsync\n     */\n    public function testControllerZone()\n    {\n        $app = $this->getApp();\n        $this->allowLogin($app);\n        $this->setRequest(Request::create(\'/async\'));\n        $request = $this->getRequest();\n        $request->cookies->set($app[\'token.authentication.name\'], \'dropbear\');\n\n        $kernel = $this->createMock(HttpKernelInterface::class);\n        $app[\'dispatcher\']->dispatch(KernelEvents::REQUEST, new GetResponseEvent($kernel, $request, HttpKernelInterface::MASTER_REQUEST));\n\n        $this->assertEquals(\'async\', Zone::get($request));\n        $this->assertTrue(Zone::isAsync($request));\n    }\n\n    public function testAsyncBaseRoute()\n    {\n        $app = $this->getApp();\n        $this->allowLogin($app);\n        $this->setRequest(Request::create(\'/async\'));\n\n        $response = $this->controller()->async();\n\n        $this->assertJson($response->getContent());\n        $this->assertEquals(\'["OK"]\', $response->getContent());\n    }\n\n    public function testChangeLogRecord()\n    {\n        $this->setRequest(Request::create(\'/async/changelog/page/1\'));\n\n        $response = $this->controller()->changeLogRecord(\'pages\', 1);\n\n        $this->assertTrue($response instanceof TemplateView);\n        $this->assertSame(\'@bolt/components/panel-change-record.twig\', $response->getTemplate());\n    }\n\n    public function testDashboardNewsWithInvalidRequest()\n    {\n        $this->setRequest(Request::create(\'/async/dashboardnews\'));\n        $app = $this->getApp();\n        $testGuzzle = $this->getMockGuzzleClient();\n        $requestInterface = $this->createMock(RequestInterface::class);\n        $testGuzzle->expects($this->at(0))->method(\'get\')->will($this->throwException(new RequestException(\'Mock Fail\', $requestInterface)));\n\n        $this->setService(\'guzzle.client\', $testGuzzle);\n\n        $logger = $this->getMockLoggerManager();\n        $logger->expects($this->at(1))\n            ->method(\'error\')\n            ->with($this->stringContains(\'Error occurred\'));\n\n        $changeRepository = $this->getService(\'storage\')->getRepository(\'Bolt\\Storage\\Entity\\LogChange\');\n        $systemRepository = $this->getService(\'storage\')->getRepository(\'Bolt\\Storage\\Entity\\LogSystem\');\n        $logger = $this->getMock(\'Bolt\\Logger\\Manager\', [\'info\', \'critical\'], [$app, $changeRepository, $systemRepository]);\n\n        $logger->expects($this->at(1))\n            ->method(\'critical\')\n            ->with($this->stringContains(\'Error occurred\'));\n        $app[\'logger.system\'] = $logger;\n        $response = $this->controller()->dashboardNews($this->getRequest());\n    }\n\n    public function testDashboardNewsWithInvalidJson()\n    {\n        $this->setRequest(Request::create(\'/async/dashboardnews\'));\n        $app = $this->getApp();\n        $testGuzzle = $this->getMock(\'GuzzleHttp\\Client\', [\'get\'], []);\n        $testRequest = $this->getMock(\'GuzzleHttp\\Message\', [\'getBody\']);\n        $testRequest->expects($this->any())\n            ->with($this->stringContains(\'Invalid JSON\'));\n        $this->setService(\'logger.system\', $logger);\n        $this->controller()->dashboardNews($this->getRequest());\n    }\n\n    public function testDashboardNewsWithVariable()\n    {\n        $app = $this->getApp();\n        $app[\'cache\']->flushAll();\n        $this->setRequest(Request::create(\'/async/dashboardnews\'));\n        $app[\'config\']->set(\'general/branding/news_variable\', \'testing\');\n\n            ->method(\'error\')\n            ->with($this->stringContains(\'Invalid JSON\'));\n        $app[\'logger.system\'] = $logger;\n        $response = $this->controller()->dashboardNews($this->getRequest());\n    }\n\n    public function testDashboardNewsWithVariable()\n            ->method(\'get\')\n            ->will($this->returnValue($requestInterface))\n        ;\n        $this->setService(\'guzzle.client\', $testGuzzle);\n\n        $response = $this->controller()->dashboardNews($this->getRequest());\n\n        $context = $response->getContext();\n        $this->assertEquals([\'item\' => \'one\'], (array) $context[\'context\'][\'information\']);\n    }\n\n    public function testDashboardNews()\n    {\n        $this->setRequest(Request::create(\'/async/dashboardnews\'));\n\n        $response = $this->controller()->dashboardNews($this->getRequest());\n        $this->assertTrue($response instanceof TemplateView);\n        $this->assertSame(\'@bolt/components/panel-news.twig\', $response->getTemplate());\n    }\n\n    public function testLastModified()\n    {\n        $this->setRequest(Request::create(\'/async/lastmodified/page/1\'));\n\n        $response = $this->controller()->lastModified(\'page\', 1);\n\n        $this->assertTrue($response instanceof TemplateView);\n        $this->assertSame(\'@bolt/components/panel-lastmodified.twig\', $response->getTemplate());\n    }\n\n    public function testLatestactivity()\n    {\n        $this->setRequest(Request::create(\'/async/latestactivity\'));\n\n        $response = $this->controller()->latestActivity();\n\n        $this->assertTrue($response instanceof TemplateView);\n        $this->assertSame(\'@bolt/components/panel-activity.twig\', $response->getTemplate());\n    }\n\n    /**\n     * @covers \\Bolt\\Storage::getUri\n     * @covers \\Bolt\\Controller\\Async\\General::makeUri\n     */\n    public function testMakeUri()\n    {\n        // Set up a fake request for getContent()\'s sake\n        $this->setRequest(Request::create(\'/\'));\n        $record = $this->getService(\'storage\')->getContent(\'pages/1\');\n        $this->setRequest(Request::create(\'/async/makeuri\', \'GET\', [\n            \'title\'           => $record->values[\'title\'],\n            \'id\'              => $record->values[\'id\'],\n            \'contenttypeslug\' => \'pages\',\n            \'fulluri\'         => true,\n        ]));\n\n        $response = $this->controller()->makeUri($this->getRequest());\n\n        $this->assertSame(\'/page/\' . $record->values[\'slug\'], $response);\n    }\n\n    public function testOmnisearch()\n    {\n        $this->setRequest(Request::create(\'/async/omnisearch\', \'GET\', [\n            \'q\' => \'sho\',\n        ]));\n\n        $response = $this->controller()->omnisearch($this->getRequest());\n\n        $this->assertTrue($response instanceof JsonResponse);\n        $this->assertSame(Response::HTTP_OK, $response->getStatusCode());\n\n        $json = Json::parse($response->getContent());\n\n        $this->assertSame(\'Omnisearch\', $json[0][\'label\']);\n        $this->assertSame(\'New Showcase\', $json[1][\'label\']);\n        $this->assertSame(\'View Showcases\', $json[2][\'label\']);\n    }\n\n    public function testPopularTags()\n    {\n        $this->setRequest(Request::create(\'/async/populartags\'));\n\n        $response = $this->controller()->popularTags($this->getRequest(), \'tags\');\n\n        $this->assertTrue($response instanceof JsonResponse);\n        $this->assertSame(Response::HTTP_OK, $response->getStatusCode());\n\n        $json = Json::parse($response->getContent());\n        $tags = $this->getDefaultTags();\n\n        $this->assertCount(20, $json);\n        $this->assertTrue(in_array($json[0][\'name\'], $tags));\n    }\n\n//    public function testTags()\n//    {\n//         $this->setRequest(Request::create(\'/async/tags/tags\'));\n//         $response = $this->controller()->tags($this->getRequest(), \'tags\');\n//\n//         $this->assertTrue($response instanceof JsonResponse);\n//         $this->assertSame(Response::HTTP_OK, $response->getStatusCode());\n//\n//         $json = Json::parse($response->getContent());\n//         $tags = $this->getDefaultTags();\n//\n//         $this->assertCount(20, $json);\n//         $this->assertTrue(in_array($json[0][\'name\'], $tags));\n//    }\n\n    /**\n     * @return \\Bolt\\Controller\\Async\\General\n     */\n    protected function controller()\n    {\n        return $this->getService(\'controller.async.general\');\n    }\n\n    private function getDefaultTags()\n    {\n        return [\'action\', \'adult\', \'adventure\', \'alpha\', \'animals\', \'animation\', \'anime\', \'architecture\', \'art\',\n            \'astronomy\', \'baby\', \'batshitinsane\', \'biography\', \'biology\', \'book\', \'books\', \'business\', \'business\',\n            \'camera\', \'cars\', \'cats\', \'cinema\', \'classic\', \'comedy\', \'comics\', \'computers\', \'cookbook\', \'cooking\',\n            \'crime\', \'culture\', \'dark\', \'design\', \'digital\', \'documentary\', \'dogs\', \'drama\', \'drugs\', \'education\',\n            \'environment\', \'evolution\', \'family\', \'fantasy\', \'fashion\', \'fiction\', \'film\', \'fitness\', \'food\',\n            \'football\', \'fun\', \'gaming\', \'gift\', \'health\', \'hip\', \'historical\', \'history\', \'horror\', \'humor\',\n            \'illustration\', \'inspirational\', \'internet\', \'journalism\', \'kids\', \'language\', \'law\', \'literature\', \'love\',\n            \'magic\', \'math\', \'media\', \'medicine\', \'military\', \'money\', \'movies\', \'mp3\', \'murder\', \'music\', \'mystery\',\n            \'news\', \'nonfiction\', \'nsfw\', \'paranormal\', \'parody\', \'philosophy\', \'photography\', \'photos\', \'physics\',\n            \'poetry\', \'politics\', \'post-apocalyptic\', \'privacy\', \'psychology\', \'radio\', \'relationships\', \'research\',\n            \'rock\', \'romance\', \'rpg\', \'satire\', \'science\', \'sciencefiction\', \'scifi\', \'security\', \'self-help\',\n            \'series\', \'software\', \'space\', \'spirituality\', \'sports\', \'story\', \'suspense\', \'technology\', \'teen\',\n            \'television\', \'terrorism\', \'thriller\', \'travel\', \'tv\', \'uk\', \'urban\', \'us\', \'usa\', \'vampire\', \'video\',\n            \'videogames\', \'war\', \'web\', \'women\', \'world\', \'writing\', \'wtf\', \'zombies\', ];\n    }\n}\n', 'message': "Merge branch 'release/3.1' into release/3.2\n", 'author_name': 'Gawain Lynch', 'lang': 'php'}