]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #841 from ArthurHoaro/feature/search-no-tag
authorArthurHoaro <arthur@hoa.ro>
Thu, 25 May 2017 13:54:20 +0000 (15:54 +0200)
committerGitHub <noreply@github.com>
Thu, 25 May 2017 13:54:20 +0000 (15:54 +0200)
Empty tag search will look for not tagged links

1  2 
application/LinkDB.php
application/Utils.php
index.php
tests/LinkDBTest.php
tests/api/controllers/GetLinksTest.php
tests/api/controllers/InfoTest.php
tests/utils/ReferenceLinkDB.php
tpl/default/linklist.html

diff --combined application/LinkDB.php
index 7802cc8a1dd408a022d2471efec794fbca1bbfe7,a03c2c0633e1648c12d977b713d722aef6566e06..8ca0fab30d55cbee54501579375e7d0697f48e62
@@@ -50,6 -50,12 +50,6 @@@ class LinkDB implements Iterator, Count
      // Link date storage format
      const LINK_DATE_FORMAT = 'Ymd_His';
  
 -    // Datastore PHP prefix
 -    protected static $phpPrefix = '<?php /* ';
 -
 -    // Datastore PHP suffix
 -    protected static $phpSuffix = ' */ ?>';
 -
      // List of links (associative array)
      //  - key:   link date (e.g. "20110823_124546"),
      //  - value: associative array (keys: title, description...)
          if (!isset($value['id']) || empty($value['url'])) {
              die('Internal Error: A link should always have an id and URL.');
          }
 -        if ((! empty($offset) && ! is_int($offset)) || ! is_int($value['id'])) {
 +        if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
              die('You must specify an integer as a key.');
          }
 -        if (! empty($offset) && $offset !== $value['id']) {
 +        if ($offset !== null && $offset !== $value['id']) {
              die('Array offset and link ID must be equal.');
          }
  
@@@ -289,7 -295,16 +289,7 @@@ You use the community supported versio
              return;
          }
  
 -        // Read data
 -        // Note that gzinflate is faster than gzuncompress.
 -        // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
 -        $this->links = array();
 -
 -        if (file_exists($this->datastore)) {
 -            $this->links = unserialize(gzinflate(base64_decode(
 -                substr(file_get_contents($this->datastore),
 -                       strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
 -        }
 +        $this->links = FileUtils::readFlatDB($this->datastore, []);
  
          $toremove = array();
          foreach ($this->links as $key => &$link) {
       */
      private function write()
      {
 -        if (is_file($this->datastore) && !is_writeable($this->datastore)) {
 -            // The datastore exists but is not writeable
 -            throw new IOException($this->datastore);
 -        } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
 -            // The datastore does not exist and its parent directory is not writeable
 -            throw new IOException(dirname($this->datastore));
 -        }
 -
 -        file_put_contents(
 -            $this->datastore,
 -            self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
 -        );
 -
 +        FileUtils::writeFlatDB($this->datastore, $this->links);
      }
  
      /**
      public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all')
      {
          // Filter link database according to parameters.
-         $searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
-         $searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
+         $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
+         $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
  
-         // Search tags + fullsearch.
-         if (! empty($searchtags) && ! empty($searchterm)) {
-             $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
-             $request = array($searchtags, $searchterm);
-         }
-         // Search by tags.
-         elseif (! empty($searchtags)) {
-             $type = LinkFilter::$FILTER_TAG;
-             $request = $searchtags;
-         }
-         // Fulltext search.
-         elseif (! empty($searchterm)) {
-             $type = LinkFilter::$FILTER_TEXT;
-             $request = $searchterm;
-         }
-         // Otherwise, display without filtering.
-         else {
-             $type = '';
-             $request = '';
-         }
+         // Search tags + fullsearch - blank string parameter will return all links.
+         $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
+         $request = [$searchtags, $searchterm];
  
          $linkFilter = new LinkFilter($this);
          return $linkFilter->filter($type, $request, $casesensitive, $visibility);
      }
  
      /**
 -     * Returns the list of all tags
 -     * Output: associative array key=tags, value=0
 +     * Returns the list tags appearing in the links with the given tags
 +     * @param $filteringTags: tags selecting the links to consider
 +     * @param $visibility: process only all/private/public links
 +     * @return: a tag=>linksCount array
       */
 -    public function allTags()
 +    public function linksCountPerTag($filteringTags = [], $visibility = 'all')
      {
 +        $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
          $tags = array();
          $caseMapping = array();
 -        foreach ($this->links as $link) {
 +        foreach ($links as $link) {
              foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
                  if (empty($tag)) {
                      continue;
diff --combined application/Utils.php
index 9d0ebc5ea3a86c68ac7693c9f793855866658d0c,87e5cc8fff3de21b4c27890cfdd4ee53eac8475c..4a2f5561cfdf5dfb38ed9a0a4c0f46b58b27c8c6
@@@ -91,6 -91,10 +91,10 @@@ function endsWith($haystack, $needle, $
   */
  function escape($input)
  {
+     if (is_bool($input)) {
+         return $input;
+     }
      if (is_array($input)) {
          $out = array();
          foreach($input as $key => $value) {
@@@ -321,148 -325,25 +325,148 @@@ function normalize_spaces($string
   * otherwise default format '%c' will be returned.
   *
   * @param DateTime $date to format.
 + * @param bool     $time Displays time if true.
   * @param bool     $intl Use international format if true.
   *
   * @return bool|string Formatted date, or false if the input is invalid.
   */
 -function format_date($date, $intl = true)
 +function format_date($date, $time = true, $intl = true)
  {
      if (! $date instanceof DateTime) {
          return false;
      }
  
      if (! $intl || ! class_exists('IntlDateFormatter')) {
 -        return strftime('%c', $date->getTimestamp());
 +        $format = $time ? '%c' : '%x';
 +        return strftime($format, $date->getTimestamp());
      }
  
      $formatter = new IntlDateFormatter(
          setlocale(LC_TIME, 0),
          IntlDateFormatter::LONG,
 -        IntlDateFormatter::LONG
 +        $time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
      );
  
      return $formatter->format($date);
  }
 +
 +/**
 + * Check if the input is an integer, no matter its real type.
 + *
 + * PHP is a bit messy regarding this:
 + *   - is_int returns false if the input is a string
 + *   - ctype_digit returns false if the input is an integer or negative
 + *
 + * @param mixed $input value
 + *
 + * @return bool true if the input is an integer, false otherwise
 + */
 +function is_integer_mixed($input)
 +{
 +    if (is_array($input) || is_bool($input) || is_object($input)) {
 +        return false;
 +    }
 +    $input = strval($input);
 +    return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
 +}
 +
 +/**
 + * Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
 + *
 + * @param string $val Size expressed in string.
 + *
 + * @return int Size expressed in bytes.
 + */
 +function return_bytes($val)
 +{
 +    if (is_integer_mixed($val) || $val === '0' || empty($val)) {
 +        return $val;
 +    }
 +    $val = trim($val);
 +    $last = strtolower($val[strlen($val)-1]);
 +    $val = intval(substr($val, 0, -1));
 +    switch($last) {
 +        case 'g': $val *= 1024;
 +        case 'm': $val *= 1024;
 +        case 'k': $val *= 1024;
 +    }
 +    return $val;
 +}
 +
 +/**
 + * Return a human readable size from bytes.
 + *
 + * @param int $bytes value
 + *
 + * @return string Human readable size
 + */
 +function human_bytes($bytes)
 +{
 +    if ($bytes === '') {
 +        return t('Setting not set');
 +    }
 +    if (! is_integer_mixed($bytes)) {
 +        return $bytes;
 +    }
 +    $bytes = intval($bytes);
 +    if ($bytes === 0) {
 +        return t('Unlimited');
 +    }
 +
 +    $units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
 +    for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
 +        $bytes /= 1024;
 +    }
 +
 +    return round($bytes) . $units[$i];
 +}
 +
 +/**
 + * Try to determine max file size for uploads (POST).
 + * Returns an integer (in bytes) or formatted depending on $format.
 + *
 + * @param mixed $limitPost   post_max_size PHP setting
 + * @param mixed $limitUpload upload_max_filesize PHP setting
 + * @param bool  $format      Format max upload size to human readable size
 + *
 + * @return int|string max upload file size
 + */
 +function get_max_upload_size($limitPost, $limitUpload, $format = true)
 +{
 +    $size1 = return_bytes($limitPost);
 +    $size2 = return_bytes($limitUpload);
 +    // Return the smaller of two:
 +    $maxsize = min($size1, $size2);
 +    return $format ? human_bytes($maxsize) : $maxsize;
 +}
 +
 +/**
 + * Sort the given array alphabetically using php-intl if available.
 + * Case sensitive.
 + *
 + * Note: doesn't support multidimensional arrays
 + *
 + * @param array $data    Input array, passed by reference
 + * @param bool  $reverse Reverse sort if set to true
 + * @param bool  $byKeys  Sort the array by keys if set to true, by value otherwise.
 + */
 +function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
 +{
 +    $callback = function($a, $b) use ($reverse) {
 +        // Collator is part of PHP intl.
 +        if (class_exists('Collator')) {
 +            $collator = new Collator(setlocale(LC_COLLATE, 0));
 +            if (!intl_is_failure(intl_get_error_code())) {
 +                return $collator->compare($a, $b) * ($reverse ? -1 : 1);
 +            }
 +        }
 +
 +        return strcasecmp($a, $b) * ($reverse ? -1 : 1);
 +    };
 +
 +    if ($byKeys) {
 +        uksort($data, $callback);
 +    } else {
 +        usort($data, $callback);
 +    }
 +}
diff --combined index.php
index 61b71129810114d95dbb9697ae5e649de2fa475a,c96d01367ab1aee7b2cf4c62a1029fd6b451d078..92eb443ba6310477ed04d831a62c72d03f2bcc3c
+++ b/index.php
@@@ -62,7 -62,6 +62,7 @@@ require_once 'application/CachedPage.ph
  require_once 'application/config/ConfigPlugin.php';
  require_once 'application/FeedBuilder.php';
  require_once 'application/FileUtils.php';
 +require_once 'application/History.php';
  require_once 'application/HttpUtils.php';
  require_once 'application/Languages.php';
  require_once 'application/LinkDB.php';
@@@ -225,6 -224,27 +225,6 @@@ function setup_login_state($conf
  }
  $userIsLoggedIn = setup_login_state($conf);
  
 -/**
 - * PubSubHubbub protocol support (if enabled)  [UNTESTED]
 - * (Source: http://aldarone.fr/les-flux-rss-shaarli-et-pubsubhubbub/ )
 - *
 - * @param ConfigManager $conf Configuration Manager instance.
 - */
 -function pubsubhub($conf)
 -{
 -    $pshUrl = $conf->get('config.PUBSUBHUB_URL');
 -    if (!empty($pshUrl))
 -    {
 -        include_once './publisher.php';
 -        $p = new Publisher($pshUrl);
 -        $topic_url = array (
 -            index_url($_SERVER).'?do=atom',
 -            index_url($_SERVER).'?do=rss'
 -        );
 -        $p->publish_update($topic_url);
 -    }
 -}
 -
  // ------------------------------------------------------------------------------------------
  // Session management
  
@@@ -452,6 -472,34 +452,6 @@@ if (isset($_POST['login'])
      }
  }
  
 -// ------------------------------------------------------------------------------------------
 -// Misc utility functions:
 -
 -// Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
 -function return_bytes($val)
 -{
 -    $val = trim($val); $last=strtolower($val[strlen($val)-1]);
 -    switch($last)
 -    {
 -        case 'g': $val *= 1024;
 -        case 'm': $val *= 1024;
 -        case 'k': $val *= 1024;
 -    }
 -    return $val;
 -}
 -
 -// Try to determine max file size for uploads (POST).
 -// Returns an integer (in bytes)
 -function getMaxFileSize()
 -{
 -    $size1 = return_bytes(ini_get('post_max_size'));
 -    $size2 = return_bytes(ini_get('upload_max_filesize'));
 -    // Return the smaller of two:
 -    $maxsize = min($size1,$size2);
 -    // FIXME: Then convert back to readable notations ? (e.g. 2M instead of 2000000)
 -    return $maxsize;
 -}
 -
  // ------------------------------------------------------------------------------------------
  // Token management for XSRF protection
  // Token should be used in any form which acts on data (create,update,delete,import...).
@@@ -647,11 -695,9 +647,11 @@@ function showDaily($pageBuilder, $LINKS
  
      $dayDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $day.'_000000');
      $data = array(
 +        'pagetitle' => $conf->get('general.title') .' - '. format_date($dayDate, false),
          'linksToDisplay' => $linksToDisplay,
          'cols' => $columns,
          'day' => $dayDate->getTimestamp(),
 +        'dayDate' => $dayDate,
          'previousday' => $previousday,
          'nextday' => $nextday,
      );
@@@ -686,7 -732,7 +686,7 @@@ function showLinkList($PAGE, $LINKSDB, 
   * @param PluginManager $pluginManager Plugin Manager instance,
   * @param LinkDB        $LINKSDB
   */
 -function renderPage($conf, $pluginManager, $LINKSDB)
 +function renderPage($conf, $pluginManager, $LINKSDB, $history)
  {
      $updater = new Updater(
          read_updates_file($conf->get('resource.updates')),
          die($e->getMessage());
      }
  
 -    $PAGE = new PageBuilder($conf);
 +    $PAGE = new PageBuilder($conf, $LINKSDB);
      $PAGE->assign('linkcount', count($LINKSDB));
      $PAGE->assign('privateLinkcount', count_private($LINKSDB));
      $PAGE->assign('plugin_errors', $pluginManager->getErrors());
      // -------- Tag cloud
      if ($targetPage == Router::$PAGE_TAGCLOUD)
      {
 -        $tags= $LINKSDB->allTags();
 +        $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
 +        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
 +        $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
  
          // We sort tags alphabetically, then choose a font size according to count.
          // First, find max value.
              $maxcount = max($maxcount, $value);
          }
  
 -        // Sort tags alphabetically: case insensitive, support locale if available.
 -        uksort($tags, function($a, $b) {
 -            // Collator is part of PHP intl.
 -            if (class_exists('Collator')) {
 -                $c = new Collator(setlocale(LC_COLLATE, 0));
 -                if (!intl_is_failure(intl_get_error_code())) {
 -                    return $c->compare($a, $b);
 -                }
 -            }
 -            return strcasecmp($a, $b);
 -        });
 +        alphabetical_sort($tags, true, true);
  
          $tagList = array();
          foreach($tags as $key => $value) {
          }
  
          $data = array(
 +            'search_tags' => implode(' ', $filteringTags),
              'tags' => $tagList,
          );
          $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => isLoggedIn()));
              $PAGE->assign($key, $value);
          }
  
 -        $PAGE->renderPage('tagcloud');
 +        $PAGE->renderPage('tag.cloud');
 +        exit;
 +    }
 +
 +    // -------- Tag cloud
 +    if ($targetPage == Router::$PAGE_TAGLIST)
 +    {
 +        $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
 +        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
 +        $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
 +
 +        if (! empty($_GET['sort']) && $_GET['sort'] === 'alpha') {
 +            alphabetical_sort($tags, false, true);
 +        }
 +
 +        $data = [
 +            'search_tags' => implode(' ', $filteringTags),
 +            'tags' => $tags,
 +        ];
 +        $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => isLoggedIn()]);
 +
 +        foreach ($data as $key => $value) {
 +            $PAGE->assign($key, $value);
 +        }
 +
 +        $PAGE->renderPage('tag.list');
          exit;
      }
  
              $conf->set('api.secret', escape($_POST['apiSecret']));
              try {
                  $conf->write(isLoggedIn());
 +                $history->updateSettings();
                  invalidateCaches($conf->get('resource.page_cache'));
              }
              catch(Exception $e) {
              $PAGE->assign('theme', $conf->get('resource.theme'));
              $PAGE->assign('theme_available', ThemeUtils::getThemes($conf->get('resource.raintpl_tpl')));
              $PAGE->assign('redirector', $conf->get('redirector.url'));
 -            list($timezone_form, $timezone_js) = generateTimeZoneForm($conf->get('general.timezone'));
 -            $PAGE->assign('timezone_form', $timezone_form);
 -            $PAGE->assign('timezone_js',$timezone_js);
 +            list($continents, $cities) = generateTimeZoneData(
 +                timezone_identifiers_list(),
 +                $conf->get('general.timezone')
 +            );
 +            $PAGE->assign('continents', $continents);
 +            $PAGE->assign('cities', $cities);
              $PAGE->assign('private_links_default', $conf->get('privacy.default_private_links', false));
              $PAGE->assign('session_protection_disabled', $conf->get('security.session_protection_disabled', false));
              $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
      if ($targetPage == Router::$PAGE_CHANGETAG)
      {
          if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
 -            $PAGE->assign('tags', $LINKSDB->allTags());
 +            $PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
              $PAGE->renderPage('changetag');
              exit;
          }
                  unset($tags[array_search($needle,$tags)]); // Remove tag.
                  $value['tags']=trim(implode(' ',$tags));
                  $LINKSDB[$key]=$value;
 +                $history->updateLink($LINKSDB[$key]);
              }
              $LINKSDB->save($conf->get('resource.page_cache'));
              echo '<script>alert("Tag was removed from '.count($linksToAlter).' links.");document.location=\'?do=changetag\';</script>';
                  $tags[array_search($needle, $tags)] = trim($_POST['totag']);
                  $value['tags'] = implode(' ', array_unique($tags));
                  $LINKSDB[$key] = $value;
 +                $history->updateLink($LINKSDB[$key]);
              }
              $LINKSDB->save($conf->get('resource.page_cache')); // Save to disk.
              echo '<script>alert("Tag was renamed in '.count($linksToAlter).' links.");document.location=\'?searchtags='.urlencode(escape($_POST['totag'])).'\';</script>';
              $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
              $updated = new DateTime();
              $shortUrl = $LINKSDB[$id]['shorturl'];
 +            $new = false;
          } else {
              // New link
              $created = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate);
              $updated = null;
              $shortUrl = link_small_hash($created, $id);
 +            $new = true;
          }
  
          // Remove multiple spaces.
  
          $LINKSDB[$id] = $link;
          $LINKSDB->save($conf->get('resource.page_cache'));
 +        if ($new) {
 +            $history->addLink($link);
 +        } else {
 +            $history->updateLink($link);
 +        }
  
          // If we are called from the bookmarklet, we must close the popup:
          if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
      // -------- User clicked the "Delete" button when editing a link: Delete link from database.
      if ($targetPage == Router::$PAGE_DELETELINK)
      {
 -        // We do not need to ask for confirmation:
 -        // - confirmation is handled by JavaScript
 -        // - we are protected from XSRF by the token.
 -
          if (! tokenOk($_GET['token'])) {
              die('Wrong token.');
          }
  
 -        $id = intval(escape($_GET['lf_linkdate']));
 -        $link = $LINKSDB[$id];
 -        $pluginManager->executeHooks('delete_link', $link);
 -        unset($LINKSDB[$id]);
 +        if (strpos($_GET['lf_linkdate'], ' ') !== false) {
 +            $ids = array_values(array_filter(preg_split('/\s+/', escape($_GET['lf_linkdate']))));
 +        } else {
 +            $ids = [$_GET['lf_linkdate']];
 +        }
 +        foreach ($ids as $id) {
 +            $id = (int) escape($id);
 +            $link = $LINKSDB[$id];
 +            $pluginManager->executeHooks('delete_link', $link);
 +            unset($LINKSDB[$id]);
 +        }
          $LINKSDB->save($conf->get('resource.page_cache')); // save to disk
 +        $history->deleteLink($link);
  
          // If we are called from the bookmarklet, we must close the popup:
          if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { echo '<script>self.close();</script>'; exit; }
              'link' => $link,
              'link_is_new' => false,
              'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
 -            'tags' => $LINKSDB->allTags(),
 +            'tags' => $LINKSDB->linksCountPerTag(),
          );
          $pluginManager->executeHooks('render_editlink', $data);
  
              'link_is_new' => $link_is_new,
              'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
              'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
 -            'tags' => $LINKSDB->allTags(),
 +            'tags' => $LINKSDB->linksCountPerTag(),
              'default_private_links' => $conf->get('privacy.default_private_links', false),
          );
          $pluginManager->executeHooks('render_editlink', $data);
  
          if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
              // Show import dialog
 -            $PAGE->assign('maxfilesize', getMaxFileSize());
 +            $PAGE->assign(
 +                'maxfilesize',
 +                get_max_upload_size(
 +                    ini_get('post_max_size'),
 +                    ini_get('upload_max_filesize'),
 +                    false
 +                )
 +            );
 +            $PAGE->assign(
 +                'maxfilesizeHuman',
 +                get_max_upload_size(
 +                    ini_get('post_max_size'),
 +                    ini_get('upload_max_filesize'),
 +                    true
 +                )
 +            );
              $PAGE->renderPage('import');
              exit;
          }
              // The file is too big or some form field may be missing.
              echo '<script>alert("The file you are trying to upload is probably'
                  .' bigger than what this webserver can accept ('
 -                .getMaxFileSize().' bytes).'
 +                .get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')).').'
                  .' Please upload in smaller chunks.");document.location=\'?do='
                  .Router::$PAGE_IMPORT .'\';</script>';
              exit;
              $_POST,
              $_FILES,
              $LINKSDB,
 -            $conf
 +            $conf,
 +            $history
          );
          echo '<script>alert("'.$status.'");document.location=\'?do='
               .Router::$PAGE_IMPORT .'\';</script>';
                  $conf->set('general.enabled_plugins', save_plugin_config($_POST));
              }
              $conf->write(isLoggedIn());
 +            $history->updateSettings();
          }
          catch (Exception $e) {
              error_log(
          exit;
      }
  
 +    // Get a fresh token
 +    if ($targetPage == Router::$GET_TOKEN) {
 +        header('Content-Type:text/plain');
 +        echo getToken($conf);
 +        exit;
 +    }
 +
      // -------- Otherwise, simply display search form and links:
      showLinkList($PAGE, $LINKSDB, $conf, $pluginManager);
      exit;
  function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
  {
      // Used in templates
-     $searchtags = !empty($_GET['searchtags']) ? escape(normalize_spaces($_GET['searchtags'])) : '';
+     if (isset($_GET['searchtags'])) {
+         if (! empty($_GET['searchtags'])) {
+             $searchtags = escape(normalize_spaces($_GET['searchtags']));
+         } else {
+             $searchtags = false;
+         }
+     } else {
+         $searchtags = '';
+     }
      $searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : '';
  
      // Smallhash filter
      } else {
          // Filter links according search parameters.
          $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
-         $linksToDisplay = $LINKSDB->filterSearch($_GET, false, $visibility);
+         $request = [
+             'searchtags' => $searchtags,
+             'searchterm' => $searchterm,
+         ];
+         $linksToDisplay = $LINKSDB->filterSearch($request, false, $visibility);
      }
  
      // ---- Handle paging.
      }
  
      // Compute paging navigation
