]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #880 from ArthurHoaro/hotfix/allowed-protocols
authorArthurHoaro <arthur@hoa.ro>
Wed, 31 May 2017 15:52:19 +0000 (17:52 +0200)
committerGitHub <noreply@github.com>
Wed, 31 May 2017 15:52:19 +0000 (17:52 +0200)
Add a whitelist of protocols for URLs

26 files changed:
application/FeedBuilder.php
application/LinkDB.php
application/LinkFilter.php
application/PageBuilder.php
application/Router.php
application/Utils.php
index.php
tests/LinkDBTest.php
tests/LinkFilterTest.php
tests/UtilsTest.php
tests/api/controllers/GetLinksTest.php
tests/api/controllers/InfoTest.php
tests/utils/ReferenceLinkDB.php
tpl/default/changetag.html
tpl/default/css/shaarli.css
tpl/default/js/shaarli.js
tpl/default/linklist.html
tpl/default/page.footer.html
tpl/default/page.header.html
tpl/default/tag.cloud.html [moved from tpl/default/tagcloud.html with 50% similarity]
tpl/default/tag.list.html [new file with mode: 0644]
tpl/default/tag.sort.html [new file with mode: 0644]
tpl/default/tools.html
tpl/vintage/linklist.html
tpl/vintage/tag.cloud.html [moved from tpl/vintage/tagcloud.html with 89% similarity]
tpl/vintage/tools.html

index a1f4da4810c0b25dceebb8d6698de93cf4eb81e9..7377bcec09c3fa21b92434953661133b386e0829 100644 (file)
@@ -97,6 +97,11 @@ class FeedBuilder
      */
     public function buildData()
     {
+        // Search for untagged links
+        if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
+            $this->userInput['searchtags'] = false;
+        }
+
         // Optionally filter the results:
         $linksToDisplay = $this->linkDB->filterSearch($this->userInput);
 
index 0d3c85bd9815e2c879d8b18160441dfd19fd80f5..8ca0fab30d55cbee54501579375e7d0697f48e62 100644 (file)
@@ -423,43 +423,29 @@ You use the community supported version of the original Shaarli project, by Seba
     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;
index 81832a4b428f4bc650d3049b8e0ced2a850c1cdc..0e887d3805a2fa7ef2024f5da2d6b155dc4a870e 100644 (file)
@@ -253,6 +253,9 @@ class LinkFilter
     {
         // Implode if array for clean up.
         $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
+        if ($tags === false) {
+            return $this->filterUntagged($visibility);
+        }
         if (empty($tags)) {
             return $this->noFilter($visibility);
         }
@@ -295,6 +298,33 @@ class LinkFilter
         return $filtered;
     }
 
+    /**
+     * Return only links without any tag.
+     *
+     * @param string $visibility return only all/private/public links.
+     *
+     * @return array filtered links.
+     */
+    public function filterUntagged($visibility)
+    {
+        $filtered = [];
+        foreach ($this->links as $key => $link) {
+            if ($visibility !== 'all') {
+                if (! $link['private'] && $visibility === 'private') {
+                    continue;
+                } else if ($link['private'] && $visibility === 'public') {
+                    continue;
+                }
+            }
+
+            if (empty(trim($link['tags']))) {
+                $filtered[$key] = $link;
+            }
+        }
+
+        return $filtered;
+    }
+
     /**
      * Returns the list of articles for a given day, chronologically sorted
      *
index 50e3f1248d789436741341d80fb78211a728fccc..c86621a254d4b3f262811e6d2a2588cac0ad7c2b 100644 (file)
@@ -89,7 +89,7 @@ class PageBuilder
         $this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
         $this->tpl->assign('token', getToken($this->conf));
         if ($this->linkDB !== null) {
-            $this->tpl->assign('tags', $this->linkDB->allTags());
+            $this->tpl->assign('tags', $this->linkDB->linksCountPerTag());
         }
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
index c9a519120eecf3274c1b66a7df5f4e2deacbec95..4df0387c56160f0f4a6998986fb6497d8f745139 100644 (file)
@@ -13,6 +13,8 @@ class Router
 
     public static $PAGE_TAGCLOUD = 'tagcloud';
 
+    public static $PAGE_TAGLIST = 'taglist';
+
     public static $PAGE_DAILY = 'daily';
 
     public static $PAGE_FEED_ATOM = 'atom';
@@ -45,6 +47,8 @@ class Router
 
     public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
 
+    public static $GET_TOKEN = 'token';
+
     /**
      * Reproducing renderPage() if hell, to avoid regression.
      *
@@ -77,6 +81,10 @@ class Router
             return self::$PAGE_TAGCLOUD;
         }
 
+        if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
+            return self::$PAGE_TAGLIST;
+        }
+
         if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
             return self::$PAGE_OPENSEARCH;
         }
@@ -142,6 +150,10 @@ class Router
             return self::$PAGE_SAVE_PLUGINSADMIN;
         }
 
+        if (startsWith($query, 'do='. self::$GET_TOKEN)) {
+            return self::$GET_TOKEN;
+        }
+
         return self::$PAGE_LINKLIST;
     }
 }
index ab463af9749cf3a597305fa37e1e91dbcf26046f..4a2f5561cfdf5dfb38ed9a0a4c0f46b58b27c8c6 100644 (file)
@@ -91,6 +91,10 @@ function endsWith($haystack, $needle, $case = true)
  */
 function escape($input)
 {
+    if (is_bool($input)) {
+        return $input;
+    }
+
     if (is_array($input)) {
         $out = array();
         foreach($input as $key => $value) {
@@ -435,3 +439,34 @@ function get_max_upload_size($limitPost, $limitUpload, $format = true)
     $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);
+    }
+}
index 944af674f4862dc4adff89342af85b327d92b453..823eb8dea7834100f40a3525770ecdce29b2f20f 100644 (file)
--- a/index.php
+++ b/index.php
@@ -790,7 +790,9 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     // -------- 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.
@@ -799,17 +801,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             $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) {
@@ -824,6 +816,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
         }
 
         $data = array(
+            'search_tags' => implode(' ', $filteringTags),
             'tags' => $tagList,
         );
         $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => isLoggedIn()));
