]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #687 from ArthurHoaro/web-thumb
authorArthurHoaro <arthur@hoa.ro>
Sat, 28 Jul 2018 07:41:29 +0000 (09:41 +0200)
committerGitHub <noreply@github.com>
Sat, 28 Jul 2018 07:41:29 +0000 (09:41 +0200)
Use web-thumbnailer to retrieve thumbnails

34 files changed:
application/PageBuilder.php
application/Router.php
application/Thumbnailer.php [new file with mode: 0644]
application/Updater.php
application/config/ConfigManager.php
assets/common/js/picwall.js [deleted file]
assets/common/js/thumbnails-update.js [new file with mode: 0644]
assets/common/js/thumbnails.js [new file with mode: 0644]
assets/default/scss/shaarli.scss
assets/vintage/css/shaarli.css
composer.json
composer.lock
doc/md/Link-structure.md [new file with mode: 0644]
doc/md/Server-configuration.md
inc/languages/fr/LC_MESSAGES/shaarli.po
inc/web-thumbnailer.json [new file with mode: 0644]
index.php
mkdocs.yml
tests/ThumbnailerTest.php [new file with mode: 0644]
tests/Updater/UpdaterTest.php
tests/config/ConfigManagerTest.php
tests/utils/config/configJson.json.php
tests/utils/config/wt.json [new file with mode: 0644]
tpl/default/configure.html
tpl/default/linklist.html
tpl/default/page.header.html
tpl/default/picwall.html
tpl/default/thumbnails.html [new file with mode: 0644]
tpl/default/tools.html
tpl/vintage/configure.html
tpl/vintage/linklist.html
tpl/vintage/picwall.html
tpl/vintage/thumbnails.html [new file with mode: 0644]
webpack.config.js

index a4483870497961a210f82fa619f730f207c7d601..b1abe0d05fd724182cea8a0ecda659f93734f6c2 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Shaarli\Config\ConfigManager;
+use Shaarli\Thumbnailer;
 
 /**
  * This class is in charge of building the final page.
@@ -21,11 +22,21 @@ class PageBuilder
      */
     protected $conf;
 
+    /**
+     * @var array $_SESSION
+     */
+    protected $session;
+
     /**
      * @var LinkDB $linkDB instance.
      */
     protected $linkDB;
-    
+
+    /**
+     * @var null|string XSRF token
+     */
+    protected $token;
+
     /** @var bool $isLoggedIn Whether the user is logged in **/
     protected $isLoggedIn = false;
 
@@ -33,14 +44,17 @@ class PageBuilder
      * PageBuilder constructor.
      * $tpl is initialized at false for lazy loading.
      *
-     * @param ConfigManager $conf   Configuration Manager instance (reference).
-     * @param LinkDB        $linkDB instance.
-     * @param string        $token  Session token
+     * @param ConfigManager $conf       Configuration Manager instance (reference).
+     * @param array         $session    $_SESSION array
+     * @param LinkDB        $linkDB     instance.
+     * @param string        $token      Session token
+     * @param bool          $isLoggedIn
      */
-    public function __construct(&$conf, $linkDB = null, $token = null, $isLoggedIn = false)
+    public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
     {
         $this->tpl = false;
         $this->conf = $conf;
+        $this->session = $session;
         $this->linkDB = $linkDB;
         $this->token = $token;
         $this->isLoggedIn = $isLoggedIn;
@@ -105,6 +119,19 @@ class PageBuilder
         if ($this->linkDB !== null) {
             $this->tpl->assign('tags', $this->linkDB->linksCountPerTag());
         }
+
+        $this->tpl->assign(
+            'thumbnails_enabled',
+            $this->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
+        );
+        $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
+        $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
+
+        if (! empty($_SESSION['warnings'])) {
+            $this->tpl->assign('global_warnings', $_SESSION['warnings']);
+            unset($_SESSION['warnings']);
+        }
+
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
     }
index 4df0387c56160f0f4a6998986fb6497d8f745139..bf86b884db07243a215c86748d5f97cc9c1b811c 100644 (file)
@@ -7,6 +7,8 @@
  */
 class Router
 {
+    public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
+
     public static $PAGE_LOGIN = 'login';
 
     public static $PAGE_PICWALL = 'picwall';
@@ -47,6 +49,8 @@ class Router
 
     public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
 
+    public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
+
     public static $GET_TOKEN = 'token';
 
     /**
@@ -101,6 +105,14 @@ class Router
             return self::$PAGE_FEED_RSS;
         }
 
+        if (startsWith($query, 'do='. self::$PAGE_THUMBS_UPDATE)) {
+            return self::$PAGE_THUMBS_UPDATE;
+        }
+
+        if (startsWith($query, 'do='. self::$AJAX_THUMB_UPDATE)) {
+            return self::$AJAX_THUMB_UPDATE;
+        }
+
         // At this point, only loggedin pages.
         if (!$loggedIn) {
             return self::$PAGE_LINKLIST;
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php
new file mode 100644 (file)
index 0000000..7d0d9c3
--- /dev/null
@@ -0,0 +1,127 @@
+<?php
+
+namespace Shaarli;
+
+use Shaarli\Config\ConfigManager;
+use WebThumbnailer\Exception\WebThumbnailerException;
+use WebThumbnailer\WebThumbnailer;
+use WebThumbnailer\Application\ConfigManager as WTConfigManager;
+
+/**
+ * Class Thumbnailer
+ *
+ * Utility class used to retrieve thumbnails using web-thumbnailer dependency.
+ */
+class Thumbnailer
+{
+    const COMMON_MEDIA_DOMAINS = [
+        'imgur.com',
+        'flickr.com',
+        'youtube.com',
+        'wikimedia.org',
+        'redd.it',
+        'gfycat.com',
+        'media.giphy.com',
+        'twitter.com',
+        'twimg.com',
+        'instagram.com',
+        'pinterest.com',
+        'pinterest.fr',
+        'tumblr.com',
+        'deviantart.com',
+    ];
+
+    const MODE_ALL = 'all';
+    const MODE_COMMON = 'common';
+    const MODE_NONE = 'none';
+
+    /**
+     * @var WebThumbnailer instance.
+     */
+    protected $wt;
+
+    /**
+     * @var ConfigManager instance.
+     */
+    protected $conf;
+
+    /**
+     * Thumbnailer constructor.
+     *
+     * @param ConfigManager $conf instance.
+     */
+    public function __construct($conf)
+    {
+        $this->conf = $conf;
+
+        if (! $this->checkRequirements()) {
+            $this->conf->set('thumbnails.enabled', false);
+            $this->conf->write(true);
+            // TODO: create a proper error handling system able to catch exceptions...
+            die(t('php-gd extension must be loaded to use thumbnails. Thumbnails are now disabled. Please reload the page.'));
+        }
+
+        $this->wt = new WebThumbnailer();
+        WTConfigManager::addFile('inc/web-thumbnailer.json');
+        $this->wt->maxWidth($this->conf->get('thumbnails.width'))
+                 ->maxHeight($this->conf->get('thumbnails.height'))
+                 ->crop(true)
+                 ->debug($this->conf->get('dev.debug', false));
+    }
+
+    /**
+     * Retrieve a thumbnail for given URL
+     *
+     * @param string $url where to look for a thumbnail.
+     *
+     * @return bool|string The thumbnail relative cache file path, or false if none has been found.
+     */
+    public function get($url)
+    {
+        if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
+            && ! $this->isCommonMediaOrImage($url)
+        ) {
+            return false;
+        }
+
+        try {
+            return $this->wt->thumbnail($url);
+        } catch (WebThumbnailerException $e) {
+            // Exceptions are only thrown in debug mode.
+            error_log(get_class($e) . ': ' . $e->getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * We check weather the given URL is from a common media domain,
+     * or if the file extension is an image.
+     *
+     * @param string $url to check
+     *
+     * @return bool true if it's an image or from a common media domain, false otherwise.
+     */
+    public function isCommonMediaOrImage($url)
+    {
+        foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
+            if (strpos($url, $domain) !== false) {
+                return true;
+            }
+        }
+
+        if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Make sure that requirements are match to use thumbnails:
+     *   - php-gd is loaded
+     */
+    protected function checkRequirements()
+    {
+        return extension_loaded('gd');
+    }
+}
index dece2c020d297fdf13bf8773b76cb37895328025..c2aa1568cdc01534a3e1efd885e494536884342e 100644 (file)
@@ -2,6 +2,7 @@
 use Shaarli\Config\ConfigJson;
 use Shaarli\Config\ConfigPhp;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Thumbnailer;
 
 /**
  * Class Updater.
@@ -30,6 +31,11 @@ class Updater
      */
     protected $isLoggedIn;
 
+    /**
+     * @var array $_SESSION
+     */
+    protected $session;
+
     /**
      * @var ReflectionMethod[] List of current class methods.
      */
@@ -42,13 +48,17 @@ class Updater
      * @param LinkDB        $linkDB      LinkDB instance.
      * @param ConfigManager $conf        Configuration Manager instance.
      * @param boolean       $isLoggedIn  True if the user is logged in.
+     * @param array         $session     $_SESSION (by reference)
+     *
+     * @throws ReflectionException
      */
-    public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
+    public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
     {
         $this->doneUpdates = $doneUpdates;
         $this->linkDB = $linkDB;
         $this->conf = $conf;
         $this->isLoggedIn = $isLoggedIn;
+        $this->session = &$session;
 
         // Retrieve all update methods.
         $class = new ReflectionClass($this);
@@ -480,6 +490,30 @@ class Updater
         }
 
         $this->conf->write($this->isLoggedIn);
+        return true;
+    }
+
+    /**
+     * * Move thumbnails management to WebThumbnailer, coming with new settings.
+     */
+    public function updateMethodWebThumbnailer()
+    {
+        if ($this->conf->exists('thumbnails.mode')) {
+            return true;
+        }
+
+        $thumbnailsEnabled = $this->conf->get('thumbnail.enable_thumbnails', true);
+        $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE);
+        $this->conf->set('thumbnails.width', 125);
+        $this->conf->set('thumbnails.height', 90);
+        $this->conf->remove('thumbnail');
+        $this->conf->write(true);
+
+        if ($thumbnailsEnabled) {
+            $this->session['warnings'][] = t(
+                'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
+            );
+        }
 
         return true;
     }
index 82f4a368e669d57316884a1deec581841813214f..32aaea48e870ab13af7714c5a53d84cf0356d613 100644 (file)
@@ -147,6 +147,33 @@ class ConfigManager
         }
     }
 
+    /**
+     * Remove a config element from the config file.
+     *
+     * @param string $setting    Asked setting, keys separated with dots.
+     * @param bool   $write      Write the new setting in the config file, default false.
+     * @param bool   $isLoggedIn User login state, default false.
+     *
+     * @throws \Exception Invalid
+     */
+    public function remove($setting, $write = false, $isLoggedIn = false)
+    {
+        if (empty($setting) || ! is_string($setting)) {
+            throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
+        }
+
+        // During the ConfigIO transition, map legacy settings to the new ones.
+        if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
+            $setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
+        }
+
+        $settings = explode('.', $setting);
+        self::removeConfig($settings, $this->loadedConfig);
+        if ($write) {
+            $this->write($isLoggedIn);
+        }
+    }
+
     /**
      * Check if a settings exists.
      *
@@ -272,7 +299,7 @@ class ConfigManager
      *
      * @param array $settings Ordered array which contains keys to find.
      * @param mixed $value
-     * @param array $conf   Loaded settings, then sub-array.
+     * @param array $conf     Loaded settings, then sub-array.
      *
      * @return mixed Found setting or NOT_FOUND flag.
      */
@@ -289,6 +316,27 @@ class ConfigManager
         $conf[$setting] = $value;
     }
 
+    /**
+     * Recursive function which find asked setting in the loaded config and deletes it.
+     *
+     * @param array $settings Ordered array which contains keys to find.
+     * @param array $conf     Loaded settings, then sub-array.
+     *
+     * @return mixed Found setting or NOT_FOUND flag.
+     */
+    protected static function removeConfig($settings, &$conf)
+    {
+        if (!is_array($settings) || count($settings) == 0) {
+            return self::$NOT_FOUND;
+        }
+
+        $setting = array_shift($settings);
+        if (count($settings) > 0) {
+            return self::removeConfig($settings, $conf[$setting]);
+        }
+        unset($conf[$setting]);
+    }
+
     /**
      * Set a bunch of default values allowing Shaarli to start without a config file.
      */
@@ -333,12 +381,12 @@ class ConfigManager
         // default state of the 'remember me' checkbox of the login form
         $this->setEmpty('privacy.remember_user_default', true);
 
-        $this->setEmpty('thumbnail.enable_thumbnails', true);
-        $this->setEmpty('thumbnail.enable_localcache', true);
-
         $this->setEmpty('redirector.url', '');
         $this->setEmpty('redirector.encode_url', true);
 
+        $this->setEmpty('thumbnails.width', '125');
+        $this->setEmpty('thumbnails.height', '90');
+
         $this->setEmpty('translation.language', 'auto');
         $this->setEmpty('translation.mode', 'php');
         $this->setEmpty('translation.extensions', []);