-     $searchtagsUrl = empty($searchtags) ? '' : '&searchtags=' . urlencode($searchtags);
+     $searchtagsUrl = $searchtags === '' ? '' : '&searchtags=' . urlencode($searchtags);
      $searchtermUrl = empty($searchterm) ? '' : '&searchterm=' . urlencode($searchterm);
      $previous_page_url = '';
      if ($i != count($keys)) {
          'visibility' => ! empty($_SESSION['privateonly']) ? 'private' : '',
          'redirector' => $conf->get('redirector.url'),  // Optional redirector URL.
          'links' => $linkDisp,
 -        'tags' => $LINKSDB->allTags(),
      );
  
      // If there is only a single link, we change on-the-fly the title of the page.
@@@ -1992,10 -1992,16 +2004,10 @@@ function install($conf
          exit;
      }
  
 -    // Display config form:
 -    list($timezone_form, $timezone_js) = generateTimeZoneForm();
 -    $timezone_html = '';
 -    if ($timezone_form != '') {
 -        $timezone_html = '<tr><td><b>Timezone:</b></td><td>'.$timezone_form.'</td></tr>';
 -    }
 -
      $PAGE = new PageBuilder($conf);
 -    $PAGE->assign('timezone_html',$timezone_html);
 -    $PAGE->assign('timezone_js',$timezone_js);
 +    list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
 +    $PAGE->assign('continents', $continents);
 +    $PAGE->assign('cities', $cities);
      $PAGE->renderPage('install');
      exit;
  }