@@ -832,7 +825,32 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             $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;
     }
 
@@ -1149,6 +1167,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     if ($targetPage == Router::$PAGE_CHANGETAG)
     {
         if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
+            $PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
             $PAGE->renderPage('changetag');
             exit;
         }
@@ -1302,18 +1321,21 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     // -------- 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);
 
@@ -1345,7 +1367,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             '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);
 
@@ -1414,7 +1436,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             '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);
@@ -1570,6 +1592,13 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
         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;
@@ -1587,7 +1616,15 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
 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
@@ -1602,7 +1639,11 @@ function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
     } 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.
@@ -1649,7 +1690,7 @@ function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
     }
 
     // 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)) {
index 7bf98f92101d572de71e345bf55080457b5aae7f..25438277e5babf1b7850c3dd65fd92972c7c37aa 100644 (file)
@@ -297,7 +297,7 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
                 'sTuff' => 2,
                 'ut' => 1,
             ),
-            self::$publicLinkDB->allTags()
+            self::$publicLinkDB->linksCountPerTag()
         );
 
         $this->assertEquals(
@@ -325,7 +325,34 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
                 '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')
         );
     }
 
@@ -448,7 +475,7 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
     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 37d5ca306e4f128edd350e1f88542fab7880ea84..741623580ce30e0980638675db8f17b9e02d2e70 100644 (file)
@@ -63,6 +63,12 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
             count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, ''))
         );
 
+        // Untagged only
+        $this->assertEquals(
+            self::$refDB->countUntaggedLinks(),
+            count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, false))
+        );
+
         $this->assertEquals(
             ReferenceLinkDB::$NB_LINKS_TOTAL,
             count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, ''))
@@ -146,7 +152,7 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
     public function testFilterDay()
     {
         $this->assertEquals(
-            3,
+            4,
             count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20121206'))
         );
     }
@@ -339,7 +345,7 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
         );
 
         $this->assertEquals(
-            7,
+            ReferenceLinkDB::$NB_LINKS_TOTAL - 1,
             count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '-revolution'))
         );
     }
@@ -399,7 +405,7 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
         );
 
         $this->assertEquals(
-            7,
+            ReferenceLinkDB::$NB_LINKS_TOTAL - 1,
             count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free'))
         );
     }
@@ -425,6 +431,13 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
                 array('', $terms)
             ))
         );