diff --git a/assets/common/js/picwall.js b/assets/common/js/picwall.js
deleted file mode 100644 (file)
index 87a93fc..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-import Blazy from 'blazy';
-
-(() => {
-  const picwall = document.getElementById('picwall_container');
-  if (picwall != null) {
-    // Suppress ESLint error because that's how bLazy works
-    /* eslint-disable no-new */
-    new Blazy();
-  }
-})();
diff --git a/assets/common/js/thumbnails-update.js b/assets/common/js/thumbnails-update.js
new file mode 100644 (file)
index 0000000..b66ca3a
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Script used in the thumbnails update page.
+ *
+ * It retrieves the list of link IDs to update, and execute AJAX requests
+ * to update their thumbnails, while updating the progress bar.
+ */
+
+/**
+ * Update the thumbnail of the link with the current i index in ids.
+ * It contains a recursive call to retrieve the thumb of the next link when it succeed.
+ * It also update the progress bar and other visual feedback elements.
+ *
+ * @param {array}  ids      List of LinkID to update
+ * @param {int}    i        Current index in ids
+ * @param {object} elements List of DOM element to avoid retrieving them at each iteration
+ */
+function updateThumb(ids, i, elements) {
+  const xhr = new XMLHttpRequest();
+  xhr.open('POST', '?do=ajax_thumb_update');
+  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+  xhr.responseType = 'json';
+  xhr.onload = () => {
+    if (xhr.status !== 200) {
+      alert(`An error occurred. Return code: ${xhr.status}`);
+    } else {
+      const { response } = xhr;
+      i += 1;
+      elements.progressBar.style.width = `${(i * 100) / ids.length}%`;
+      elements.current.innerHTML = i;
+      elements.title.innerHTML = response.title;
+      if (response.thumbnail !== false) {
+        elements.thumbnail.innerHTML = `<img src="${response.thumbnail}">`;
+      }
+      if (i < ids.length) {
+        updateThumb(ids, i, elements);
+      }
+    }
+  };
+  xhr.send(`id=${ids[i]}`);
+}
+
+(() => {
+  const ids = document.getElementsByName('ids')[0].value.split(',');
+  const elements = {
+    progressBar: document.querySelector('.progressbar > div'),
+    current: document.querySelector('.progress-current'),
+    thumbnail: document.querySelector('.thumbnail-placeholder'),
+    title: document.querySelector('.thumbnail-link-title'),
+  };
+  updateThumb(ids, 0, elements);
+})();
diff --git a/assets/common/js/thumbnails.js b/assets/common/js/thumbnails.js
new file mode 100644 (file)
index 0000000..c28322b
--- /dev/null
@@ -0,0 +1,7 @@
+import Blazy from 'blazy';
+
+(() => {
+  // Suppress ESLint error because that's how bLazy works
+  /* eslint-disable no-new */
+  new Blazy();
+})();
index 09d5efbefb19a0f1e6fb60ff3e664927b574203d..6b286f1ecdebdea0893f6a33ea04da1073472c52 100644 (file)
@@ -146,6 +146,17 @@ body,
   background-color: $main-green;
 }
 
+.pure-alert-warning {
+  a {
+    color: $warning-text;
+    font-weight: bold;
+  }
+}
+
+.page-single-alert {
+  margin-top: 100px;
+}
+
 .anchor {
   &:target {
     padding-top: 40px;
@@ -625,23 +636,22 @@ body,
 }
 
 .linklist-item {
+  position: relative;
   margin: 0 0 10px;
   box-shadow: 1px 1px 3px $light-grey;
   background: $almost-white;
 
   &.private {
-    .linklist-item-title {
-      &::before {
-        @extend %private-border;
-        margin-top: 3px;
-      }
-    }
-
-    .linklist-item-description {
-      &::before {
-        @extend %private-border;
-        height: 100%;
-      }
+    &::before {
+      display: block;
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 1;
+      background: $orange;
+      width: 2px;
+      height: 100%;
+      content: '';
     }
   }
 }
@@ -1543,3 +1553,40 @@ form {
 .pure-button-shaarli {
   background-color: $main-green;
 }
+
+.progressbar {
+  border-radius: 6px;
+  background-color: $main-green;
+  padding: 1px;
+
+  > div {
+    border-radius: 10px;
+    background: repeating-linear-gradient(
+      -45deg,
+      $almost-white,
+      $almost-white 6px,
+      $background-color 6px,
+      $background-color 12px
+    );
+    width: 0%;
+    height: 10px;
+  }
+}
+
+.thumbnails-page-container {
+  .progress-counter {
+    padding: 10px 0 20px;
+  }
+
+  .thumbnail-placeholder {
+    margin: 10px auto;
+    background-color: $light-grey;
+  }
+
+  .thumbnail-link-title {
+    padding-bottom: 20px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
index c919339b95ca3b10f064227fcf029add52135540..87c440c86f702958224c7c5a334802ca7f817196 100644 (file)
@@ -701,8 +701,8 @@ a.bigbutton, #pageheader a.bigbutton {
     position: relative;
     display: table-cell;
     vertical-align: middle;
-    width: 90px;
-    height: 90px;
+    width: 120px;
+    height: 120px;
     overflow: hidden;
     text-align: center;
     float: left;
@@ -739,9 +739,9 @@ a.bigbutton, #pageheader a.bigbutton {
     position: absolute;
     top: 0;
     left: 0;
-    width: 90px;
+    width: 120px;
     font-weight: bold;
-    font-size: 8pt;
+    font-size: 9pt;
     color: #fff;
     text-align: left;
     background-color: transparent;
@@ -1210,3 +1210,43 @@ ul.errors {
     width: 13px;
     height: 13px;
 }
+
+.thumbnails-update-container {
+    padding: 20px 0;
+    width: 50%;
+    margin: auto;
+}
+
+.thumbnails-update-container .thumbnail-placeholder {
+    background: grey;
+    margin: auto;
+}
+
+.thumbnails-update-container .thumbnail-link-title {
+    width: 75%;
+    margin: auto;
+
+    padding-bottom: 20px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.progressbar {
+    border-radius: 6px;
+    background-color: #111;
+    padding: 1px;
+}
+
+.progressbar > div {
+    border-radius: 10px;
+    background: repeating-linear-gradient(
+        -45deg,
+        #f5f5f5,
+        #f5f5f5 6px,
+        #d0d0d0 6px,
+        #d0d0d0 12px
+    );
+    width: 0%;
+    height: 10px;
+}
index 0d4c623c8b181bc2aa5a7433de4a40290b6b3420..99ef0b5e6da82c9799f4ac583e5bd750a274b89a 100644 (file)
@@ -19,6 +19,7 @@
         "shaarli/netscape-bookmark-parser": "^2.0",
         "erusev/parsedown": "^1.6",
         "slim/slim": "^3.0",
+        "arthurhoaro/web-thumbnailer": "^1.1",
         "pubsubhubbub/publisher": "dev-master",
         "gettext/gettext": "^4.4"
     },
index ee762c0e7f2df026045d272aa7295ba2615cf7a0..08e915cf14b0523c33276ade15dec1a1f92abfa2 100644 (file)
@@ -1,11 +1,59 @@
 {
     "_readme": [
         "This file locks the dependencies of your project to a known state",
-        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "308a35eab91602fbb449f2c669c445ed",
+    "content-hash": "da7a0c081b61d949154c5d2e5370cbab",
     "packages": [
+        {
+            "name": "arthurhoaro/web-thumbnailer",
+            "version": "v1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ArthurHoaro/web-thumbnailer.git",
+                "reference": "a5a52f69e8e8f3c71fab9649e2a927e2d3f418f1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ArthurHoaro/web-thumbnailer/zipball/a5a52f69e8e8f3c71fab9649e2a927e2d3f418f1",
+                "reference": "a5a52f69e8e8f3c71fab9649e2a927e2d3f418f1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6",
+                "phpunit/php-text-template": "^1.2"
+            },
+            "conflict": {
+                "phpunit/php-timer": ">=2"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.0",
+                "phpunit/phpunit": "5.2.*",
+                "squizlabs/php_codesniffer": "^3.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "WebThumbnailer\\": [
+                        "src/",
+                        "tests/"
+                    ]
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Arthur Hoaro",
+                    "homepage": "http://hoa.ro"
+                }
+            ],
+            "description": "PHP library which will retrieve a thumbnail for any given URL",
+            "time": "2018-07-17T10:21:14+00:00"
+        },
         {
             "name": "container-interop/container-interop",
             "version": "1.2.0",
         },
         {
             "name": "gettext/gettext",
-            "version": "v4.4.4",
+            "version": "v4.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/oscarotero/Gettext.git",
-                "reference": "ab5e863de2f60806d02e6e6081e21efd45249168"
+                "reference": "cae84aff39a87e07bd6e5cddb5adb720a0ffa357"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/oscarotero/Gettext/zipball/ab5e863de2f60806d02e6e6081e21efd45249168",
-                "reference": "ab5e863de2f60806d02e6e6081e21efd45249168",
+                "url": "https://api.github.com/repos/oscarotero/Gettext/zipball/cae84aff39a87e07bd6e5cddb5adb720a0ffa357",
+                "reference": "cae84aff39a87e07bd6e5cddb5adb720a0ffa357",
                 "shasum": ""
             },
             "require": {
             },
             "require-dev": {
                 "illuminate/view": "*",
-                "phpunit/phpunit": "^4.8|^5.7",
+                "phpunit/phpunit": "^4.8|^5.7|^6.5",
                 "squizlabs/php_codesniffer": "^3.0",
                 "symfony/yaml": "~2",
                 "twig/extensions": "*",
                 "po",
                 "translation"
             ],
-            "time": "2018-02-21T18:49:59+00:00"
+            "time": "2018-06-26T16:51:09+00:00"
         },
         {
             "name": "gettext/languages",
-            "version": "2.3.0",
+            "version": "2.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/mlocati/cldr-to-gettext-plural-rules.git",
-                "reference": "49c39e51569963cc917a924b489e7025bfb9d8c7"
+                "reference": "1b74377bd0c4cd87e8d72b948f5d8867e23505a5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/mlocati/cldr-to-gettext-plural-rules/zipball/49c39e51569963cc917a924b489e7025bfb9d8c7",
-                "reference": "49c39e51569963cc917a924b489e7025bfb9d8c7",
+                "url": "https://api.github.com/repos/mlocati/cldr-to-gettext-plural-rules/zipball/1b74377bd0c4cd87e8d72b948f5d8867e23505a5",
+                "reference": "1b74377bd0c4cd87e8d72b948f5d8867e23505a5",
                 "shasum": ""
             },
             "require": {
                 "translations",
                 "unicode"
             ],
-            "time": "2017-03-23T17:02:28+00:00"
+            "time": "2018-06-21T15:58:36+00:00"
         },
         {
             "name": "katzgrau/klogger",
             ],
             "time": "2018-02-13T20:26:39+00:00"
         },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "1.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "time": "2015-06-21T13:50:34+00:00"
+        },
         {
             "name": "pimple/pimple",
             "version": "v3.2.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/pubsubhubbub/php-publisher.git",
-                "reference": "0d224daebd504ab61c22fee4db58f8d1fc18945f"
+                "reference": "5008fc529b057251b48f4d17a10fdb20047ea8f5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/pubsubhubbub/php-publisher/zipball/0d224daebd504ab61c22fee4db58f8d1fc18945f",
-                "reference": "0d224daebd504ab61c22fee4db58f8d1fc18945f",
+                "url": "https://api.github.com/repos/pubsubhubbub/php-publisher/zipball/5008fc529b057251b48f4d17a10fdb20047ea8f5",
+                "reference": "5008fc529b057251b48f4d17a10fdb20047ea8f5",
                 "shasum": ""
             },
             "require": {
                 "publishers",
                 "pubsubhubbub"
             ],
-            "time": "2017-10-08T10:59:41+00:00"
+            "time": "2018-05-22T11:56:26+00:00"
         },
         {
             "name": "shaarli/netscape-bookmark-parser",
         },
         {
             "name": "slim/slim",
-            "version": "3.9.2",
+            "version": "3.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/slimphp/Slim.git",
-                "reference": "4086d0106cf5a7135c69fce4161fe355a8feb118"
+                "reference": "d8aabeacc3688b25e2f2dd2db91df91ec6fdd748"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/slimphp/Slim/zipball/4086d0106cf5a7135c69fce4161fe355a8feb118",
-                "reference": "4086d0106cf5a7135c69fce4161fe355a8feb118",
+                "url": "https://api.github.com/repos/slimphp/Slim/zipball/d8aabeacc3688b25e2f2dd2db91df91ec6fdd748",
+                "reference": "d8aabeacc3688b25e2f2dd2db91df91ec6fdd748",
                 "shasum": ""
             },
             "require": {
                 "micro",
                 "router"
             ],
-            "time": "2017-11-26T19:13:09+00:00"
+            "time": "2018-04-19T19:29:08+00:00"
         }
     ],
     "packages-dev": [
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.7.5",
+            "version": "1.7.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "dfd6be44111a7c41c2e884a336cc4f461b3b2401"
+                "reference": "33a7e3c4fda54e912ff6338c48823bd5c0f0b712"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/dfd6be44111a7c41c2e884a336cc4f461b3b2401",
-                "reference": "dfd6be44111a7c41c2e884a336cc4f461b3b2401",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/33a7e3c4fda54e912ff6338c48823bd5c0f0b712",
+                "reference": "33a7e3c4fda54e912ff6338c48823bd5c0f0b712",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.0.2",
                 "php": "^5.3|^7.0",
                 "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0",
-                "sebastian/comparator": "^1.1|^2.0",
+                "sebastian/comparator": "^1.1|^2.0|^3.0",
                 "sebastian/recursion-context": "^1.0|^2.0|^3.0"
             },
             "require-dev": {
                 "spy",
                 "stub"
             ],
-            "time": "2018-02-19T10:16:54+00:00"
+            "time": "2018-04-18T13:57:24+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
             ],
             "time": "2017-11-27T13:52:08+00:00"
         },