@@@ -2239,27 -2245,16 +2251,27 @@@ $linkDb = new LinkDB
      $conf->get('redirector.encode_url')
  );
  
 +try {
 +    $history = new History($conf->get('resource.history'));
 +} catch(Exception $e) {
 +    die($e->getMessage());
 +}
 +
  $container = new \Slim\Container();
  $container['conf'] = $conf;
  $container['plugins'] = $pluginManager;
 +$container['history'] = $history;
  $app = new \Slim\App($container);
  
  // REST API routes
  $app->group('/api/v1', function() {
 -    $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo');
 -    $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks');
 -    $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink');
 +    $this->get('/info', '\Shaarli\Api\Controllers\Info:getInfo')->setName('getInfo');
 +    $this->get('/links', '\Shaarli\Api\Controllers\Links:getLinks')->setName('getLinks');
 +    $this->get('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:getLink')->setName('getLink');
 +    $this->post('/links', '\Shaarli\Api\Controllers\Links:postLink')->setName('postLink');
 +    $this->put('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:putLink')->setName('putLink');
 +    $this->delete('/links/{id:[\d]+}', '\Shaarli\Api\Controllers\Links:deleteLink')->setName('deleteLink');
 +    $this->get('/history', '\Shaarli\Api\Controllers\History:getHistory')->setName('getHistory');
  })->add('\Shaarli\Api\ApiMiddleware');
  
  $response = $app->run(true);
  if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
      // We use UTF-8 for proper international characters handling.
      header('Content-Type: text/html; charset=utf-8');
 -    renderPage($conf, $pluginManager, $linkDb);
 +    renderPage($conf, $pluginManager, $linkDb, $history);
  } else {
      $app->respond($response);
  }