+        $this->assertEquals(
+            1,
+            count(self::$linkFilter->filter(
+                LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
+                array(false, 'PSR-2')
+            ))
+        );
         $this->assertEquals(
             1,
             count(self::$linkFilter->filter(
index d6a0aad5e8e0201da8441dcaa83eab6d7181575b..3d1aa6538918f8ad48dc5436b91dd03253a3eadd 100644 (file)
@@ -417,4 +417,116 @@ class UtilsTest extends PHPUnit_Framework_TestCase
         $this->assertEquals('1048576', get_max_upload_size('1m', '2m', false));
         $this->assertEquals('100', get_max_upload_size(100, 100, false));
     }
+
+    /**
+     * Test alphabetical_sort by value, not reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByValue()
+    {
+        $arr = [
+            'zZz',
+            'éee',
+            'éae',
+            'eee',
+            'A',
+            'a',
+            'zzz',
+        ];
+        $expected = [
+            'a',
+            'A',
+            'éae',
+            'eee',
+            'éee',
+            'zzz',
+            'zZz',
+        ];
+
+        alphabetical_sort($arr);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by value, reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByValueReversed()
+    {
+        $arr = [
+            'zZz',
+            'éee',
+            'éae',
+            'eee',
+            'A',
+            'a',
+            'zzz',
+        ];
+        $expected = [
+            'zZz',
+            'zzz',
+            'éee',
+            'eee',
+            'éae',
+            'A',
+            'a',
+        ];
+
+        alphabetical_sort($arr, true);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by keys, not reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByKeys()
+    {
+        $arr = [
+            'zZz' => true,
+            'éee' => true,
+            'éae' => true,
+            'eee' => true,
+            'A' => true,
+            'a' => true,
+            'zzz' => true,
+        ];
+        $expected = [
+            'a' => true,
+            'A' => true,
+            'éae' => true,
+            'eee' => true,
+            'éee' => true,
+            'zzz' => true,
+            'zZz' => true,
+        ];
+
+        alphabetical_sort($arr, true, true);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by keys, reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByKeysReversed()
+    {
+        $arr = [
+            'zZz' => true,
+            'éee' => true,
+            'éae' => true,
+            'eee' => true,
+            'A' => true,
+            'a' => true,
+            'zzz' => true,
+        ];
+        $expected = [
+            'zZz' => true,
+            'zzz' => true,
+            'éee' => true,
+            'eee' => true,
+            'éae' => true,
+            'A' => true,
+            'a' => true,
+        ];
+
+        alphabetical_sort($arr, true, true);
+        $this->assertEquals($expected, $arr);
+    }
 }
index 84ae7f7af863dedc7c5e19e9f7b0264e79c2a173..4cb70224ce734318862810e647eabdd52490d452 100644 (file)
@@ -95,7 +95,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase
         $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));
@@ -164,7 +164,7 @@ class GetLinksTest extends \PHPUnit_Framework_TestCase
         $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..f7e63bfaf2623e498ce0f9605c76aad7e517975b 100644 (file)
@@ -81,7 +81,7 @@ class InfoTest extends \PHPUnit_Framework_TestCase
         $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']);
@@ -104,7 +104,7 @@ class InfoTest extends \PHPUnit_Framework_TestCase
         $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..f09eebc13b26f701ecc8944602794bc22adbc219 100644 (file)
@@ -4,7 +4,7 @@
  */
 class ReferenceLinkDB
 {
-    public static $NB_LINKS_TOTAL = 8;
+    public static $NB_LINKS_TOTAL = 9;
 
     private $_links = array();
     private $_publicCount = 0;
@@ -37,6 +37,16 @@ class ReferenceLinkDB
             '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',
@@ -161,6 +171,20 @@ class ReferenceLinkDB
         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 8d263a16df5bddb334a7daac16f29793d1694bb4..49dd20d951952082202e213a8433db1f619c6da2 100644 (file)
@@ -11,7 +11,7 @@
     <h2 class="window-title">{"Manage tags"|t}</h2>
     <form method="POST" action="#" name="changetag" id="changetag">
       <div>
-        <input type="text" name="fromtag" placeholder="{'Tag'|t}"
+        <input type="text" name="fromtag" placeholder="{'Tag'|t}" value="{$fromtag}"
                list="tagsList" autocomplete="off" class="awesomplete autofocus" data-minChars="1">
         <datalist id="tagsList">
           {loop="$tags"}<option>{$key}</option>{/loop}
@@ -31,6 +31,8 @@
         <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
       </div>
     </form>
+
+    <p>You can also edit tags in the <a href="?do=taglist&sort=usage">tag list</a>.</p>
   </div>
 </div>
 {include="page.footer"}
index 73fade5ffa3a4a6570ecb296baa7e51391f0f3df..3391fa0539ee7b49a5df06f303dce3cbc9b0681b 100644 (file)
@@ -211,7 +211,7 @@ body, .pure-g [class*="pure-u"] {
     }
 }
 
-#search, #search-linklist {
+#search, #search-linklist, #search-tagcloud {
     text-align: center;
     width: 100%;
 }
@@ -234,6 +234,7 @@ body, .pure-g [class*="pure-u"] {
 }
 
 #search button,
+#search-tagcloud button,
 #search-linklist button {
     background: transparent;
     border: none;
@@ -251,6 +252,9 @@ body, .pure-g [class*="pure-u"] {
 #search-linklist button:hover {
     color: #fff;
 }
+#search-tagcloud button:hover {
+    color: #d0d0d0;
+}
 
 #search-linklist {
     padding: 5px 0;
