Merge pull request #835 from ArthurHoaro/feature/tag-cloud

Adds a taglist view with edit/delete buttons
This commit is contained in:
ArthurHoaro 2017-05-25 15:28:26 +02:00 committed by GitHub
commit 81a91579ba
12 changed files with 540 additions and 17 deletions

View file

@ -13,6 +13,8 @@ class Router
public static $PAGE_TAGCLOUD = 'tagcloud'; public static $PAGE_TAGCLOUD = 'tagcloud';
public static $PAGE_TAGLIST = 'taglist';
public static $PAGE_DAILY = 'daily'; public static $PAGE_DAILY = 'daily';
public static $PAGE_FEED_ATOM = 'atom'; public static $PAGE_FEED_ATOM = 'atom';
@ -45,6 +47,8 @@ class Router
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin'; public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
public static $GET_TOKEN = 'token';
/** /**
* Reproducing renderPage() if hell, to avoid regression. * Reproducing renderPage() if hell, to avoid regression.
* *
@ -77,6 +81,10 @@ public static function findPage($query, $get, $loggedIn)
return self::$PAGE_TAGCLOUD; return self::$PAGE_TAGCLOUD;
} }
if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
return self::$PAGE_TAGLIST;
}
if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) { if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
return self::$PAGE_OPENSEARCH; return self::$PAGE_OPENSEARCH;
} }
@ -142,6 +150,10 @@ public static function findPage($query, $get, $loggedIn)
return self::$PAGE_SAVE_PLUGINSADMIN; return self::$PAGE_SAVE_PLUGINSADMIN;
} }
if (startsWith($query, 'do='. self::$GET_TOKEN)) {
return self::$GET_TOKEN;
}
return self::$PAGE_LINKLIST; return self::$PAGE_LINKLIST;
} }
} }

View file

@ -435,3 +435,34 @@ function get_max_upload_size($limitPost, $limitUpload, $format = true)
$maxsize = min($size1, $size2); $maxsize = min($size1, $size2);
return $format ? human_bytes($maxsize) : $maxsize; 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);
}
}

View file

@ -791,7 +791,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
if ($targetPage == Router::$PAGE_TAGCLOUD) if ($targetPage == Router::$PAGE_TAGCLOUD)
{ {
$visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all'; $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); $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
// We sort tags alphabetically, then choose a font size according to count. // 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); $maxcount = max($maxcount, $value);
} }
// Sort tags alphabetically: case insensitive, support locale if available. alphabetical_sort($tags, true, true);
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);
});
$tagList = array(); $tagList = array();
foreach($tags as $key => $value) { foreach($tags as $key => $value) {
@ -835,7 +825,32 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
$PAGE->assign($key, $value); $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; exit;
} }
@ -1152,6 +1167,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
if ($targetPage == Router::$PAGE_CHANGETAG) if ($targetPage == Router::$PAGE_CHANGETAG)
{ {
if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) { if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
$PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
$PAGE->renderPage('changetag'); $PAGE->renderPage('changetag');
exit; exit;
} }
@ -1582,6 +1598,13 @@ function($a, $b) { return $a['order'] - $b['order']; }
exit; 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: // -------- Otherwise, simply display search form and links:
showLinkList($PAGE, $LINKSDB, $conf, $pluginManager); showLinkList($PAGE, $LINKSDB, $conf, $pluginManager);
exit; exit;

View file

@ -417,4 +417,116 @@ public function testGetMaxUploadSizeRaw()
$this->assertEquals('1048576', get_max_upload_size('1m', '2m', false)); $this->assertEquals('1048576', get_max_upload_size('1m', '2m', false));
$this->assertEquals('100', get_max_upload_size(100, 100, 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);
}
} }

View file

@ -11,7 +11,7 @@
<h2 class="window-title">{"Manage tags"|t}</h2> <h2 class="window-title">{"Manage tags"|t}</h2>
<form method="POST" action="#" name="changetag" id="changetag"> <form method="POST" action="#" name="changetag" id="changetag">
<div> <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"> list="tagsList" autocomplete="off" class="awesomplete autofocus" data-minChars="1">
<datalist id="tagsList"> <datalist id="tagsList">
{loop="$tags"}<option>{$key}</option>{/loop} {loop="$tags"}<option>{$key}</option>{/loop}
@ -31,6 +31,8 @@ <h2 class="window-title">{"Manage tags"|t}</h2>
<input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete"> <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
</div> </div>
</form> </form>
<p>You can also edit tags in the <a href="?do=taglist&sort=usage">tag list</a>.</p>
</div> </div>
</div> </div>
{include="page.footer"} {include="page.footer"}

View file

@ -751,10 +751,11 @@ body, .pure-g [class*="pure-u"] {
.page-form a { .page-form a {
color: #1b926c; color: #1b926c;
font-weight: bold; font-weight: bold;
text-decoration: none;
} }
.page-form p { .page-form p {
padding: 0 10px; padding: 5px 10px;
margin: 0; margin: 0;
} }
@ -1070,7 +1071,7 @@ form[name="linkform"].page-form {
} }
#cloudtag, #cloudtag a { #cloudtag, #cloudtag a {
color: #000; color: #252525;
text-decoration: none; text-decoration: none;
} }
@ -1078,6 +1079,42 @@ form[name="linkform"].page-form {
color: #7f7f7f; 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 * Picture wall CSS
*/ */
@ -1227,3 +1264,16 @@ form[name="linkform"].page-form {
.pure-button { .pure-button {
-moz-user-select: auto; -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;
}

View file

@ -412,8 +412,197 @@ window.onload = function () {
} }
}); });
} }
/**
* 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) { function activateFirefoxSocial(node) {
var loc = location.href; var loc = location.href;
var baseURL = loc.substring(0, loc.lastIndexOf("/")); var baseURL = loc.substring(0, loc.lastIndexOf("/"));
@ -445,8 +634,11 @@ function activateFirefoxSocial(node) {
* @param currentContinent Current selected continent * @param currentContinent Current selected continent
* @param reset Set to true to reset the selected value * @param reset Set to true to reset the selected value
*/ */
function hideTimezoneCities(cities, currentContinent, reset = false) { function hideTimezoneCities(cities, currentContinent) {
var first = true; var first = true;
if (reset == null) {
reset = false;
}
[].forEach.call(cities, function (option) { [].forEach.call(cities, function (option) {
if (option.getAttribute('data-continent') != currentContinent) { if (option.getAttribute('data-continent') != currentContinent) {
option.className = 'hidden'; option.className = 'hidden';

View file

@ -16,6 +16,9 @@
</div> </div>
<div class="pure-u-2-24"></div> <div class="pure-u-2-24"></div>
</div> </div>
<input type="hidden" name="token" value="{$token}" id="token" />
{loop="$plugins_footer.endofpage"} {loop="$plugins_footer.endofpage"}
{$value} {$value}
{/loop} {/loop}

View file

@ -6,6 +6,8 @@
<body> <body>
{include="page.header"} {include="page.header"}
{include="tag.sort"}
<div class="pure-g"> <div class="pure-g">
<div class="pure-u-lg-1-6 pure-u-1-24"></div> <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"> <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
@ -54,6 +56,8 @@ <h2 class="window-title">{'Tag cloud'|t} - {$countTags} {'tags'|t}</h2>
</div> </div>
</div> </div>
{include="tag.sort"}
{include="page.footer"} {include="page.footer"}
</body> </body>
</html> </html>

86
tpl/default/tag.list.html Normal file
View file

@ -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>

View file

@ -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>