diff --combined tests/LinkDBTest.php
index 2523467d19aeb7e688cbc3e873822bdce51ece94,6fbf597ae175d1e07f22c33f41a964639a63436e..25438277e5babf1b7850c3dd65fd92972c7c37aa
@@@ -101,7 -101,7 +101,7 @@@ class LinkDBTest extends PHPUnit_Framew
       * Attempt to instantiate a LinkDB whereas the datastore is not writable
       *
       * @expectedException              IOException
 -     * @expectedExceptionMessageRegExp /Error accessing\nnull/
 +     * @expectedExceptionMessageRegExp /Error accessing "null"/
       */
      public function testConstructDatastoreNotWriteable()
      {
                  'sTuff' => 2,
                  'ut' => 1,
              ),
 -            self::$publicLinkDB->allTags()
 +            self::$publicLinkDB->linksCountPerTag()
          );
  
          $this->assertEquals(
                  'tag4' => 1,
                  'ut' => 1,
              ),
 -            self::$privateLinkDB->allTags()
 +            self::$privateLinkDB->linksCountPerTag()
 +        );
 +        $this->assertEquals(
 +            array(
 +                'web' => 4,
 +                'cartoon' => 2,
 +                'gnu' => 1,
 +                'dev' => 1,
 +                'samba' => 1,
 +                'media' => 1,
 +                'html' => 1,
 +                'w3c' => 1,
 +                'css' => 1,
 +                'Mercurial' => 1,
 +                '.hidden' => 1,
 +                'hashtag' => 1,
 +            ),
 +            self::$privateLinkDB->linksCountPerTag(['web'])
 +        );
 +        $this->assertEquals(
 +            array(
 +                'web' => 1,
 +                'html' => 1,
 +                'w3c' => 1,
 +                'css' => 1,
 +                'Mercurial' => 1,
 +            ),
 +            self::$privateLinkDB->linksCountPerTag(['web'], 'private')
          );
      }
  
      public function testReorderLinksDesc()
      {
          self::$privateLinkDB->reorder('ASC');
-         $linkIds = array(42, 4, 1, 0, 7, 6, 8, 41);
+         $linkIds = array(42, 4, 9, 1, 0, 7, 6, 8, 41);
          $cpt = 0;
          foreach (self::$privateLinkDB as $key => $value) {
              $this->assertEquals($linkIds[$cpt++], $key);
index 84ae7f7af863dedc7c5e19e9f7b0264e79c2a173,f1b262bc16faa2d8193cc0dc8f7eb68910ee2ad1..4cb70224ce734318862810e647eabdd52490d452
@@@ -61,7 -61,6 +61,7 @@@ class GetLinksTest extends \PHPUnit_Fra
          $this->container = new Container();
          $this->container['conf'] = $this->conf;
          $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
 +        $this->container['history'] = null;
  
          $this->controller = new Links($this->container);
      }
@@@ -95,7 -94,7 +95,7 @@@
          $this->assertEquals($this->refDB->countLinks(), count($data));
  
          // Check order
-         $order = [41, 8, 6, 7, 0, 1, 4, 42];
+         $order = [41, 8, 6, 7, 0, 1, 9, 4, 42];
          $cpt = 0;
          foreach ($data as $link) {
              $this->assertEquals(self::NB_FIELDS_LINK, count($link));
          $data = json_decode((string) $response->getBody(), true);
          $this->assertEquals($this->refDB->countLinks(), count($data));
          // Check order
-         $order = [41, 8, 6, 7, 0, 1, 4, 42];
+         $order = [41, 8, 6, 7, 0, 1, 9, 4, 42];
          $cpt = 0;
          foreach ($data as $link) {
              $this->assertEquals(self::NB_FIELDS_LINK, count($link));
index e85eb281ffa5c750c8389f33ea7a8288882c52b0,5d6a23298670fe694f75bb4dd51a78a3b66ecb8e..f7e63bfaf2623e498ce0f9605c76aad7e517975b
@@@ -54,7 -54,6 +54,7 @@@ class InfoTest extends \PHPUnit_Framewo
          $this->container = new Container();
          $this->container['conf'] = $this->conf;
          $this->container['db'] = new \LinkDB(self::$testDatastore, true, false);
 +        $this->container['history'] = null;
  
          $this->controller = new Info($this->container);
      }
@@@ -81,7 -80,7 +81,7 @@@
          $this->assertEquals(200, $response->getStatusCode());
          $data = json_decode((string) $response->getBody(), true);
  
-         $this->assertEquals(8, $data['global_counter']);
+         $this->assertEquals(\ReferenceLinkDB::$NB_LINKS_TOTAL, $data['global_counter']);
          $this->assertEquals(2, $data['private_counter']);
          $this->assertEquals('Shaarli', $data['settings']['title']);
          $this->assertEquals('?', $data['settings']['header_link']);
          $this->assertEquals(200, $response->getStatusCode());
          $data = json_decode((string) $response->getBody(), true);
  
-         $this->assertEquals(8, $data['global_counter']);
+         $this->assertEquals(\ReferenceLinkDB::$NB_LINKS_TOTAL, $data['global_counter']);
          $this->assertEquals(2, $data['private_counter']);
          $this->assertEquals($title, $data['settings']['title']);
          $this->assertEquals($headerLink, $data['settings']['header_link']);
index 1f4b306372e9a8dd4959d9e3636c39220ea01202,29d63facf7a92adeec871ee0f4844dc40ecd7cbf..f09eebc13b26f701ecc8944602794bc22adbc219
@@@ -4,7 -4,7 +4,7 @@@
   */
  class ReferenceLinkDB
  {
-     public static $NB_LINKS_TOTAL = 8;
+     public static $NB_LINKS_TOTAL = 9;
  
      private $_links = array();
      private $_publicCount = 0;
              'ut'
          );
  
+         $this->addLink(
+             9,
+             'PSR-2: Coding Style Guide',
+             'http://www.php-fig.org/psr/psr-2/',
+             'This guide extends and expands on PSR-1, the basic coding standard.',
+             0,
+             DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_152312'),
+             ''
+         );
          $this->addLink(
              8,
              'Free as in Freedom 2.0 @website',
@@@ -56,7 -66,7 +66,7 @@@
              0,
              DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130614_184135'),
              'gnu media web .hidden hashtag',
 -            null,
 +            DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130615_184230'),
              'IuWvgA'
          );
  
          return $this->_privateCount;
      }
  
+     /**
+      * Returns the number of links without tag
+      */
+     public function countUntaggedLinks()
+     {
+         $cpt = 0;
+         foreach ($this->_links as $link) {
+             if (empty($link['tags'])) {
+                 ++$cpt;
+             }
+         }
+         return $cpt;
+     }
      public function getLinks()
      {
          return $this->_links;
index 6a4e14a6a820b2bf0629b07ab312c642fee2abac,3d6be52993f292ee19513d696f75e7b74f4ca52c..2568a5d6525c1622d77ea9bdadaad9140dadc34a
@@@ -15,8 -15,6 +15,8 @@@
    {/if}
  </div>
  
 +<input type="hidden" name="token" value="{$token}">
 +
  <div id="search-linklist">
  
    <div class="pure-g">
@@@ -91,7 -89,7 +91,7 @@@
          <div id="searchcriteria">{'Nothing found.'|t}</div>
        </div>
      </div>
-   {elseif="!empty($search_term) or !empty($search_tags) or !empty($visibility)"}
+   {elseif="!empty($search_term) or $search_tags !== '' or !empty($visibility)"}
      <div class="pure-g pure-alert pure-alert-success search-result">
        <div class="pure-u-2-24"></div>
        <div class="pure-u-20-24">
                  <a href="?removetag={function="urlencode($value)"}">{$value}<span class="remove"><i class="fa fa-times"></i></span></a>
                </span>
            {/loop}
+         {elseif="$search_tags === false"}
+           <span class="label label-tag" title="{'Remove tag'|t}">
+             <a href="?">{'untagged'|t}<span class="remove"><i class="fa fa-times"></i></span></a>
+           </span>
          {/if}
          {if="!empty($visibility)"}
            {'with status'|t}
      <div class="pure-u-lg-20-24 pure-u-22-24">
        {loop="links"}
          <div class="anchor" id="{$value.shorturl}"></div>
 -        <div class="linklist-item{if="$value.class"} {$value.class}{/if}">
 +        <div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}">
  
            <div class="linklist-item-title">
              {if="isLoggedIn()"}
                  {if="$value.private"}
                    <span class="label label-private">{'Private'|t}</span>
                  {/if}
 +                <input type="checkbox" class="delete-checkbox" value="{$value.id}">
                  <!-- FIXME! JS translation -->
                  <a href="?edit_link={$value.id}" title="{'Edit'|t}"><i class="fa fa-pencil-square-o edit-link"></i></a>
                  <a href="#" title="{'Fold'|t}" class="fold-button"><i class="fa fa-chevron-up"></i></a>