@@ -275,6 +279,19 @@ body, .pure-g [class*="pure-u"] {
     }
 }
 
+.subheader-form a.button {
+    color: #f5f5f5;
+    font-weight: bold;
+    text-decoration: none;
+    border: 2px solid #f5f5f5;
+    border-radius: 5px;
+    padding: 3px 10px;
+}
+
+.linklist-item-editbuttons .delete-checkbox {
+    display: none;
+}
+
 #header-login-form input[type="text"], #header-login-form input[type="password"] {
     width: 200px;
 }
@@ -522,8 +539,8 @@ body, .pure-g [class*="pure-u"] {
     color: #1b926c;
 }
 
-.linklist-item-title .linklist-link:visited {
-    color: #1b926c;
+.linklist-item-title a:visited .linklist-link {
+    color: #555555;
 }
 
 .linklist-item-title a:hover, .linklist-item-title .linklist-link:hover{
@@ -734,10 +751,11 @@ body, .pure-g [class*="pure-u"] {
 .page-form a {
     color: #1b926c;
     font-weight: bold;
+    text-decoration: none;
 }
 
 .page-form p {
-    padding: 0 10px;
+    padding: 5px 10px;
     margin: 0;
 }
 
@@ -1053,7 +1071,7 @@ form[name="linkform"].page-form {
 }
 
 #cloudtag, #cloudtag a {
-    color: #000;
+    color: #252525;
     text-decoration: none;
 }
 
@@ -1061,6 +1079,42 @@ form[name="linkform"].page-form {
     color: #7f7f7f;
 }
 
+/**
+ * TAG LIST
+ */
+#taglist {
+    padding: 0 10px;
+}
+
+#taglist a {
+    color: #252525;
+    text-decoration: none;
+}
+
+#taglist .count {
+    display: inline-block;
+    width: 35px;
+    text-align: right;
+    color: #7f7f7f;
+}
+
+#taglist .rename-tag-form {
+    display: none;
+}
+
+#taglist .delete-tag {
+    color: #ac2925;
+    display: none;
+}
+
+#taglist .rename-tag {
+    color: #0b5ea6;
+}
+
+#taglist .validate-rename-tag {
+    color: #1b926c;
+}
+
 /**
  * Picture wall CSS
  */
