]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Adds a taglist view with edit/delete buttons
authorArthurHoaro <arthur@hoa.ro>
Sat, 25 Mar 2017 14:59:01 +0000 (15:59 +0100)
committerArthurHoaro <arthur@hoa.ro>
Thu, 25 May 2017 13:25:04 +0000 (15:25 +0200)
  * The tag list can be sort alphabetically or by most used tag
  * Edit/Delete are perform using AJAX, or fallback to 'do=changetag' page
  * New features aren't backported to vintage theme

application/Router.php
application/Utils.php
index.php
tests/UtilsTest.php
tpl/default/changetag.html
tpl/default/css/shaarli.css
tpl/default/js/shaarli.js
tpl/default/tag.list.html [new file with mode: 0644]
tpl/default/tag.sort.html [new file with mode: 0644]

index f6896b1cfcd578ac02792d0f9c4f673f169aa136..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';
@@ -79,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;
         }
index ab463af9749cf3a597305fa37e1e91dbcf26046f..9d0ebc5ea3a86c68ac7693c9f793855866658d0c 100644 (file)
@@ -435,3 +435,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 de098ab55320fd19f55d976daef8ed8ec2106439..61b71129810114d95dbb9697ae5e649de2fa475a 100644 (file)
--- a/index.php
+++ b/index.php
@@ -791,7 +791,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     if ($targetPage == Router::$PAGE_TAGCLOUD)
     {
         $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : array();
+        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
         $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
 
         // We sort tags alphabetically, then choose a font size according to count.
@@ -801,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) {
@@ -839,6 +829,31 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
         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;
+    }
+
     // Daily page.
     if ($targetPage == Router::$PAGE_DAILY) {
         showDaily($PAGE, $LINKSDB, $conf, $pluginManager);
@@ -1152,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;
         }
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 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 4415a1b70f5a32fb0133cc9d0979b0961ba3fac8..2eda5df4bb1a69f448d80f8567d8e55313588da0 100644 (file)
@@ -751,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;
 }
 
@@ -1070,7 +1071,7 @@ form[name="linkform"].page-form {
 }
 
 #cloudtag, #cloudtag a {
-    color: #000;
+    color: #252525;
     text-decoration: none;
 }
 
@@ -1078,6 +1079,38 @@ 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 .delete-tag {
+    color: #ac2925;
+    display: none;
+}
+
+#taglist .rename-tag {
+    color: #0b5ea6;
+}
+
+#taglist .validate-rename-tag {
+    color: #1b926c;
+}
+
 /**
  * Picture wall CSS
  */
@@ -1227,3 +1260,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 ceb1d1b863fd096a437ff24e8ac1b661e07347f8..e19e90010b08cafd30de1d95919663c8ca5a3a39 100644 (file)
@@ -412,8 +412,139 @@ window.onload = function () {
             }
         });
     }
+
+    /**
+     * Tag list operations
+     *
+     * TODO: support error code in the backend for AJAX requests
+     */
+    // 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');
+            form.style.display = form.style.display == 'none' ? 'block' : 'none';
+        });
+    });
+
+    // 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);
+                    input.parentNode.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));
+                }
+            };
+            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();
+            }
+        });
+    });
 };
 
+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;
+}
+
+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();
+}
+
+/**
+ * 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("/"));
@@ -445,8 +576,11 @@ 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;
+    if (reset == null) {
+        reset = false;
+    }
     [].forEach.call(cities, function (option) {
         if (option.getAttribute('data-continent') != currentContinent) {
             option.className = 'hidden';
diff --git a/tpl/default/tag.list.html b/tpl/default/tag.list.html
new file mode 100644 (file)
index 0000000..9897105
--- /dev/null
@@ -0,0 +1,82 @@
+<!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" style="display:none;">
+              <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>
+
+{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