-        {
-            "name": "phpunit/php-text-template",
-            "version": "1.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-text-template.git",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Simple template engine.",
-            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
-            "keywords": [
-                "template"
-            ],
-            "time": "2015-06-21T13:50:34+00:00"
-        },
         {
             "name": "phpunit/php-timer",
             "version": "1.0.9",
         },
         {
             "name": "symfony/config",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/config.git",
-                "reference": "05e10567b529476a006b00746c5f538f1636810e"
+                "reference": "1fffdeb349ff36a25184e5564c25289b1dbfc402"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/config/zipball/05e10567b529476a006b00746c5f538f1636810e",
-                "reference": "05e10567b529476a006b00746c5f538f1636810e",
+                "url": "https://api.github.com/repos/symfony/config/zipball/1fffdeb349ff36a25184e5564c25289b1dbfc402",
+                "reference": "1fffdeb349ff36a25184e5564c25289b1dbfc402",
                 "shasum": ""
             },
             "require": {
                 "php": "^5.5.9|>=7.0.8",
-                "symfony/filesystem": "~2.8|~3.0|~4.0"
+                "symfony/filesystem": "~2.8|~3.0|~4.0",
+                "symfony/polyfill-ctype": "~1.8"
             },
             "conflict": {
                 "symfony/dependency-injection": "<3.3",
             ],
             "description": "Symfony Config Component",
             "homepage": "https://symfony.com",
-            "time": "2018-02-14T10:03:57+00:00"
+            "time": "2018-06-19T14:02:58+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "067339e9b8ec30d5f19f5950208893ff026b94f7"
+                "reference": "1b97071a26d028c9bd4588264e101e14f6e7cd00"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/067339e9b8ec30d5f19f5950208893ff026b94f7",
-                "reference": "067339e9b8ec30d5f19f5950208893ff026b94f7",
+                "url": "https://api.github.com/repos/symfony/console/zipball/1b97071a26d028c9bd4588264e101e14f6e7cd00",
+                "reference": "1b97071a26d028c9bd4588264e101e14f6e7cd00",
                 "shasum": ""
             },
             "require": {
                 "symfony/process": "~3.3|~4.0"
             },
             "suggest": {
-                "psr/log": "For using the console logger",
+                "psr/log-implementation": "For using the console logger",
                 "symfony/event-dispatcher": "",
                 "symfony/lock": "",
                 "symfony/process": ""
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2018-02-26T15:46:28+00:00"
+            "time": "2018-05-23T05:02:55+00:00"
         },
         {
             "name": "symfony/debug",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/debug.git",
-                "reference": "9b1071f86e79e1999b3d3675d2e0e7684268b9bc"
+                "reference": "47e6788c5b151cf0cfdf3329116bf33800632d75"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/9b1071f86e79e1999b3d3675d2e0e7684268b9bc",
-                "reference": "9b1071f86e79e1999b3d3675d2e0e7684268b9bc",
+                "url": "https://api.github.com/repos/symfony/debug/zipball/47e6788c5b151cf0cfdf3329116bf33800632d75",
+                "reference": "47e6788c5b151cf0cfdf3329116bf33800632d75",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Debug Component",
             "homepage": "https://symfony.com",
-            "time": "2018-02-28T21:49:22+00:00"
+            "time": "2018-06-25T11:10:40+00:00"
         },
         {
             "name": "symfony/dependency-injection",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dependency-injection.git",
-                "reference": "12e901abc1cb0d637a0e5abe9923471361d96b07"
+                "reference": "a0be80e3f8c11aca506e250c00bb100c04c35d10"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/12e901abc1cb0d637a0e5abe9923471361d96b07",
-                "reference": "12e901abc1cb0d637a0e5abe9923471361d96b07",
+                "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a0be80e3f8c11aca506e250c00bb100c04c35d10",
+                "reference": "a0be80e3f8c11aca506e250c00bb100c04c35d10",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony DependencyInjection Component",
             "homepage": "https://symfony.com",
-            "time": "2018-03-04T03:54:53+00:00"
+            "time": "2018-06-25T08:36:56+00:00"
         },
         {
             "name": "symfony/filesystem",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "253a4490b528597aa14d2bf5aeded6f5e5e4a541"
+                "reference": "8a721a5f2553c6c3482b1c5b22ed60fe94dd63ed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/253a4490b528597aa14d2bf5aeded6f5e5e4a541",
-                "reference": "253a4490b528597aa14d2bf5aeded6f5e5e4a541",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/8a721a5f2553c6c3482b1c5b22ed60fe94dd63ed",
+                "reference": "8a721a5f2553c6c3482b1c5b22ed60fe94dd63ed",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8"
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-ctype": "~1.8"
             },
             "type": "library",
             "extra": {
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2018-02-22T10:48:49+00:00"
+            "time": "2018-06-21T11:10:19+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "a479817ce0a9e4adfd7d39c6407c95d97c254625"
+                "reference": "3a8c3de91d2b2c68cd2d665cf9d00f7ef9eaa394"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/a479817ce0a9e4adfd7d39c6407c95d97c254625",
-                "reference": "a479817ce0a9e4adfd7d39c6407c95d97c254625",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/3a8c3de91d2b2c68cd2d665cf9d00f7ef9eaa394",
+                "reference": "3a8c3de91d2b2c68cd2d665cf9d00f7ef9eaa394",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2018-03-05T18:28:11+00:00"
+            "time": "2018-06-19T20:52:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
+                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.8-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                },
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "time": "2018-04-30T19:57:29+00:00"
         },
         {
             "name": "symfony/polyfill-mbstring",
-            "version": "v1.7.0",
+            "version": "v1.8.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "78be803ce01e55d3491c1397cf1c64beb9c1b63b"
+                "reference": "3296adf6a6454a050679cde90f95350ad604b171"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/78be803ce01e55d3491c1397cf1c64beb9c1b63b",
-                "reference": "78be803ce01e55d3491c1397cf1c64beb9c1b63b",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/3296adf6a6454a050679cde90f95350ad604b171",
+                "reference": "3296adf6a6454a050679cde90f95350ad604b171",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.7-dev"
+                    "dev-master": "1.8-dev"
                 }
             },
             "autoload": {
                 "portable",
                 "shim"
             ],