@@ -1210,3 +1264,16 @@ form[name="linkform"].page-form {
 .pure-button {
     -moz-user-select: auto;
 }
+
+.tag-sort {
+    margin-top: 30px;
+    text-align: center;
+}
+
+.tag-sort a {
+    display: inline-block;
+    margin: 0 15px;
+    color: white;
+    text-decoration: none;
+    font-weight: bold;
+}
index 4d47fcd0c2cd4aaf3be8080e84c61782adabd247..4ebb7815a1d4c4860224228e65f4f9f5f68af00f 100644 (file)
@@ -216,14 +216,14 @@ window.onload = function () {
     /**
      * Autofocus text fields
      */
-    // ES6 syntax
-    let autofocusElements = document.querySelectorAll('.autofocus');
-    for (let autofocusElement of autofocusElements) {
-        if (autofocusElement.value == '') {
+    var autofocusElements = document.querySelectorAll('.autofocus');
+    var breakLoop = false;
+    [].forEach.call(autofocusElements, function(autofocusElement) {
+        if (autofocusElement.value == '' && ! breakLoop) {
             autofocusElement.focus();
-            break;
+            breakLoop = true;
         }
-    }
+    });
 
     /**
      * Handle sub menus/forms
@@ -357,13 +357,252 @@ window.onload = function () {
     var continent = document.getElementById('continent');
     var city = document.getElementById('city');
     if (continent != null && city != null) {
-        continent.addEventListener('change', function(event) {
+        continent.addEventListener('change', function (event) {
             hideTimezoneCities(city, continent.options[continent.selectedIndex].value, true);
         });
         hideTimezoneCities(city, continent.options[continent.selectedIndex].value, false);
     }
+
+    /**
+     * Bulk actions
+     */
+    var linkCheckboxes = document.querySelectorAll('.delete-checkbox');
+    var bar = document.getElementById('actions');
+    [].forEach.call(linkCheckboxes, function(checkbox) {
+        checkbox.style.display = 'block';
+        checkbox.addEventListener('click', function(event) {
+            var count = 0;
+            var linkCheckedCheckboxes = document.querySelectorAll('.delete-checkbox:checked');
+            [].forEach.call(linkCheckedCheckboxes, function(checkbox) {
+                count++;
+            });
+            if (count == 0 && bar.classList.contains('open')) {
+                bar.classList.toggle('open');
+            } else if (count > 0 && ! bar.classList.contains('open')) {
+                bar.classList.toggle('open');
+            }
+        });
+    });
+
+    var deleteButton = document.getElementById('actions-delete');
+    var token = document.querySelector('input[type="hidden"][name="token"]');
+    if (deleteButton != null && token != null) {
+        deleteButton.addEventListener('click', function(event) {
+            event.preventDefault();
+
+            var links = [];
+            var linkCheckedCheckboxes = document.querySelectorAll('.delete-checkbox:checked');
+            [].forEach.call(linkCheckedCheckboxes, function(checkbox) {
+                links.push({
+                    'id': checkbox.value,
+                    'title': document.querySelector('.linklist-item[data-id="'+ checkbox.value +'"] .linklist-link').innerHTML
+                });
+            });
+
+            var message = 'Are you sure you want to delete '+ links.length +' links?\n';
+            message += 'This action is IRREVERSIBLE!\n\nTitles:\n';
+            var ids = '';
+            links.forEach(function(item) {
+                message += '  - '+ item['title'] +'\n';
+                ids += item['id'] +'+';
+            });
+
+            if (window.confirm(message)) {
+                window.location = '?delete_link&lf_linkdate='+ ids +'&token='+ token.value;
+            }
+        });
+    }
+
+    /**
+     * Tag list operations
+     *
+     * TODO: support error code in the backend for AJAX requests
+     */
+    var existingTags = document.querySelector('input[name="taglist"]').value.split(' ');
+    var awesomepletes = [];
+
+    // Display/Hide rename form
+    var renameTagButtons = document.querySelectorAll('.rename-tag');
+    [].forEach.call(renameTagButtons, function(rename) {
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var form = block.querySelector('.rename-tag-form');
+            if (form.style.display == 'none' || form.style.display == '') {
+                form.style.display = 'block';
+            } else {
+                form.style.display = 'none';
+            }
+            block.querySelector('input').focus();
+        });
+    });
+
+    // Rename a tag with an AJAX request
+    var renameTagSubmits = document.querySelectorAll('.validate-rename-tag');
+    [].forEach.call(renameTagSubmits, function(rename) {
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var input = block.querySelector('.rename-tag-input');
+            var totag = input.value.replace('/"/g', '\\"');
+            if (totag.trim() == '') {
+                return;
+            }
+            var fromtag = block.getAttribute('data-tag');
+            var token = document.getElementById('token').value;
+
+            xhr = new XMLHttpRequest();
+            xhr.open('POST', '?do=changetag');
+            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+            xhr.onload = function() {
+                if (xhr.status !== 200) {
+                    alert('An error occurred. Return code: '+ xhr.status);
+                    location.reload();
+                } else {
+                    block.setAttribute('data-tag', totag);
+                    input.setAttribute('name', totag);
+                    input.setAttribute('value', totag);
+                    findParent(input, 'div', {'class': 'rename-tag-form'}).style.display = 'none';
+                    block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
+                    block.querySelector('a.tag-link').setAttribute('href', '?searchtags='+ encodeURIComponent(totag));
+                    block.querySelector('a.rename-tag').setAttribute('href', '?do=changetag&fromtag='+ encodeURIComponent(totag));
+
+                    // Refresh awesomplete values
+                    for (var key in existingTags) {
+                        if (existingTags[key] == fromtag) {
+                            existingTags[key] = totag;
+                        }
+                    }
+                    awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+                }
+            };
+            xhr.send('renametag=1&fromtag='+ encodeURIComponent(fromtag) +'&totag='+ encodeURIComponent(totag) +'&token='+ token);
+            refreshToken();
+        });
+    });
+
+    // Validate input with enter key
+    var renameTagInputs = document.querySelectorAll('.rename-tag-input');
+    [].forEach.call(renameTagInputs, function(rename) {
+
+        rename.addEventListener('keypress', function(event) {
+            if (event.keyCode === 13) { // enter
+                findParent(event.target, 'div', {'class': 'tag-list-item'}).querySelector('.validate-rename-tag').click();
+            }
+        });
+    });
+
+    // Delete a tag with an AJAX query (alert popup confirmation)
+    var deleteTagButtons = document.querySelectorAll('.delete-tag');
+    [].forEach.call(deleteTagButtons, function(rename) {
+        rename.style.display = 'inline';
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var tag = block.getAttribute('data-tag');
+            var token = document.getElementById('token').value;
+
+            if (confirm('Are you sure you want to delete the tag "'+ tag +'"?')) {
+                xhr = new XMLHttpRequest();
+                xhr.open('POST', '?do=changetag');
+                xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+                xhr.onload = function() {
+                    block.remove();
+                };
+                xhr.send(encodeURI('deletetag=1&fromtag='+ tag +'&token='+ token));
+                refreshToken();
+            }
+        });
+    });
+
+    updateAwesompleteList('.rename-tag-input', document.querySelector('input[name="taglist"]').value.split(' '), awesomepletes);
 };
 