-            "time": "2018-01-30T19:27:44+00:00"
+            "time": "2018-04-26T10:06:28+00:00"
         },
         {
             "name": "symfony/yaml",
-            "version": "v3.4.6",
+            "version": "v3.4.12",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "6af42631dcf89e9c616242c900d6c52bd53bd1bb"
+                "reference": "c5010cc1692ce1fa328b1fb666961eb3d4a85bb0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/6af42631dcf89e9c616242c900d6c52bd53bd1bb",
-                "reference": "6af42631dcf89e9c616242c900d6c52bd53bd1bb",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/c5010cc1692ce1fa328b1fb666961eb3d4a85bb0",
+                "reference": "c5010cc1692ce1fa328b1fb666961eb3d4a85bb0",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.5.9|>=7.0.8"
+                "php": "^5.5.9|>=7.0.8",
+                "symfony/polyfill-ctype": "~1.8"
             },
             "conflict": {
                 "symfony/console": "<3.4"
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2018-02-16T09:50:28+00:00"
+            "time": "2018-05-03T23:18:14+00:00"
         },
         {
             "name": "theseer/fdomdocument",
diff --git a/doc/md/Link-structure.md b/doc/md/Link-structure.md
new file mode 100644 (file)
index 0000000..0a2d0f8
--- /dev/null
@@ -0,0 +1,18 @@
+## Link structure
+
+Every link available through the `LinkDB` object is represented as an array 
+containing the following fields:
+
+  * `id` (integer): Unique identifier.
+  * `title` (string): Title of the link.
+  * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).  
+           Can be absolute or relative for Notes.
+  * `real_url` (string): Real destination URL, can be redirected, encoded, etc.
+  * `shorturl` (string): Permalink small hash.
+  * `description` (string): Link text description.
+  * `private` (boolean): whether the link is private or not.
+  * `tags` (string): all link tags separated by a single space
+  * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any.
+  * `created` (DateTime): link creation date time.
+  * `updated` (DateTime): last modification date time.
+  
\ No newline at end of file
index ca82b2ec7cd63c558717cee9ffb430a8459d4578..e281dc859239cae2ead0801671e6c8ccf1dc197a 100644 (file)
@@ -29,7 +29,7 @@ Extension | Required? | Usage
 ---|:---:|---
 [`openssl`](http://php.net/manual/en/book.openssl.php) | All | OpenSSL, HTTPS
 [`php-mbstring`](http://php.net/manual/en/book.mbstring.php) | CentOS, Fedora, RHEL, Windows, some hosting providers | multibyte (Unicode) string support
-[`php-gd`](http://php.net/manual/en/book.image.php) | optional | thumbnail resizing
+[`php-gd`](http://php.net/manual/en/book.image.php) | optional | required to use thumbnails
 [`php-intl`](http://php.net/manual/en/book.intl.php) | optional | localized text sorting (e.g. `e->è->f`)
 [`php-curl`](http://php.net/manual/en/book.curl.php) | optional | using cURL for fetching webpages and thumbnails in a more robust way
 [`php-gettext`](http://php.net/manual/en/book.gettext.php) | optional | Use the translation system in gettext mode (faster)
index 2ebeccbc2d4c9629033c1a52670860b0e46786f4..155eb52ee2de3c71c5ca8f0ca6d2689d09baba7c 100644 (file)
@@ -1,15 +1,15 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
-"POT-Creation-Date: 2018-01-24 18:43+0100\n"
-"PO-Revision-Date: 2018-03-06 18:44+0100\n"
+"POT-Creation-Date: 2018-07-17 13:04+0200\n"
+"PO-Revision-Date: 2018-07-17 13:07+0200\n"
 "Last-Translator: \n"
 "Language-Team: Shaarli\n"
 "Language: fr_FR\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 2.0.6\n"
+"X-Generator: Poedit 2.0.9\n"
 "X-Poedit-Basepath: ../../../..\n"
 "Plural-Forms: nplurals=2; plural=(n > 1);\n"
 "X-Poedit-SourceCharset: UTF-8\n"
@@ -56,7 +56,7 @@ msgstr "Liens directs"
 
 #: application/FeedBuilder.php:153
 #: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:178
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
 msgid "Permalink"
 msgstr "Permalien"
 
@@ -68,18 +68,22 @@ msgstr "Le fichier d'historique n'est pas accessible en lecture ou en Ã©criture"
 msgid "Could not parse history file"
 msgstr "Format incorrect pour le fichier d'historique"
 
-#: application/Languages.php:161
+#: application/Languages.php:177
 msgid "Automatic"
 msgstr "Automatique"
 
-#: application/Languages.php:162
+#: application/Languages.php:178
 msgid "English"
 msgstr "Anglais"
 
-#: application/Languages.php:163
+#: application/Languages.php:179
 msgid "French"
 msgstr "Français"
 
+#: application/Languages.php:180
+msgid "German"
+msgstr "Allemand"
+
 #: application/LinkDB.php:136
 msgid "You are not authorized to add a link."
 msgstr "Vous n'êtes pas autorisé Ã  ajouter un lien."
@@ -163,11 +167,11 @@ msgstr ""
 "a Ã©té importé avec succès en %d secondes : %d liens importés, %d liens "
 "écrasés, %d liens ignorés."
 
-#: application/PageBuilder.php:168
+#: application/PageBuilder.php:200
 msgid "The page you are trying to reach does not exist or has been deleted."
 msgstr "La page que vous essayez de consulter n'existe pas ou a Ã©té supprimée."
 
-#: application/PageBuilder.php:170
+#: application/PageBuilder.php:202
 msgid "404 Not Found"
 msgstr "404 Introuvable"
 
@@ -176,21 +180,37 @@ msgstr "404 Introuvable"
 msgid "Plugin \"%s\" files not found."
 msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
 
-#: application/Updater.php:76
+#: application/Thumbnailer.php:61
+msgid ""
+"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
+"disabled. Please reload the page."
+msgstr ""
+"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
+"disabled. Please reload the page."
+
+#: application/Updater.php:86
 msgid "Couldn't retrieve Updater class methods."
 msgstr "Impossible de récupérer les méthodes de la classe Updater."
 
-#: application/Updater.php:506
+#: application/Updater.php:514 index.php:1023
+msgid ""
+"You have enabled or changed thumbnails mode. <a href=\"?do=thumbs_update"
+"\">Please synchronize them</a>."
+msgstr ""
+"Vous avez activé ou changé le mode de miniatures. <a href=\"?do=thumbs_update"
+"\">Merci de les synchroniser</a>."
+
+#: application/Updater.php:566
 msgid "An error occurred while running the update "
 msgstr "Une erreur s'est produite lors de l'exécution de la mise Ã  jour "
 
-#: application/Updater.php:546
+#: application/Updater.php:606
 msgid "Updates file path is not set, can't write updates."
 msgstr ""
 "Le chemin vers le fichier de mise Ã  jour n'est pas défini, impossible "
 "d'écrire les mises Ã  jour."
 
-#: application/Updater.php:551
+#: application/Updater.php:611
 msgid "Unable to write updates in "
 msgstr "Impossible d'écrire les mises Ã  jour dans "
 
@@ -230,6 +250,7 @@ msgstr ""
 "Shaarli a les droits d'écriture dans le dossier dans lequel il est installé."
 
 #: application/config/ConfigManager.php:135
+#: application/config/ConfigManager.php:162
 msgid "Invalid setting key parameter. String expected, got: "
 msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : "
 
@@ -251,135 +272,133 @@ msgstr "Vous n'êtes pas autorisé Ã  modifier la configuration."
 msgid "Error accessing"
 msgstr "Une erreur s'est produite en accédant Ã "
 
-#: index.php:142
+#: index.php:143
 msgid "Shared links on "
 msgstr "Liens partagés sur "
 
-#: index.php:164
+#: index.php:165
 msgid "Insufficient permissions:"
 msgstr "Permissions insuffisantes :"
 
-#: index.php:303
+#: index.php:201
 msgid "I said: NO. You are banned for the moment. Go away."
 msgstr "NON. Vous Ãªtes banni pour le moment. Revenez plus tard."
 
-#: index.php:368
+#: index.php:273
 msgid "Wrong login/password."
 msgstr "Nom d'utilisateur ou mot de passe incorrects."
 
-#: index.php:576 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:42
+#: index.php:483 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:46
 msgid "Daily"
 msgstr "Quotidien"
 
-#: index.php:681 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: index.php:589 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
 #: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:71
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:95
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:75
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:99
 msgid "Login"
 msgstr "Connexion"
 
-#: index.php:722 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:39
+#: index.php:606 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:41
 msgid "Picture wall"
 msgstr "Mur d'images"
 
-#: index.php:770 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: index.php:683 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 msgid "Tag cloud"
 msgstr "Nuage de tags"
 
-#: index.php:803 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+#: index.php:716 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 msgid "Tag list"
 msgstr "Liste des tags"
 
-#: index.php:1028 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
+#: index.php:941 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31
 msgid "Tools"
 msgstr "Outils"
 
-#: index.php:1037
+#: index.php:950
 msgid "You are not supposed to change a password on an Open Shaarli."
 msgstr ""
 "Vous n'êtes pas censé modifier le mot de passe d'un Shaarli en mode ouvert."
 
-#: index.php:1042 index.php:1084 index.php:1162 index.php:1193 index.php:1293
+#: index.php:955 index.php:997 index.php:1085 index.php:1116 index.php:1221
 msgid "Wrong token."
 msgstr "Jeton invalide."
 
-#: index.php:1047
+#: index.php:960
 msgid "The old password is not correct."
 msgstr "L'ancien mot de passe est incorrect."
 
-#: index.php:1067
+#: index.php:980
 msgid "Your password has been changed"
 msgstr "Votre mot de passe a Ã©té modifié"
 
-#: index.php:1072
+#: index.php:985
 #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
 #: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "Change password"
 msgstr "Modification du mot de passe"
 
-#: index.php:1121
+#: index.php:1043
 msgid "Configuration was saved."
 msgstr "La configuration a Ã©té sauvegardé."
 
-#: index.php:1145 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+#: index.php:1068 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
 msgid "Configure"
 msgstr "Configurer"
 
-#: index.php:1156 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: index.php:1079 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
 #: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 msgid "Manage tags"
 msgstr "Gérer les tags"
 
-#: index.php:1174
+#: index.php:1097
 #, php-format
 msgid "The tag was removed from %d link."
 msgid_plural "The tag was removed from %d links."
 msgstr[0] "Le tag a Ã©té supprimé de %d lien."
 msgstr[1] "Le tag a Ã©té supprimé de %d liens."
 
-#: index.php:1175
+#: index.php:1098
 #, php-format
 msgid "The tag was renamed in %d link."
 msgid_plural "The tag was renamed in %d links."
 msgstr[0] "Le tag a Ã©té renommé dans %d lien."
 msgstr[1] "Le tag a Ã©té renommé dans %d liens."
 
-#: index.php:1183 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: index.php:1106 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
 msgid "Shaare a new link"
 msgstr "Partager un nouveau lien"
 
-#: index.php:1353 tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
+#: index.php:1281 tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
 msgid "Edit"
 msgstr "Modifier"
 
-#: index.php:1353 index.php:1418
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: index.php:1281 index.php:1351
 #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26
 msgid "Shaare"
 msgstr "Shaare"
 
-#: index.php:1387
+#: index.php:1320
 msgid "Note: "
 msgstr "Note : "
 
-#: index.php:1427 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
+#: index.php:1360 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
 msgid "Export"
 msgstr "Exporter"
 
-#: index.php:1489 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+#: index.php:1422 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "Import"
 msgstr "Importer"
 
-#: index.php:1499
+#: index.php:1432
 #, php-format
 msgid ""
 "The file you are trying to upload is probably bigger than what this "
@@ -389,16 +408,20 @@ msgstr ""
 "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
 "légères."
 
-#: index.php:1538 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: index.php:1471 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 #: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
 msgid "Plugin administration"
 msgstr "Administration des extensions"
 
-#: index.php:1703
+#: index.php:1523 tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Thumbnails update"
+msgstr "Mise Ã  jour des miniatures"
+
+#: index.php:1695
 msgid "Search: "
 msgstr "Recherche : "
 
-#: index.php:1930
+#: index.php:1735
 #, php-format
 msgid ""
 "<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
@@ -417,7 +440,7 @@ msgstr ""
 "cookies. Nous vous recommandons d'accéder Ã  votre serveur depuis son adresse "
 "IP ou un <em>Fully Qualified Domain Name</em>.<br>"
 
-#: index.php:1940
+#: index.php:1745
 msgid "Click to try again."
 msgstr "Cliquer ici pour réessayer."
 
@@ -467,19 +490,19 @@ msgstr ""
 msgid "Isso server URL (without 'http://')"
 msgstr "URL du serveur Isso (sans 'http://')"
 
-#: plugins/markdown/markdown.php:158
+#: plugins/markdown/markdown.php:161
 msgid "Description will be rendered with"
 msgstr "La description sera générée avec"
 
-#: plugins/markdown/markdown.php:159
+#: plugins/markdown/markdown.php:162
 msgid "Markdown syntax documentation"
 msgstr "Documentation sur la syntaxe Markdown"
 
-#: plugins/markdown/markdown.php:160
+#: plugins/markdown/markdown.php:163
 msgid "Markdown syntax"
 msgstr "la syntaxe Markdown"
 
-#: plugins/markdown/markdown.php:339
+#: plugins/markdown/markdown.php:347
 msgid ""
 "Render shaare description with Markdown syntax.<br><strong>Warning</"
 "strong>:\n"
@@ -577,11 +600,11 @@ msgstr "URL de l'API Wallabag"
 msgid "Wallabag API version (1 or 2)"
 msgstr "Version de l'API Wallabag (1 ou 2)"
 
-#: tests/LanguagesTest.php:188 tests/LanguagesTest.php:201
+#: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
 #: tests/languages/fr/LanguagesFrTest.php:160
 #: tests/languages/fr/LanguagesFrTest.php:173
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:81
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:85
 msgid "Search"
 msgid_plural "Search"
 msgstr[0] "Rechercher"
@@ -625,8 +648,8 @@ msgid "Rename"
 msgstr "Renommer"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:172
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
 msgid "Delete"
 msgstr "Supprimer"
 
@@ -736,8 +759,36 @@ msgstr ""
 msgid "API secret"
 msgstr "Clé d'API secrète"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:277
+msgid "Enable thumbnails"
+msgstr "Activer les miniatures"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:281
+msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
+msgstr ""
+"Vous devez activer l'extension <code>php-gd</code> pour utiliser les "
+"miniatures."
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:285
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+msgid "Synchronize thumbnails"
+msgstr "Synchroniser les miniatures"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:296
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
+msgid "All"
+msgstr "Tous"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:300
+msgid "Only common media hosts"
+msgstr "Seulement les hébergeurs de média connus"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:304
+msgid "None"
+msgstr "Aucune"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:312
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
 msgid "Save"
@@ -763,25 +814,27 @@ msgstr "Tous les liens d'un jour sur une page."
 msgid "Next day"
 msgstr "Jour suivant"
 
-#: tpl/editlink.html
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -789,17 +842,17 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
 msgid "Tags"
 msgstr "Tags"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
 msgid "Private"
 msgstr "Privé"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
 msgid "Apply Changes"
 msgstr "Appliquer"
 
@@ -811,10 +864,6 @@ msgstr "Exporter les données"
 msgid "Selection"
 msgstr "Choisir"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "All"
-msgstr "Tous"
-
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "Public"
 msgstr "Publics"
@@ -876,15 +925,15 @@ msgstr ""
 
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
 #: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:151
 msgid "Username"
 msgstr "Nom d'utilisateur"
 
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:148
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:152
 msgid "Password"
 msgstr "Mot de passe"
 
@@ -901,28 +950,28 @@ msgid "Install"
 msgstr "Installer"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
 msgid "shaare"
 msgid_plural "shaares"
 msgstr[0] "shaare"
 msgstr[1] "shaares"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "private link"
 msgid_plural "private links"
 msgstr[0] "lien privé"
 msgstr[1] "liens privés"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:117
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:121
 msgid "Search text"
 msgstr "Recherche texte"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:124
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:128
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:128
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
@@ -930,52 +979,52 @@ msgstr "Recherche texte"
 msgid "Filter by tag"
 msgstr "Filtrer par tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "Nothing found."
 msgstr "Aucun résultat."
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:119
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
 #, php-format
 msgid "%s result"
 msgid_plural "%s results"
 msgstr[0] "%s résultat"
 msgstr[1] "%s résultats"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
 msgid "for"
 msgstr "pour"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
 msgid "tagged"
 msgstr "taggé"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:133
 msgid "Remove tag"
 msgstr "Retirer le tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:142
 msgid "with status"
 msgstr "avec le statut"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
 msgid "without any tag"
 msgstr "sans tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:174
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
 msgid "Fold"
 msgstr "Replier"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
 msgid "Edited: "
 msgstr "Modifié : "
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:180
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
 msgid "permalink"
 msgstr "permalien"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
 msgid "Add tag"
 msgstr "Ajouter un tag"
 
@@ -1021,8 +1070,8 @@ msgstr ""
 "réessayer plus tard."
 
 #: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:151
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:155
 msgid "Remember me"
 msgstr "Rester connecté"
 
@@ -1053,35 +1102,52 @@ msgstr "Déplier tout"
 msgid "Are you sure you want to delete this link?"
 msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:61
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:86
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:90
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:65
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:90
 msgid "RSS Feed"
 msgstr "Flux RSS"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:66
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:102
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:70
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:106
 msgid "Logout"
 msgstr "Déconnexion"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:173
 msgid "is available"
 msgstr "est disponible"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:176
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:180
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:180
 msgid "Error"
 msgstr "Erreur"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Picture wall unavailable (thumbnails are disabled)."
+msgstr ""
+"Le mur d'images n'est pas disponible (les miniatures sont désactivées)."
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+#, fuzzy
+#| msgid ""
+#| "You don't have any cached thumbnail. Try to <a href=\"?do=thumbs_update"
+#| "\">synchronize them</a>."
+msgid ""
+"There is no cached thumbnail. Try to <a href=\"?do=thumbs_update"
+"\">synchronize them</a>."
+msgstr ""
+"Il n'y a aucune miniature en cache. Essayer de <a href=\"?do=thumbs_update"
+"\">les synchroniser</a>."
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 msgid "Picture Wall"
 msgstr "Mur d'images"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 msgid "pics"
 msgstr "images"
 
@@ -1223,7 +1289,11 @@ msgstr ""
 msgid "Export database"
 msgstr "Exporter les données"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55
+msgid "Synchronize all link thumbnails"
+msgstr "Synchroniser toutes les miniatures"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
 msgid ""
 "Drag one of these button to your bookmarks toolbar or right-click it and "
 "\"Bookmark This Link\""
@@ -1231,13 +1301,13 @@ msgstr ""
 "Glisser un de ces bouttons dans votre barre de favoris ou cliquer droit "
 "dessus et Â« Ajouter aux favoris Â»"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
 msgid "then click on the bookmarklet in any page you want to share."
 msgstr ""
 "puis cliquer sur le marque page depuis un site que vous souhaitez partager."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:100
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid ""
 "Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
 "Link"
@@ -1245,31 +1315,31 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et Â« "
 "Ajouter aux favoris Â»"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
 msgid "then click âœšShaare link button in any page you want to share"
 msgstr "puis cliquer sur âœšShaare depuis un site que vous souhaitez partager"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
 msgid "The selected text is too long, it will be truncated."
 msgstr "Le texte sélectionné est trop long, il sera tronqué."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid "Shaare link"
 msgstr "Shaare"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
 msgid ""
 "Then click âœšAdd Note button anytime to start composing a private Note (text "
 "post) to your Shaarli"
 msgstr ""
 "Puis cliquer sur âœšAdd Note pour commencer Ã  rédiger une Note sur Shaarli"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127
 msgid "Add Note"
 msgstr "Ajouter une Note"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
 msgid ""
 "You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
 "functionality."
@@ -1277,25 +1347,25 @@ msgstr ""
 "Vous devez utiliser Shaarli en <strong>HTTPS</strong> pour utiliser cette "
 "fonctionalité."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
 msgid "Add to"
 msgstr "Ajouter Ã "
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
 msgid "3rd party"
 msgstr "Applications tierces"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:163
 msgid "Plugin"
 msgstr "Extension"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:158
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164
 msgid "plugin"
 msgstr "extension"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:191
 msgid ""
 "Drag this link to your bookmarks toolbar, or right-click it and choose "
 "Bookmark This Link"
@@ -1303,6 +1373,26 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et Â« "
 "Ajouter aux favoris Â»"
 
+#, fuzzy
+#~| msgid "Enable thumbnails"
+#~ msgid "Synchonize thumbnails"
+#~ msgstr "Activer les miniatures"
+
+#~ msgid "Warning: "
+#~ msgstr "Attention : "
+
+#~ msgid ""
+#~ "It's recommended to visit the picture wall after enabling this feature."
+#~ msgstr ""
+#~ "Il est recommandé de visiter le Mur d'images après avoir activé cette "
+#~ "fonctionnalité."
+
+#~ msgid ""
+#~ "If you have a large database, the first retrieval may take a few minutes."
+#~ msgstr ""
+#~ "Si vous avez beaucoup de liens, la première récupération peut prendre "
+#~ "plusieurs minutes."
+
 #, fuzzy
 #~| msgid "Change"
 #~ msgid "range"
diff --git a/inc/web-thumbnailer.json b/inc/web-thumbnailer.json
new file mode 100644 (file)
index 0000000..dcaa149
--- /dev/null
@@ -0,0 +1,13 @@
+{
+  "settings": {
+    "default": {
+      "download_mode": "DOWNLOAD",
+      "_comment": "infinite cache",
+      "cache_duration": -1,
+      "timeout": 10
+    },
+    "path": {
+      "cache": "cache/"
+    }
+  }
+}
index 5fc880e639c5353583d4392556e5183c7e59d5a0..1480bbc5aeaa80456c2f84f89d7ebaad426f59f7 100644 (file)
--- a/index.php
+++ b/index.php
@@ -75,11 +75,12 @@ require_once 'application/Utils.php';
 require_once 'application/PluginManager.php';
 require_once 'application/Router.php';
 require_once 'application/Updater.php';
-use \Shaarli\Languages;
-use \Shaarli\ThemeUtils;
 use \Shaarli\Config\ConfigManager;
+use \Shaarli\Languages;
 use \Shaarli\Security\LoginManager;
 use \Shaarli\Security\SessionManager;
+use \Shaarli\ThemeUtils;
+use \Shaarli\Thumbnailer;
 
 // Ensure the PHP version is supported
 try {
@@ -513,7 +514,8 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
         read_updates_file($conf->get('resource.updates')),
         $LINKSDB,
         $conf,
-        $loginManager->isLoggedIn()
+        $loginManager->isLoggedIn(),
+        $_SESSION
     );
     try {
         $newUpdates = $updater->update();
@@ -528,7 +530,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
         die($e->getMessage());
     }
 
-    $PAGE = new PageBuilder($conf, $LINKSDB, $sessionManager->generateToken(), $loginManager->isLoggedIn());
+    $PAGE = new PageBuilder($conf, $_SESSION, $LINKSDB, $sessionManager->generateToken(), $loginManager->isLoggedIn());
     $PAGE->assign('linkcount', count($LINKSDB));
     $PAGE->assign('privateLinkcount', count_private($LINKSDB));
     $PAGE->assign('plugin_errors', $pluginManager->getErrors());
@@ -601,19 +603,23 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
     // -------- Picture wall
     if ($targetPage == Router::$PAGE_PICWALL)
     {
+        $PAGE->assign('pagetitle', t('Picture wall') .' - '. $conf->get('general.title', 'Shaarli'));
+        if (! $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
+            $PAGE->assign('linksToDisplay', []);
+            $PAGE->renderPage('picwall');
+            exit;
+        }
+
         // Optionally filter the results:
         $links = $LINKSDB->filterSearch($_GET);
         $linksToDisplay = array();
 
         // Get only links which have a thumbnail.
-        foreach($links as $link)
+        // Note: we do not retrieve thumbnails here, the request is too heavy.
+        foreach($links as $key => $link)
         {
-            $permalink='?'.$link['shorturl'];
-            $thumb=lazyThumbnail($conf, $link['url'],$permalink);
-            if ($thumb!='') // Only output links which have a thumbnail.
-            {
-                $link['thumbnail']=$thumb; // Thumbnail HTML code.
-                $linksToDisplay[]=$link; // Add to array.
+            if (isset($link['thumbnail']) && $link['thumbnail'] !== false) {
+                $linksToDisplay[] = $link; // Add to array.
             }
         }
 
@@ -626,7 +632,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
             $PAGE->assign($key, $value);
         }
 
-        $PAGE->assign('pagetitle', t('Picture wall') .' - '. $conf->get('general.title', 'Shaarli'));
+
         $PAGE->renderPage('picwall');
         exit;
     }
@@ -1009,6 +1015,16 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
             $conf->set('api.secret', escape($_POST['apiSecret']));
             $conf->set('translation.language', escape($_POST['language']));
 
+            $thumbnailsMode = extension_loaded('gd') ? $_POST['enableThumbnails'] : Thumbnailer::MODE_NONE;
+            if ($thumbnailsMode !== Thumbnailer::MODE_NONE
+                && $thumbnailsMode !== $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
+            ) {
+                $_SESSION['warnings'][] = t(
+                    'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
+                );
+            }
+            $conf->set('thumbnails.mode', $thumbnailsMode);
+
             try {
                 $conf->write($loginManager->isLoggedIn());
                 $history->updateSettings();
@@ -1047,6 +1063,8 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
             $PAGE->assign('api_secret', $conf->get('api.secret'));
             $PAGE->assign('languages', Languages::getAvailableLanguages());
             $PAGE->assign('language', $conf->get('translation.language'));
+            $PAGE->assign('gd_enabled', extension_loaded('gd'));
+            $PAGE->assign('thumbnails_mode', $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
             $PAGE->assign('pagetitle', t('Configure') .' - '. $conf->get('general.title', 'Shaarli'));
             $PAGE->renderPage('configure');
             exit;
@@ -1148,6 +1166,11 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
             $link['title'] = $link['url'];
         }
 
+        if ($conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE) {
+            $thumbnailer = new Thumbnailer($conf);
+            $link['thumbnail'] = $thumbnailer->get($url);
+        }
+
         $pluginManager->executeHooks('save_link', $link);
 
         $LINKSDB[$id] = $link;
@@ -1486,6 +1509,43 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager,
         exit;
     }
 
+    // -------- Thumbnails Update
+    if ($targetPage == Router::$PAGE_THUMBS_UPDATE) {
+        $ids = [];
+        foreach ($LINKSDB as $link) {
+            // A note or not HTTP(S)
+            if ($link['url'][0] === '?' || ! startsWith(strtolower($link['url']), 'http')) {
+                continue;
+            }
+            $ids[] = $link['id'];
+        }
+        $PAGE->assign('ids', $ids);
+        $PAGE->assign('pagetitle', t('Thumbnails update') .' - '. $conf->get('general.title', 'Shaarli'));
+        $PAGE->renderPage('thumbnails');
+        exit;
+    }
+
+    // -------- Single Thumbnail Update
+    if ($targetPage == Router::$AJAX_THUMB_UPDATE) {
+        if (! isset($_POST['id']) || ! ctype_digit($_POST['id'])) {
+            http_response_code(400);
+            exit;
+        }
+        $id = (int) $_POST['id'];
+        if (empty($LINKSDB[$id])) {
+            http_response_code(404);
+            exit;
+        }
+        $thumbnailer = new Thumbnailer($conf);
+        $link = $LINKSDB[$id];
+        $link['thumbnail'] = $thumbnailer->get($link['url']);
+        $LINKSDB[$id] = $link;
+        $LINKSDB->save($conf->get('resource.page_cache'));
+
+        echo json_encode($link);
+        exit;
+    }
+
     // -------- Otherwise, simply display search form and links:
     showLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager);
     exit;
@@ -1549,6 +1609,12 @@ function buildLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager)
     // Start index.
     $i = ($page-1) * $_SESSION['LINKS_PER_PAGE'];
     $end = $i + $_SESSION['LINKS_PER_PAGE'];
+
+    $thumbnailsEnabled = $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE;
+    if ($thumbnailsEnabled) {
+        $thumbnailer = new Thumbnailer($conf);
+    }
+
     $linkDisp = array();
     while ($i<$end && $i<count($keys))
     {
@@ -1569,9 +1635,21 @@ function buildLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager)
         $taglist = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
         uasort($taglist, 'strcasecmp');
         $link['taglist'] = $taglist;
+
+        // Thumbnails enabled, not a note,
+        // and (never retrieved yet or no valid cache file)
+        if ($thumbnailsEnabled && $link['url'][0] != '?'
+            && (! isset($link['thumbnail']) || ($link['thumbnail'] !== false && ! is_file($link['thumbnail'])))
+        ) {
+            $elem = $LINKSDB[$keys[$i]];
+            $elem['thumbnail'] = $thumbnailer->get($link['url']);
+            $LINKSDB[$keys[$i]] = $elem;
+            $updateDB = true;
+            $link['thumbnail'] = $elem['thumbnail'];
+        }
+
         // Check for both signs of a note: starting with ? and 7 chars long.
-        if ($link['url'][0] === '?' &&
-            strlen($link['url']) === 7) {
+        if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
             $link['url'] = index_url($_SERVER) . $link['url'];
         }
 
@@ -1579,6 +1657,11 @@ function buildLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager)
         $i++;
     }
 
+    // If we retrieved new thumbnails, we update the database.
+    if (!empty($updateDB)) {
+        $LINKSDB->save($conf->get('resource.page_cache'));
+    }
+
     // Compute paging navigation
     $searchtagsUrl = $searchtags === '' ? '' : '&searchtags=' . urlencode($searchtags);
     $searchtermUrl = empty($searchterm) ? '' : '&searchterm=' . urlencode($searchterm);
@@ -1629,194 +1712,6 @@ function buildLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager)
     return;
 }
 
-/**
- * Compute the thumbnail for a link.
- *
- * With a link to the original URL.
- * Understands various services (youtube.com...)
- * Input: $url = URL for which the thumbnail must be found.
- *        $href = if provided, this URL will be followed instead of $url
- * Returns an associative array with thumbnail attributes (src,href,width,height,style,alt)
- * Some of them may be missing.
- * Return an empty array if no thumbnail available.
- *
- * @param ConfigManager $conf Configuration Manager instance.
- * @param string        $url
- * @param string|bool   $href
- *
- * @return array
- */
-function computeThumbnail($conf, $url, $href = false)
-{
-    if (!$conf->get('thumbnail.enable_thumbnails')) return array();
-    if ($href==false) $href=$url;
-
-    // For most hosts, the URL of the thumbnail can be easily deduced from the URL of the link.
-    // (e.g. http://www.youtube.com/watch?v=spVypYk4kto --->  http://img.youtube.com/vi/spVypYk4kto/default.jpg )
-    //                                     ^^^^^^^^^^^                                 ^^^^^^^^^^^
-    $domain = parse_url($url,PHP_URL_HOST);
-    if ($domain=='youtube.com' || $domain=='www.youtube.com')
-    {
-        parse_str(parse_url($url,PHP_URL_QUERY), $params); // Extract video ID and get thumbnail
-        if (!empty($params['v'])) return array('src'=>'https://img.youtube.com/vi/'.$params['v'].'/default.jpg',
-                                               'href'=>$href,'width'=>'120','height'=>'90','alt'=>'YouTube thumbnail');
-    }
-    if ($domain=='youtu.be') // Youtube short links
-    {
-        $path = parse_url($url,PHP_URL_PATH);
-        return array('src'=>'https://img.youtube.com/vi'.$path.'/default.jpg',
-                     'href'=>$href,'width'=>'120','height'=>'90','alt'=>'YouTube thumbnail');
-    }
-    if ($domain=='pix.toile-libre.org') // pix.toile-libre.org image hosting
-    {
-        parse_str(parse_url($url,PHP_URL_QUERY), $params); // Extract image filename.
-        if (!empty($params) && !empty($params['img'])) return array('src'=>'http://pix.toile-libre.org/upload/thumb/'.urlencode($params['img']),
-                                                                    'href'=>$href,'style'=>'max-width:120px; max-height:150px','alt'=>'pix.toile-libre.org thumbnail');
-    }
-
-    if ($domain=='imgur.com')
-    {
-        $path = parse_url($url,PHP_URL_PATH);
-        if (startsWith($path,'/a/')) return array(); // Thumbnails for albums are not available.
-        if (startsWith($path,'/r/')) return array('src'=>'https://i.imgur.com/'.basename($path).'s.jpg',
-                                                  'href'=>$href,'width'=>'90','height'=>'90','alt'=>'imgur.com thumbnail');
-        if (startsWith($path,'/gallery/')) return array('src'=>'https://i.imgur.com'.substr($path,8).'s.jpg',
-                                                        'href'=>$href,'width'=>'90','height'=>'90','alt'=>'imgur.com thumbnail');
-
-        if (substr_count($path,'/')==1) return array('src'=>'https://i.imgur.com/'.substr($path,1).'s.jpg',
-                                                     'href'=>$href,'width'=>'90','height'=>'90','alt'=>'imgur.com thumbnail');
-    }
-    if ($domain=='i.imgur.com')
-    {
-        $pi = pathinfo(parse_url($url,PHP_URL_PATH));
-        if (!empty($pi['filename'])) return array('src'=>'https://i.imgur.com/'.$pi['filename'].'s.jpg',
-                                                  'href'=>$href,'width'=>'90','height'=>'90','alt'=>'imgur.com thumbnail');
-    }
-    if ($domain=='dailymotion.com' || $domain=='www.dailymotion.com')
-    {
-        if (strpos($url,'dailymotion.com/video/')!==false)
-        {
-            $thumburl=str_replace('dailymotion.com/video/','dailymotion.com/thumbnail/video/',$url);
-            return array('src'=>$thumburl,
-                         'href'=>$href,'width'=>'120','style'=>'height:auto;','alt'=>'DailyMotion thumbnail');
-        }
-    }
-    if (endsWith($domain,'.imageshack.us'))
-    {
-        $ext=strtolower(pathinfo($url,PATHINFO_EXTENSION));
-        if ($ext=='jpg' || $ext=='jpeg' || $ext=='png' || $ext=='gif')
-        {
-            $thumburl = substr($url,0,strlen($url)-strlen($ext)).'th.'.$ext;
-            return array('src'=>$thumburl,
-                         'href'=>$href,'width'=>'120','style'=>'height:auto;','alt'=>'imageshack.us thumbnail');
-        }
-    }
-
-    // Some other hosts are SLOW AS HELL and usually require an extra HTTP request to get the thumbnail URL.
-    // So we deport the thumbnail generation in order not to slow down page generation
-    // (and we also cache the thumbnail)
-
-    if (! $conf->get('thumbnail.enable_localcache')) return array(); // If local cache is disabled, no thumbnails for services which require the use a local cache.
-
-    if ($domain=='flickr.com' || endsWith($domain,'.flickr.com')
-        || $domain=='vimeo.com'
-        || $domain=='ted.com' || endsWith($domain,'.ted.com')
-        || $domain=='xkcd.com' || endsWith($domain,'.xkcd.com')
-    )
-    {
-        if ($domain=='vimeo.com')
-        {   // Make sure this vimeo URL points to a video (/xxx... where xxx is numeric)
-            $path = parse_url($url,PHP_URL_PATH);
-            if (!preg_match('!/\d+.+?!',$path)) return array(); // This is not a single video URL.
-        }
-        if ($domain=='xkcd.com' || endsWith($domain,'.xkcd.com'))
-        {   // Make sure this URL points to a single comic (/xxx... where xxx is numeric)
-            $path = parse_url($url,PHP_URL_PATH);
-            if (!preg_match('!/\d+.+?!',$path)) return array();
-        }
-        if ($domain=='ted.com' || endsWith($domain,'.ted.com'))
-        {   // Make sure this TED URL points to a video (/talks/...)
-            $path = parse_url($url,PHP_URL_PATH);
-            if ("/talks/" !== substr($path,0,7)) return array(); // This is not a single video URL.
-        }
-        $sign = hash_hmac('sha256', $url, $conf->get('credentials.salt')); // We use the salt to sign data (it's random, secret, and specific to each installation)
-        return array('src'=>index_url($_SERVER).'?do=genthumbnail&hmac='.$sign.'&url='.urlencode($url),
-                     'href'=>$href,'width'=>'120','style'=>'height:auto;','alt'=>'thumbnail');
-    }
-
-    // For all other, we try to make a thumbnail of links ending with .jpg/jpeg/png/gif
-    // Technically speaking, we should download ALL links and check their Content-Type to see if they are images.
-    // But using the extension will do.
-    $ext=strtolower(pathinfo($url,PATHINFO_EXTENSION));
-    if ($ext=='jpg' || $ext=='jpeg' || $ext=='png' || $ext=='gif')
-    {
-        $sign = hash_hmac('sha256', $url, $conf->get('credentials.salt')); // We use the salt to sign data (it's random, secret, and specific to each installation)
-        return array('src'=>index_url($_SERVER).'?do=genthumbnail&hmac='.$sign.'&url='.urlencode($url),
-                     'href'=>$href,'width'=>'120','style'=>'height:auto;','alt'=>'thumbnail');
-    }
-    return array(); // No thumbnail.
-
-}
-
-
-// Returns the HTML code to display a thumbnail for a link
-// with a link to the original URL.
-// Understands various services (youtube.com...)
-// Input: $url = URL for which the thumbnail must be found.
-//        $href = if provided, this URL will be followed instead of $url
-// Returns '' if no thumbnail available.
-function thumbnail($url,$href=false)
-{
-    // FIXME!
-    global $conf;
-    $t = computeThumbnail($conf, $url,$href);
-    if (count($t)==0) return ''; // Empty array = no thumbnail for this URL.
-
-    $html='<a href="'.escape($t['href']).'"><img src="'.escape($t['src']).'"';
-    if (!empty($t['width']))  $html.=' width="'.escape($t['width']).'"';
-    if (!empty($t['height'])) $html.=' height="'.escape($t['height']).'"';
-    if (!empty($t['style']))  $html.=' style="'.escape($t['style']).'"';
-    if (!empty($t['alt']))    $html.=' alt="'.escape($t['alt']).'"';
-    $html.='></a>';
-    return $html;
-}
-
-// Returns the HTML code to display a thumbnail for a link
-// for the picture wall (using lazy image loading)
-// Understands various services (youtube.com...)
-// Input: $url = URL for which the thumbnail must be found.
-//        $href = if provided, this URL will be followed instead of $url
-// Returns '' if no thumbnail available.
-function lazyThumbnail($conf, $url,$href=false)
-{
-    // FIXME!
-    global $conf;
-    $t = computeThumbnail($conf, $url,$href);
-    if (count($t)==0) return ''; // Empty array = no thumbnail for this URL.
-
-    $html='<a href="'.escape($t['href']).'">';
-
-    // Lazy image
-    $html.='<img class="b-lazy" src="#" data-src="'.escape($t['src']).'"';
-
-    if (!empty($t['width']))  $html.=' width="'.escape($t['width']).'"';
-    if (!empty($t['height'])) $html.=' height="'.escape($t['height']).'"';
-    if (!empty($t['style']))  $html.=' style="'.escape($t['style']).'"';
-    if (!empty($t['alt']))    $html.=' alt="'.escape($t['alt']).'"';
-    $html.='>';
-
-    // No-JavaScript fallback.
-    $html.='<noscript><img src="'.escape($t['src']).'"';
-    if (!empty($t['width']))  $html.=' width="'.escape($t['width']).'"';
-    if (!empty($t['height'])) $html.=' height="'.escape($t['height']).'"';
-    if (!empty($t['style']))  $html.=' style="'.escape($t['style']).'"';
-    if (!empty($t['alt']))    $html.=' alt="'.escape($t['alt']).'"';
-    $html.='></noscript></a>';
-
-    return $html;
-}
-
-
 /**
  * Installation
  * This function should NEVER be called if the file data/config.php exists.
@@ -1908,7 +1803,7 @@ function install($conf, $sessionManager, $loginManager) {
         exit;
     }
 
-    $PAGE = new PageBuilder($conf, null, $sessionManager->generateToken());
+    $PAGE = new PageBuilder($conf, $_SESSION, null, $sessionManager->generateToken());
     list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
     $PAGE->assign('continents', $continents);
     $PAGE->assign('cities', $cities);
@@ -1917,232 +1812,6 @@ function install($conf, $sessionManager, $loginManager) {
     exit;
 }
 
-/**
- * Because some f*cking services like flickr require an extra HTTP request to get the thumbnail URL,
- * I have deported the thumbnail URL code generation here, otherwise this would slow down page generation.
- * The following function takes the URL a link (e.g. a flickr page) and return the proper thumbnail.
- * This function is called by passing the URL:
- * http://mywebsite.com/shaarli/?do=genthumbnail&hmac=[HMAC]&url=[URL]
- * [URL] is the URL of the link (e.g. a flickr page)
- * [HMAC] is the signature for the [URL] (so that these URL cannot be forged).
- * The function below will fetch the image from the webservice and store it in the cache.
- *
- * @param ConfigManager $conf Configuration Manager instance,
- */
-function genThumbnail($conf)
-{
-    // Make sure the parameters in the URL were generated by us.
-    $sign = hash_hmac('sha256', $_GET['url'], $conf->get('credentials.salt'));
-    if ($sign!=$_GET['hmac']) die('Naughty boy!');
-
-    $cacheDir = $conf->get('resource.thumbnails_cache', 'cache');
-    // Let's see if we don't already have the image for this URL in the cache.
-    $thumbname=hash('sha1',$_GET['url']).'.jpg';
-    if (is_file($cacheDir .'/'. $thumbname))
-    {   // We have the thumbnail, just serve it:
-        header('Content-Type: image/jpeg');
-        echo file_get_contents($cacheDir .'/'. $thumbname);
-        return;
-    }
-    // We may also serve a blank image (if service did not respond)
-    $blankname=hash('sha1',$_GET['url']).'.gif';
-    if (is_file($cacheDir .'/'. $blankname))
-    {
-        header('Content-Type: image/gif');
-        echo file_get_contents($cacheDir .'/'. $blankname);
-        return;
-    }
-
-    // Otherwise, generate the thumbnail.
-    $url = $_GET['url'];
-    $domain = parse_url($url,PHP_URL_HOST);
-
-    if ($domain=='flickr.com' || endsWith($domain,'.flickr.com'))
-    {
-        // Crude replacement to handle new flickr domain policy (They prefer www. now)
-        $url = str_replace('http://flickr.com/','http://www.flickr.com/',$url);
-
-        // Is this a link to an image, or to a flickr page ?
-        $imageurl='';
-        if (endsWith(parse_url($url, PHP_URL_PATH), '.jpg'))
-        {  // This is a direct link to an image. e.g. http://farm1.staticflickr.com/5/5921913_ac83ed27bd_o.jpg
-            preg_match('!(http://farm\d+\.staticflickr\.com/\d+/\d+_\w+_)\w.jpg!',$url,$matches);
-            if (!empty($matches[1])) $imageurl=$matches[1].'m.jpg';
-        }
-        else // This is a flickr page (html)
-        {
-            // Get the flickr html page.
-            list($headers, $content) = get_http_response($url, 20);
-            if (strpos($headers[0], '200 OK') !== false)
-            {
-                // flickr now nicely provides the URL of the thumbnail in each flickr page.
-                preg_match('!<link rel=\"image_src\" href=\"(.+?)\"!', $content, $matches);
-                if (!empty($matches[1])) $imageurl=$matches[1];
-
-                // In albums (and some other pages), the link rel="image_src" is not provided,
-                // but flickr provides:
-                // <meta property="og:image" content="http://farm4.staticflickr.com/3398/3239339068_25d13535ff_z.jpg" />
-                if ($imageurl=='')
-                {
-                    preg_match('!<meta property=\"og:image\" content=\"(.+?)\"!', $content, $matches);
-                    if (!empty($matches[1])) $imageurl=$matches[1];
-                }
-            }
-        }
-
-        if ($imageurl!='')
-        {   // Let's download the image.
-            // Image is 240x120, so 10 seconds to download should be enough.
-            list($headers, $content) = get_http_response($imageurl, 10);
-            if (strpos($headers[0], '200 OK') !== false) {
-                // Save image to cache.
-                file_put_contents($cacheDir .'/'. $thumbname, $content);
-                header('Content-Type: image/jpeg');
-                echo $content;
-                return;
-            }
-        }
-    }
-
-    elseif ($domain=='vimeo.com' )
-    {
-        // This is more complex: we have to perform a HTTP request, then parse the result.
-        // Maybe we should deport this to JavaScript ? Example: http://stackoverflow.com/questions/1361149/get-img-thumbnails-from-vimeo/4285098#4285098
-        $vid = substr(parse_url($url,PHP_URL_PATH),1);
-        list($headers, $content) = get_http_response('https://vimeo.com/api/v2/video/'.escape($vid).'.php', 5);
-        if (strpos($headers[0], '200 OK') !== false) {
-            $t = unserialize($content);
-            $imageurl = $t[0]['thumbnail_medium'];
-            // Then we download the image and serve it to our client.
-            list($headers, $content) = get_http_response($imageurl, 10);
-            if (strpos($headers[0], '200 OK') !== false) {
-                // Save image to cache.
-                file_put_contents($cacheDir .'/'. $thumbname, $content);
-                header('Content-Type: image/jpeg');
-                echo $content;
-                return;
-            }
-        }
-    }
-
-    elseif ($domain=='ted.com' || endsWith($domain,'.ted.com'))
-    {
-        // The thumbnail for TED talks is located in the <link rel="image_src" [...]> tag on that page
-        // http://www.ted.com/talks/mikko_hypponen_fighting_viruses_defending_the_net.html
-        // <link rel="image_src" href="http://images.ted.com/images/ted/28bced335898ba54d4441809c5b1112ffaf36781_389x292.jpg" />
-        list($headers, $content) = get_http_response($url, 5);
-        if (strpos($headers[0], '200 OK') !== false) {
-            // Extract the link to the thumbnail
-            preg_match('!link rel="image_src" href="(http://images.ted.com/images/ted/.+_\d+x\d+\.jpg)"!', $content, $matches);
-            if (!empty($matches[1]))
-            {   // Let's download the image.
-                $imageurl=$matches[1];
-                // No control on image size, so wait long enough
-                list($headers, $content) = get_http_response($imageurl, 20);
-                if (strpos($headers[0], '200 OK') !== false) {
-                    $filepath = $cacheDir .'/'. $thumbname;
-                    file_put_contents($filepath, $content); // Save image to cache.
-                    if (resizeImage($filepath))
-                    {
-                        header('Content-Type: image/jpeg');
-                        echo file_get_contents($filepath);
-                        return;
-                    }
-                }
-            }
-        }
-    }
-
-    elseif ($domain=='xkcd.com' || endsWith($domain,'.xkcd.com'))
-    {
-        // There is no thumbnail available for xkcd comics, so download the whole image and resize it.
-        // http://xkcd.com/327/
-        // <img src="http://imgs.xkcd.com/comics/exploits_of_a_mom.png" title="<BLABLA>" alt="<BLABLA>" />
-        list($headers, $content) = get_http_response($url, 5);
-        if (strpos($headers[0], '200 OK') !== false) {
-            // Extract the link to the thumbnail
-            preg_match('!<img src="(http://imgs.xkcd.com/comics/.*)" title="[^s]!', $content, $matches);
-            if (!empty($matches[1]))
-            {   // Let's download the image.
-                $imageurl=$matches[1];
-                // No control on image size, so wait long enough
-                list($headers, $content) = get_http_response($imageurl, 20);
-                if (strpos($headers[0], '200 OK') !== false) {
-                    $filepath = $cacheDir.'/'.$thumbname;
-                    // Save image to cache.
-                    file_put_contents($filepath, $content);
-                    if (resizeImage($filepath))
-                    {
-                        header('Content-Type: image/jpeg');
-                        echo file_get_contents($filepath);
-                        return;
-                    }
-                }
-            }
-        }
-    }
-
-    else
-    {
-        // For all other domains, we try to download the image and make a thumbnail.
-        // We allow 30 seconds max to download (and downloads are limited to 4 Mb)
-        list($headers, $content) = get_http_response($url, 30);
-        if (strpos($headers[0], '200 OK') !== false) {
-            $filepath = $cacheDir .'/'.$thumbname;
-            // Save image to cache.
-            file_put_contents($filepath, $content);
-            if (resizeImage($filepath))
-            {
-                header('Content-Type: image/jpeg');
-                echo file_get_contents($filepath);
-                return;
-            }
-        }
-    }
-
-
-    // Otherwise, return an empty image (8x8 transparent gif)
-    $blankgif = base64_decode('R0lGODlhCAAIAIAAAP///////yH5BAEKAAEALAAAAAAIAAgAAAIHjI+py+1dAAA7');
-    // Also put something in cache so that this URL is not requested twice.
-    file_put_contents($cacheDir .'/'. $blankname, $blankgif);
-    header('Content-Type: image/gif');
-    echo $blankgif;
-}
-
-// Make a thumbnail of the image (to width: 120 pixels)
-// Returns true if success, false otherwise.
-function resizeImage($filepath)
-{
-    if (!function_exists('imagecreatefromjpeg')) return false; // GD not present: no thumbnail possible.
-
-    // Trick: some stupid people rename GIF as JPEG... or else.
-    // So we really try to open each image type whatever the extension is.
-    $header=file_get_contents($filepath,false,NULL,0,256); // Read first 256 bytes and try to sniff file type.
-    $im=false;
-    $i=strpos($header,'GIF8'); if (($i!==false) && ($i==0)) $im = imagecreatefromgif($filepath); // Well this is crude, but it should be enough.
-    $i=strpos($header,'PNG'); if (($i!==false) && ($i==1)) $im = imagecreatefrompng($filepath);
-    $i=strpos($header,'JFIF'); if ($i!==false) $im = imagecreatefromjpeg($filepath);
-    if (!$im) return false;  // Unable to open image (corrupted or not an image)
-    $w = imagesx($im);
-    $h = imagesy($im);
-    $ystart = 0; $yheight=$h;
-    if ($h>$w) { $ystart= ($h/2)-($w/2); $yheight=$w/2; }
-    $nw = 120;   // Desired width
-    $nh = min(floor(($h*$nw)/$w),120); // Compute new width/height, but maximum 120 pixels height.
-    // Resize image:
-    $im2 = imagecreatetruecolor($nw,$nh);
-    imagecopyresampled($im2, $im, 0, 0, 0, $ystart, $nw, $nh, $w, $yheight);
-    imageinterlace($im2,true); // For progressive JPEG.
-    $tempname=$filepath.'_TEMP.jpg';
-    imagejpeg($im2, $tempname, 90);
-    imagedestroy($im);
-    imagedestroy($im2);
-    unlink($filepath);
-    rename($tempname,$filepath);  // Overwrite original picture with thumbnail.
-    return true;
-}
-
-if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=genthumbnail')) { genThumbnail($conf); exit; }  // Thumbnail generation/cache does not need the link database.
 if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=dailyrss')) { showDailyRSS($conf); exit; }
 if (!isset($_SESSION['LINKS_PER_PAGE'])) {
     $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
index ae38459bfd9a5d0dcfe5da72559662cb56973987..941fce3aad1ad0f6a779d2789ce666419a4029c6 100644 (file)
@@ -39,6 +39,7 @@ pages:
     - Continuous integration tools: Continuous-integration-tools.md
     - GnuPG signature: GnuPG-signature.md
     - Directory structure: Directory-structure.md
+    - Link Structure: Link-structure.md
     - 3rd party libraries: 3rd-party-libraries.md
     - Plugin System: Plugin-System.md
     - Release Shaarli: Release-Shaarli.md
diff --git a/tests/ThumbnailerTest.php b/tests/ThumbnailerTest.php
new file mode 100644 (file)
index 0000000..0831154
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+
+namespace Shaarli;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+use WebThumbnailer\Application\ConfigManager as WTConfigManager;
+
+/**
+ * Class ThumbnailerTest
+ *
+ * We only make 1 thumb test because:
+ *
+ *   1. the thumbnailer library is itself tested
+ *   2. we don't want to make too many external requests during the tests
+ */
+class ThumbnailerTest extends TestCase
+{
+    const WIDTH = 190;
+
+    const HEIGHT = 210;
+
+    /**
+     * @var Thumbnailer;
+     */
+    protected $thumbnailer;
+
+    /**
+     * @var ConfigManager
+     */
+    protected $conf;
+
+    public function setUp()
+    {
+        $this->conf = new ConfigManager('tests/utils/config/configJson');
+        $this->conf->set('thumbnails.mode', Thumbnailer::MODE_ALL);
+        $this->conf->set('thumbnails.width', self::WIDTH);
+        $this->conf->set('thumbnails.height', self::HEIGHT);
+        $this->conf->set('dev.debug', true);
+
+        $this->thumbnailer = new Thumbnailer($this->conf);
+        // cache files in the sandbox
+        WTConfigManager::addFile('tests/utils/config/wt.json');
+    }
+
+    public function tearDown()
+    {
+        $this->rrmdirContent('sandbox/');
+    }
+
+    /**
+     * Test a thumbnail with a custom size in 'all' mode.
+     */
+    public function testThumbnailAllValid()
+    {
+        $thumb = $this->thumbnailer->get('https://github.com/shaarli/Shaarli/');
+        $this->assertNotFalse($thumb);
+        $image = imagecreatefromstring(file_get_contents($thumb));
+        $this->assertEquals(self::WIDTH, imagesx($image));
+        $this->assertEquals(self::HEIGHT, imagesy($image));
+    }
+
+    /**
+     * Test a thumbnail with a custom size in 'common' mode.
+     */
+    public function testThumbnailCommonValid()
+    {
+        $this->conf->set('thumbnails.mode', Thumbnailer::MODE_COMMON);
+        $thumb = $this->thumbnailer->get('https://imgur.com/jlFgGpe');
+        $this->assertNotFalse($thumb);
+        $image = imagecreatefromstring(file_get_contents($thumb));
+        $this->assertEquals(self::WIDTH, imagesx($image));
+        $this->assertEquals(self::HEIGHT, imagesy($image));
+    }
+
+    /**
+     * Test a thumbnail in 'common' mode which isn't include in common websites.
+     */
+    public function testThumbnailCommonInvalid()
+    {
+        $this->conf->set('thumbnails.mode', Thumbnailer::MODE_COMMON);
+        $thumb = $this->thumbnailer->get('https://github.com/shaarli/Shaarli/');
+        $this->assertFalse($thumb);
+    }
+
+    /**
+     * Test a thumbnail that can't be retrieved.
+     */
+    public function testThumbnailNotValid()
+    {
+        $oldlog = ini_get('error_log');
+        ini_set('error_log', '/dev/null');
+
+        $thumbnailer = new Thumbnailer(new ConfigManager());
+        $thumb = $thumbnailer->get('nope');
+        $this->assertFalse($thumb);
+
+        ini_set('error_log', $oldlog);
+    }
+
+    protected function rrmdirContent($dir) {
+        if (is_dir($dir)) {
+            $objects = scandir($dir);
+            foreach ($objects as $object) {
+                if ($object != "." && $object != "..") {
+                    if (is_dir($dir."/".$object))
+                        $this->rrmdirContent($dir."/".$object);
+                    else
+                        unlink($dir."/".$object);
+                }
+            }
+        }
+    }
+}
index 94e3c7d307c4bb46f1e45d8cf523852ee3b7ba88..cacee2d2da25b247e8f5084a1192fe09640693f4 100644 (file)
@@ -2,6 +2,7 @@
 use Shaarli\Config\ConfigJson;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Config\ConfigPhp;
+use Shaarli\Thumbnailer;
 
 require_once 'tests/Updater/DummyUpdater.php';
 require_once 'inc/rain.tpl.class.php';
@@ -20,7 +21,7 @@ class UpdaterTest extends PHPUnit_Framework_TestCase
     /**
      * @var string Config file path (without extension).
      */
-    protected static $configFile = 'tests/utils/config/configJson';
+    protected static $configFile = 'sandbox/config';
 
     /**
      * @var ConfigManager
@@ -32,6 +33,7 @@ class UpdaterTest extends PHPUnit_Framework_TestCase
      */
     public function setUp()
     {
+        copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php');
         $this->conf = new ConfigManager(self::$configFile);
     }
 
@@ -684,4 +686,50 @@ $GLOBALS[\'privateLinkByDefault\'] = true;';
         $this->assertEquals(4194304, $this->conf->get('general.download_max_size'));
         $this->assertEquals(3, $this->conf->get('general.download_timeout'));
     }
+
+    /**
+     * Test updateMethodWebThumbnailer with thumbnails enabled.
+     */
+    public function testUpdateMethodWebThumbnailerEnabled()
+    {
+        $this->conf->remove('thumbnails');
+        $this->conf->set('thumbnail.enable_thumbnails', true);
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(\Shaarli\Thumbnailer::MODE_ALL, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
+        $this->assertContains('You have enabled or changed thumbnails', $_SESSION['warnings'][0]);
+    }
+
+    /**
+     * Test updateMethodWebThumbnailer with thumbnails disabled.
+     */
+    public function testUpdateMethodWebThumbnailerDisabled()
+    {
+        $this->conf->remove('thumbnails');
+        $this->conf->set('thumbnail.enable_thumbnails', false);
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(Thumbnailer::MODE_NONE, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(125, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.height'));
+        $this->assertTrue(empty($_SESSION['warnings']));
+    }
+
+    /**
+     * Test updateMethodWebThumbnailer with thumbnails disabled.
+     */
+    public function testUpdateMethodWebThumbnailerNothingToDo()
+    {
+        $updater = new Updater([], [], $this->conf, true, $_SESSION);
+        $this->assertTrue($updater->updateMethodWebThumbnailer());
+        $this->assertFalse($this->conf->exists('thumbnail'));
+        $this->assertEquals(Thumbnailer::MODE_COMMON, $this->conf->get('thumbnails.mode'));
+        $this->assertEquals(90, $this->conf->get('thumbnails.width'));
+        $this->assertEquals(53, $this->conf->get('thumbnails.height'));
+        $this->assertTrue(empty($_SESSION['warnings']));
+    }
 }
index 1ec447b23aeab9bfbe31b847e9731cfa780db617..4a4e94ac551f135cca96d81a7f08672c43bae7ef 100644 (file)
@@ -81,6 +81,18 @@ class ConfigManagerTest extends \PHPUnit_Framework_TestCase
         $this->assertEquals('testSetWriteGetNested', $this->conf->get('foo.bar.key.stuff'));
     }
 
+    public function testSetDeleteNested()
+    {
+        $this->conf->set('foo.bar.key.stuff', 'testSetDeleteNested');
+        $this->assertTrue($this->conf->exists('foo.bar'));
+        $this->assertTrue($this->conf->exists('foo.bar.key.stuff'));
+        $this->assertEquals('testSetDeleteNested', $this->conf->get('foo.bar.key.stuff'));
+
+        $this->conf->remove('foo.bar');
+        $this->assertFalse($this->conf->exists('foo.bar.key.stuff'));
+        $this->assertFalse($this->conf->exists('foo.bar'));
+    }
+
     /**
      * Set with an empty key.
      *
@@ -103,6 +115,17 @@ class ConfigManagerTest extends \PHPUnit_Framework_TestCase
         $this->conf->set(array('foo' => 'bar'), 'stuff');
     }
 
+    /**
+     * Remove with an empty key.
+     *
+     * @expectedException \Exception
+     * @expectedExceptionMessageRegExp #^Invalid setting key parameter. String expected, got.*#
+     */
+    public function testRmoveEmptyKey()
+    {
+        $this->conf->remove('');
+    }
+
     /**
      * Try to write the config without mandatory parameter (e.g. 'login').
      *
index 9c9288f30cc696c38115dd1562a1770b3dfe8cb3..1549ddfc500acf72602d2f351cde4c5a69af9ef0 100644 (file)
@@ -1,35 +1,84 @@
 <?php /*
 {
     "credentials": {
-        "login":"root",
-        "hash":"hash",
-        "salt":"salt"
+        "login": "root",
+        "hash": "hash",
+        "salt": "salt"
     },
     "security": {
-        "session_protection_disabled":false
+        "session_protection_disabled": false,
+        "ban_after": 4,
+        "ban_duration": 1800,
+        "open_shaarli": false,
+        "allowed_protocols": [
+            "ftp",
+            "ftps",
+            "magnet"
+        ]
     },
     "general": {
-        "timezone":"Europe\/Paris",
+        "timezone": "Europe\/Paris",
         "title": "Shaarli",
-        "header_link": "?"
+        "header_link": "?",
+        "links_per_page": 20,
+        "enabled_plugins": [
+            "qrcode"
+        ],
+        "default_note_title": "Note: "
     },
     "privacy": {
-        "default_private_links":true
+        "default_private_links": true,
+        "hide_public_links": false,
+        "force_login": false,
+        "hide_timestamps": false,
+        "remember_user_default": true
     },
     "redirector": {
-        "url":"lala"
+        "url": "lala",
+        "encode_url": true
     },
     "config": {
         "foo": "bar"
     },
     "resource": {
         "datastore": "tests\/utils\/config\/datastore.php",
-        "data_dir": "sandbox/",
-        "raintpl_tpl": "tpl/"
+        "data_dir": "sandbox\/",
+        "raintpl_tpl": "tpl\/",
+        "config": "data\/config.php",
+        "ban_file": "data\/ipbans.php",
+        "updates": "data\/updates.txt",
+        "log": "data\/log.txt",
+        "update_check": "data\/lastupdatecheck.txt",
+        "history": "data\/history.php",
+        "theme": "default",
+        "raintpl_tmp": "tmp\/",
+        "thumbnails_cache": "cache",
+        "page_cache": "pagecache"
     },
     "plugins": {
         "WALLABAG_VERSION": 1
+    },
+    "dev": {
+        "debug": true
+    },
+    "updates": {
+        "check_updates": false,
+        "check_updates_branch": "stable",
+        "check_updates_interval": 86400
+    },
+    "feed": {
+        "rss_permalinks": true,
+        "show_atom": true
+    },
+    "translation": {
+        "language": "auto",
+        "mode": "php",
+        "extensions": []
+    },
+    "thumbnails": {
+        "mode": "common",
+        "width": 90,
+        "height": 53
     }
 }
 */ ?>
-
diff --git a/tests/utils/config/wt.json b/tests/utils/config/wt.json
new file mode 100644 (file)
index 0000000..69ce49a
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "settings": {
+    "default": {
+      "_comment": "infinite cache",
+      "cache_duration": -1,
+      "timeout": 10
+    },
+    "path": {
+      "cache": "sandbox/"
+    }
+  }
+}
\ No newline at end of file
index a63c7ad33a6d8402423aebe90d51b5357c11de17..42e32230b7bacb88cad86354e06072bac72078c0 100644 (file)
           </div>
         </div>
       </div>
+      <div class="pure-g">
+        <div class="pure-u-lg-{$ratioLabel} pure-u-{$ratioLabelMobile}">
+          <div class="form-label">
+            <label for="enableThumbnails">
+              <span class="label-name">{'Enable thumbnails'|t}</span><br>
+              <span class="label-desc">
+                {if="! $gd_enabled"}
+                  {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
+                {elseif="$thumbnails_enabled"}
+                  <a href="?do=thumbs_update">{'Synchronize thumbnails'|t}</a>
+                {/if}
+              </span>
+            </label>
+          </div>
+        </div>
+        <div class="pure-u-lg-{$ratioInput} pure-u-{$ratioInputMobile}">
+          <div class="form-input">
+            <select name="enableThumbnails" id="enableThumbnails" class="align">
+              <option value="all"    {if="$thumbnails_mode=='all'"}selected{/if}>
+                {'All'|t}
+              </option>
+              <option value="common" {if="$thumbnails_mode=='common'"}selected{/if}>
+                {'Only common media hosts'|t}
+              </option>
+              <option value="none"   {if="$thumbnails_mode=='none'"}selected{/if}>
+                {'None'|t}
+              </option>
+            </select>
+          </div>
+        </div>
+      </div>
       <div class="center">
         <input type="submit" value="{'Save'|t}" name="save">
       </div>
index 322cddd5341ec417cbfc9597bf199a629b7d6f9b..8ea2ce66194ef728581cd75f8c5a7dfa5cf157e8 100644 (file)
 
         <div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}">
           <div class="linklist-item-title">
-            {$thumb=thumbnail($value.url)}
-            {if="$thumb!=false"}
-              <div class="linklist-item-thumbnail">{$thumb}</div>
+            {if="$thumbnails_enabled && !empty($value.thumbnail)"}
+              <div class="linklist-item-thumbnail" style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;">
+                <div class="thumbnail">
+                  <a href="{$value.real_url}">
+                  {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+                  <img data-src="{$value.thumbnail}#" class="b-lazy"
+                    src="#"
+                    alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+                  </a>
+                </div>
+              </div>
             {/if}
 
             {if="$is_logged_in"}
 </div>
 
 {include="page.footer"}
+<script src="js/thumbnails.min.js?v={$version_hash}"></script>
 </body>
 </html>
index 82568d635ca0db797b2daf609b7d1852d1142bf0..fc03404edc4263f7aec8237553e0e889c2be6ba4 100644 (file)
         <li class="pure-menu-item" id="shaarli-menu-tags">
           <a href="?do=tagcloud" class="pure-menu-link">{'Tag cloud'|t}</a>
         </li>
-        <li class="pure-menu-item" id="shaarli-menu-picwall">
-          <a href="?do=picwall{$searchcrits}" class="pure-menu-link">{'Picture wall'|t}</a>
-        </li>
+        {if="$thumbnails_enabled"}
+          <li class="pure-menu-item" id="shaarli-menu-picwall">
+            <a href="?do=picwall{$searchcrits}" class="pure-menu-link">{'Picture wall'|t}</a>
+          </li>
+        {/if}
         <li class="pure-menu-item" id="shaarli-menu-daily">
           <a href="?do=daily" class="pure-menu-link">{'Daily'|t}</a>
         </li>
   </div>
 {/if}
 
+{if="!empty($global_warnings) && $is_logged_in"}
+  <div class="pure-g pure-alert pure-alert-warning pure-alert-closable" id="shaarli-warnings-alert">
+    <div class="pure-u-2-24"></div>
+    <div class="pure-u-20-24">
+      {loop="global_warnings"}
+        <p>{$value}</p>
+      {/loop}
+    </div>
+    <div class="pure-u-2-24">
+      <i class="fa fa-times pure-alert-close"></i>
+    </div>
+  </div>
+{/if}
+
   <div class="clear"></div>
index 2f7e03dc3bc0b127e254acb7f3acb82f86d9d986..9a0b10dc6084e2e3ac7e29d0fa562a6728c56426 100644 (file)
@@ -5,41 +5,61 @@
 </head>
 <body>
 {include="page.header"}
+{if="!$thumbnails_enabled"}
+<div class="pure-g pure-alert pure-alert-warning page-single-alert">
+  <div class="pure-u-1 center">
+    {'Picture wall unavailable (thumbnails are disabled).'|t}
+  </div>
+</div>
+{else}
+  {if="count($linksToDisplay)===0 && $is_logged_in"}
+    <div class="pure-g pure-alert pure-alert-warning page-single-alert">
+      <div class="pure-u-1 center">
+        {'There is no cached thumbnail. Try to <a href="?do=thumbs_update">synchronize them</a>.'|t}
+      </div>
+    </div>
+  {/if}
 
-<div class="pure-g">
-  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
-  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
-    {$countPics=count($linksToDisplay)}
-    <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
+  <div class="pure-g">
+    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+    <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
+      {$countPics=count($linksToDisplay)}
+      <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
 
-    <div id="plugin_zone_start_picwall" class="plugin_zone">
-      {loop="$plugin_start_zone"}
-        {$value}
-      {/loop}
-    </div>
+      <div id="plugin_zone_start_picwall" class="plugin_zone">
+        {loop="$plugin_start_zone"}
+          {$value}
+        {/loop}
+      </div>
 
-    <div id="picwall_container" class="picwall-container">
-      {loop="$linksToDisplay"}
-        <div class="picwall-pictureframe">
-          {$value.thumbnail}<a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
-          {loop="$value.picwall_plugin"}
-            {$value}
-          {/loop}
-        </div>
-      {/loop}
-      <div class="clear"></div>
-    </div>
+      <div id="picwall-container" class="picwall-container">
+        {loop="$linksToDisplay"}
+          <div class="picwall-pictureframe">
+            {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+            <img data-src="{$value.thumbnail}#" class="b-lazy"
+                 src="#"
+                 alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+            <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
+            {loop="$value.picwall_plugin"}
+              {$value}
+            {/loop}
+          </div>
+        {/loop}
+        <div class="clear"></div>
+      </div>
 
-    <div id="plugin_zone_end_picwall" class="plugin_zone">
-      {loop="$plugin_end_zone"}
-        {$value}
-      {/loop}
+      <div id="plugin_zone_end_picwall" class="plugin_zone">
+        {loop="$plugin_end_zone"}
+          {$value}
+        {/loop}
+      </div>
     </div>
+    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   </div>
-</div>
+{/if}
 
 {include="page.footer"}
-<script src="js/picwall.min.js?v={$version_hash}"></script>
+<script src="js/thumbnails.min.js?v={$version_hash}"></script>
 </body>
 </html>
 
diff --git a/tpl/default/thumbnails.html b/tpl/default/thumbnails.html
new file mode 100644 (file)
index 0000000..a8cf904
--- /dev/null
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<html>
+<head>
+  {include="includes"}
+</head>
+<body>
+{include="page.header"}
+
+<div class="pure-g thumbnails-page-container">
+  <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+  <div class="pure-u-lg-1-3 pure-u-22-24 page-form page-form-light">
+    <h2 class="window-title">{'Thumbnails update'|t}</h2>
+
+    <div class="pure-g">
+      <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+      <div class="pure-u-lg-1-3 pure-u-22-24">
+        <div class="thumbnail-placeholder" style="width: {$thumbnails_width}px; height: {$thumbnails_height}px;"></div>
+      </div>
+    </div>
+
+    <div class="pure-g">
+      <div class="pure-u-1-12"></div>
+      <div class="pure-u-5-6">
+        <div class="thumbnail-link-title"></div>
+
+        <div class="progressbar">
+          <div></div>
+        </div>
+      </div>
+    </div>
+
+    <div class="pure-g">
+      <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+      <div class="pure-u-lg-1-3 pure-u-22-24">
+        <div class="progress-counter">
+          <span class="progress-current">0</span> / <span class="progress-total">{$ids|count}</span>
+        </div>
+      </div>
+    </div>
+
+    <input type="hidden" name="ids" value="{function="implode($ids, ',')"}" />
+  </div>
+</div>
+
+{include="page.footer"}
+<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+</body>
+</html>
index ece66884c3438cb9f524131b73f810db877bc4c4..20060994452eafce857378cbff0687dbff8cccdb 100644 (file)
       </a>
     </div>
 
+    {if="$thumbnails_enabled"}
+      <div class="tools-item">
+        <a href="?do=thumbs_update" title="{'Synchronize all link thumbnails'|t}">
+          <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
+        </a>
+      </div>
+    {/if}
+
     {loop="$tools_plugin"}
       <div class="tools-item">
         {$value}
index 479284eb1aa2ec993ceb02caf5281093871cb0a4..9466c2354780cbdb8a8f7805041d733a4b7025fc 100644 (file)
           <input type="text" name="apiSecret" id="apiSecret" size="50" value="{$api_secret}" />
         </td>
       </tr>
+      <tr>
+        <td valign="top"><b>Enable thumbnails</b></td>
+        <td>
+          <select name="enableThumbnails" id="enableThumbnails" class="align">
+            <option value="all"    {if="$thumbnails_mode=='all'"}selected{/if}>
+            {'All'|t}
+            </option>
+            <option value="common" {if="$thumbnails_mode=='common'"}selected{/if}>
+            {'Only common media hosts'|t}
+            </option>
+            <option value="none"   {if="$thumbnails_mode=='none'"}selected{/if}>
+            {'None'|t}
+            </option>
+          </select>
+          <label for="enableThumbnails">
+            {if="! $gd_enabled"}
+              {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
+            {elseif="$thumbnails_enabled"}
+              <a href="?do=thumbs_update">{'Synchonize thumbnails'|t}</a>
+            {/if}
+          </label>
+        </td>
+      </tr>
 
       <tr>
         <td></td>
index 1ca51be3b962f4ac1d3965fa94b5ba231623924f..3f202849db3313b91f26b32ef2d5deec3b019656 100644 (file)
         {loop="$links"}
         <li{if="$value.class"} class="{$value.class}"{/if}>
             <a id="{$value.shorturl}"></a>
-            <div class="thumbnail">{$value.url|thumbnail}</div>
+            {if="$thumbnails_enabled && !empty($value.thumbnail)"}
+                <div class="thumbnail">
+                    <a href="{$value.real_url}">
+                        {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+                        <img data-src="{$value.thumbnail}#" class="b-lazy"
+                             src="#"
+                             alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+                    </a>
+                </div>
+            {/if}
             <div class="linkcontainer">
                 {if="$is_logged_in"}
                     <div class="linkeditbuttons">
 </div>
 
     {include="page.footer"}
+<script src="js/thumbnails.min.js"></script>
 
 </body>
 </html>
index 29688914165ed19f870b75d29087b29fab80a88d..5f1d266ee1f15ddc7bc4f00403e968511d033b7d 100644 (file)
         <div id="picwall_container">
             {loop="$linksToDisplay"}
             <div class="picwall_pictureframe">
-                   {$value.thumbnail}<a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
+                {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+                <img data-src="{$value.thumbnail}#" class="b-lazy"
+                     src="#"
+                     alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+                <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
                 {loop="$value.picwall_plugin"}
                     {$value}
                 {/loop}
@@ -34,6 +38,6 @@
 
 {include="page.footer"}
 
-<script src="js/picwall.min.js"></script>
+<script src="js/thumbnails.min.js"></script>
 </body>
 </html>
diff --git a/tpl/vintage/thumbnails.html b/tpl/vintage/thumbnails.html
new file mode 100644 (file)
index 0000000..79aebf8
--- /dev/null
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>{include="includes"}</head>
+<body>
+<div id="pageheader">
+{include="page.header"}
+</div>
+
+<div class="center thumbnails-update-container">
+  <div class="thumbnail-placeholder" style="width: {$thumbnails_width}px; height: {$thumbnails_height}px;"></div>
+
+  <div class="thumbnail-link-title"></div>
+
+  <div class="progressbar">
+    <div></div>
+  </div>
+
+  <div class="progress-counter">
+    <span class="progress-current">0</span> / <span class="progress-total">{$ids|count}</span>
+  </div>
+</div>
+
+<input type="hidden" name="ids" value="{function="implode($ids, ',')"}" />
+
+{include="page.footer"}
+<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+</body>
+</html>
index 94b7aa701b270a57f068a05800bac9d6e9964773..ed548c735ad08444ba7adf204e93a2c14012e4ab 100644 (file)
@@ -23,7 +23,8 @@ const extractCssVintage = new ExtractTextPlugin({
 module.exports = [
   {
     entry: {
-      picwall: './assets/common/js/picwall.js',
+      thumbnails: './assets/common/js/thumbnails.js',
+      thumbnails_update: './assets/common/js/thumbnails-update.js',
       pluginsadmin: './assets/default/js/plugins-admin.js',
       shaarli: [
         './assets/default/js/base.js',
@@ -96,7 +97,8 @@ module.exports = [
         './assets/vintage/css/reset.css',
         './assets/vintage/css/shaarli.css',
       ].concat(glob.sync('./assets/vintage/img/*')),
-      picwall: './assets/common/js/picwall.js',
+      thumbnails: './assets/common/js/thumbnails.js',
+      thumbnails_update: './assets/common/js/thumbnails-update.js',
     },
     output: {
       filename: '[name].min.js',