+/**
+ * Find a parent element according to its tag and its attributes
+ *
+ * @param element    Element where to start the search
+ * @param tagName    Expected parent tag name
+ * @param attributes Associative array of expected attributes (name=>value).
+ *
+ * @returns Found element or null.
+ */
+function findParent(element, tagName, attributes)
+{
+    while (element) {
+        if (element.tagName.toLowerCase() == tagName) {
+            var match = true;
+            for (var key in attributes) {
+                if (! element.hasAttribute(key)
+                    || (attributes[key] != '' && element.getAttribute(key).indexOf(attributes[key]) == -1)
+                ) {
+                    match = false;
+                    break;
+                }
+            }
+
+            if (match) {
+                return element;
+            }
+        }
+        element = element.parentElement;
+    }
+    return null;
+}
+
+/**
+ * Ajax request to refresh the CSRF token.
+ */
+function refreshToken()
+{
+    var xhr = new XMLHttpRequest();
+    xhr.open('GET', '?do=token');
+    xhr.onload = function() {
+        var token = document.getElementById('token');
+        token.setAttribute('value', xhr.responseText);
+    };
+    xhr.send();
+}
+
+/**
+ * Update awesomplete list of tag for all elements matching the given selector
+ *
+ * @param selector  CSS selector
+ * @param tags      Array of tags
+ * @param instances List of existing awesomplete instances
+ */
+function updateAwesompleteList(selector, tags, instances)
+{
+    // First load: create Awesomplete instances
+    if (instances.length == 0) {
+        var elements = document.querySelectorAll(selector);
+        [].forEach.call(elements, function (element) {
+            instances.push(new Awesomplete(
+                element,
+                {'list': tags}
+            ));
+        });
+    } else {
+        // Update awesomplete tag list
+        for (var key in instances) {
+            instances[key].list = tags;
+        }
+    }
+    return instances;
+}
+
+/**
+ * html_entities in JS
+ *
+ * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
+ */
+function htmlEntities(str)
+{
+    return str.replace(/[\u00A0-\u9999<>\&]/gim, function(i) {
+        return '&#'+i.charCodeAt(0)+';';
+    });
+}
+
 function activateFirefoxSocial(node) {
     var loc = location.href;
     var baseURL = loc.substring(0, loc.lastIndexOf("/"));
@@ -395,9 +634,12 @@ function activateFirefoxSocial(node) {
  * @param currentContinent Current selected continent
  * @param reset            Set to true to reset the selected value
  */
-function hideTimezoneCities(cities, currentContinent, reset = false) {
+function hideTimezoneCities(cities, currentContinent) {
     var first = true;
-    [].forEach.call(cities, function(option) {
+    if (reset == null) {
+        reset = false;
+    }
+    [].forEach.call(cities, function (option) {
         if (option.getAttribute('data-continent') != currentContinent) {
             option.className = 'hidden';
         } else {
index 57ef4567a8ee754c1f308c3b153e6d0188223b86..2568a5d6525c1622d77ea9bdadaad9140dadc34a 100644 (file)
@@ -15,6 +15,8 @@
   {/if}
 </div>
 
+<input type="hidden" name="token" value="{$token}">
+
 <div id="search-linklist">
 
   <div class="pure-g">
@@ -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>
index 77fc65dd9630feef1520b28c43569f18d13f8b85..02fc76428e3110420f6d284cec6e7151b65a24ba 100644 (file)
@@ -16,6 +16,9 @@
   </div>
   <div class="pure-u-2-24"></div>
 </div>
+
+<input type="hidden" name="token" value="{$token}" id="token" />
+
 {loop="$plugins_footer.endofpage"}
     {$value}
 {/loop}
index 9388ef79e9cdc42ca3e1b9c288a21df91d4255ce..6c71a718371a3f93a010a2e246dbe16fd810a930 100644 (file)
       </div>
     </div>
   </div>
+  <div id="actions" class="subheader-form">
+    <div class="pure-g">
+      <div class="pure-u-1">
+        <a href="" id="actions-delete" class="button">Delete</a>
+      </div>
+    </div>
+  </div>
   {if="!isLoggedIn()"}
     <form method="post" name="loginform">
       <div class="subheader-form" id="header-login-form">
similarity index 50%
rename from tpl/default/tagcloud.html
rename to tpl/default/tag.cloud.html
index 53c317485a1734db464d303c8333967eeb83b848..59aa2ee065d325f667c8bceec3f75947071d002a 100644 (file)
@@ -6,12 +6,32 @@
 <body>
 {include="page.header"}
 
+{include="tag.sort"}
+
 <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">
     {$countTags=count($tags)}
     <h2 class="window-title">{'Tag cloud'|t} - {$countTags} {'tags'|t}</h2>
 
+    <div id="search-tagcloud" class="pure-g">
+      <div class="pure-u-lg-1-4"></div>
+      <div class="pure-u-1 pure-u-lg-1-2">
+        <form method="GET">
+          <input type="hidden" name="do" value="tagcloud">
+          <input type="text" name="searchtags" placeholder="{'Filter by tag'|t}"
+                 {if="!empty($search_tags)"}
+                 value="{$search_tags}"
+                 {/if}
+          autocomplete="off" data-multiple data-autofirst data-minChars="1"
+          data-list="{loop="$tags"}{$key}, {/loop}"
+          >
+          <button type="submit" class="search-button"><i class="fa fa-search"></i></button>
+        </form>
+      </div>
+      <div class="pure-u-lg-1-4"></div>
+    </div>
+
     <div id="plugin_zone_start_tagcloud" class="plugin_zone">
       {loop="$plugin_start_zone"}
         {$value}
@@ -21,7 +41,7 @@
     <div id="cloudtag">
       {loop="tags"}
         <a href="?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a
-        ><span class="count">{$value.count}</span>
+        ><a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
         {loop="$value.tag_plugin"}
           {$value}
         {/loop}
@@ -36,6 +56,8 @@
   </div>
 </div>
 
+{include="tag.sort"}
+
 {include="page.footer"}
 </body>
 </html>
diff --git a/tpl/default/tag.list.html b/tpl/default/tag.list.html
new file mode 100644 (file)
index 0000000..62e2e7c
--- /dev/null
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html>
+<head>
+  {include="includes"}
+</head>
+<body>
+{include="page.header"}
+
+{include="tag.sort"}
+
+<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">
+    {$countTags=count($tags)}
+    <h2 class="window-title">{'Tag list'|t} - {$countTags} {'tags'|t}</h2>
+
+    <div id="search-tagcloud" class="pure-g">
+      <div class="pure-u-lg-1-4"></div>
+      <div class="pure-u-1 pure-u-lg-1-2">
+        <form method="GET">
+          <input type="hidden" name="do" value="taglist">
+          <input type="text" name="searchtags" placeholder="{'Filter by tag'|t}"
+                 {if="!empty($search_tags)"}
+                 value="{$search_tags}"
+                 {/if}
+          autocomplete="off" data-multiple data-autofirst data-minChars="1"
+          data-list="{loop="$tags"}{$key}, {/loop}"
+          >
+          <button type="submit" class="search-button"><i class="fa fa-search"></i></button>
+        </form>
+      </div>
+      <div class="pure-u-lg-1-4"></div>
+    </div>
+
+    <div id="plugin_zone_start_tagcloud" class="plugin_zone">
+      {loop="$plugin_start_zone"}
+        {$value}
+      {/loop}
+    </div>
+
+    <div id="taglist">
+      {loop="tags"}
+        <div class="tag-list-item pure-g" data-tag="{$key}">
+          <div class="pure-u-1">
+            {if="isLoggedIn()===true"}
+              <a href="#" class="delete-tag"><i class="fa fa-trash"></i></a>&nbsp;&nbsp;
+              <a href="?do=changetag&fromtag={$key|urlencode}" class="rename-tag">
+                <i class="fa fa-pencil-square-o {$key}"></i>
+              </a>
+            {/if}
+
+            <a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value}</a>
+            <a href="?searchtags={$key|urlencode}" class="tag-link">{$key}</a>
+
+            {loop="$value.tag_plugin"}
+              {$value}
+            {/loop}
+          </div>
+          {if="isLoggedIn()===true"}
+            <div class="rename-tag-form pure-u-1">
+              <input type="text" name="{$key}" value="{$key}" class="rename-tag-input" />
+              <a href="#" class="validate-rename-tag"><i class="fa fa-check"></i></a>
+            </div>
+          {/if}
+        </div>
+      {/loop}
+    </div>
+
+    <div id="plugin_zone_end_tagcloud" class="plugin_zone">
+      {loop="$plugin_end_zone"}
+      {$value}
+      {/loop}
+    </div>
+  </div>
+</div>
+
+{if="isLoggedIn()===true"}
+  <input type="hidden" name="taglist" value="{loop="$tags"}{$key} {/loop}"
+{/if}
+
+{include="tag.sort"}
+
+{include="page.footer"}
+</body>
+</html>
+
diff --git a/tpl/default/tag.sort.html b/tpl/default/tag.sort.html
new file mode 100644 (file)
index 0000000..89acda0
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="pure-g">
+  <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
+    {'Sort by:'|t}
+    <a href="?do=tagcloud" title="cloud">{'Cloud'|t}</a> &middot;
+    <a href="?do=taglist&sort=usage" title="cloud">{'Most used'|t}</a> &middot;
+    <a href="?do=taglist&sort=alpha" title="cloud">{'Alphabetical'|t}</a>
+  </div>
+</div>
\ No newline at end of file
index baa033aff7cb0b22cbaed714b2b04f99fd17052f..35173d179144aab4bac64494cd2e4177e13f61f2 100644 (file)
           function(){
             var%20url%20=%20location.href;
             var%20title%20=%20document.title%20||%20url;
+            var%20desc=document.getSelection().toString();
+            if(desc.length>4000){
+              desc=desc.substr(0,4000)+'...';
+              alert('{function="str_replace(' ', '%20', t('The selected text is too long, it will be truncated.'))"}');
+            }
             window.open(
               '{$pageabsaddr}?post='%20+%20encodeURIComponent(url)+
               '&amp;title='%20+%20encodeURIComponent(title)+
-              '&amp;description='%20+%20encodeURIComponent(document.getSelection())+
+              '&amp;description='%20+%20encodeURIComponent(desc)+
               '&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1'
             );
           }
     <div class="tools-item">
       <a title="{'Drag this link to your bookmarks toolbar or right-click it and Bookmark This Link'|t},
                 {'Then click ✚Add Note button anytime to start composing a private Note (text post) to your Shaarli'|t}"
-         href="?private=1&amp;post="
-         class="bookmarklet-link">
+         class="bookmarklet-link"
+         href="javascript:(
+          function(){
+            var%20desc=document.getSelection().toString();
+            if(desc.length>4000){
+              desc=desc.substr(0,4000)+'...';
+              alert("{function="str_replace(' ', '%20', t('The selected text is too long, it will be truncated.'))"}");
+            }
+            window.open(
+              '{$pageabsaddr}?private=1&amp;post='+
+              '&amp;description='%20+%20encodeURIComponent(desc)+
+              '&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1'
+            );
+          }
+        )();">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">✚ {'Add Note'|t}</span>
       </a>
     </div>
        value="{'Drag this link to your bookmarks toolbar, or right-click it and choose Bookmark This Link'|t}">
 </body>
 </html>
-
index fc116667aac83dd12bb4d45bbfad8d5744cbb560..8458caa1c89224f377370b34ecfaedeb2ae8b395 100644 (file)
@@ -55,7 +55,7 @@
 
     {if="count($links)==0"}
         <div id="searchcriteria">Nothing found.</div>
-    {elseif="!empty($search_term) or !empty($search_tags)"}
+    {elseif="!empty($search_term) or $search_tags !== ''"}
         <div id="searchcriteria">
             {$result_count} results
             {if="!empty($search_term)"}
                         <a href="?removetag={function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
                     </span>
                 {/loop}
+            {elseif="$search_tags === false"}
+                <span class="linktag" title="Remove tag">
+                    <a href="?">untagged <span class="remove">x</span></a>
+                </span>
             {/if}
         </div>
     {/if}
similarity index 89%
rename from tpl/vintage/tagcloud.html
rename to tpl/vintage/tag.cloud.html
index 05e45273ffa284a61f5a71714bf1e6e93b18fad2..d93bf4f9db94b8cdbbd4b97f3358025652320a72 100644 (file)
@@ -12,7 +12,7 @@
 
     <div id="cloudtag">
         {loop="$tags"}
-            <span class="count">{$value.count}</span><a
+            <a href="?addtag={$key|urlencode}" class="count">{$value.count}</a><a
                 href="?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a>
             {loop="$value.tag_plugin"}
                 {$value}
index c36aa5b5ef0fa1afc127e342bcbde104fc2d5010..69689807e2cdbc9f71390927187630800e46c5b5 100644 (file)
                </a><br><br>
                <a class="smallbutton"
                        onclick="return alertBookmarklet();"
-                       href="?private=1&amp;post="><b>✚Add Note</b></a>
+        href="javascript:(
+          function(){
+            window.open(
+              '{$pageabsaddr}?private=1&amp;post='+
+              '&amp;description='%20+%20encodeURIComponent(document.getSelection())+
+              '&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1'
+            );
+          }
+        )();"><b>✚Add Note</b></a>
                <a href="#" onclick="return alertBookmarklet();">
                        <span>
                                &#x21D0; Drag this link to your bookmarks toolbar (or right-click it and choose Bookmark This Link....).<br>