*/
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);
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;
{
// 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);
}
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
*
$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);
public static $PAGE_TAGCLOUD = 'tagcloud';
+ public static $PAGE_TAGLIST = 'taglist';
+
public static $PAGE_DAILY = 'daily';
public static $PAGE_FEED_ATOM = 'atom';
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
+ public static $GET_TOKEN = 'token';
+
/**
* Reproducing renderPage() if hell, to avoid regression.
*
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;
}
return self::$PAGE_SAVE_PLUGINSADMIN;
}
+ if (startsWith($query, 'do='. self::$GET_TOKEN)) {
+ return self::$GET_TOKEN;
+ }
+
return self::$PAGE_LINKLIST;
}
}
*/
function escape($input)
{
+ if (is_bool($input)) {
+ return $input;
+ }
+
if (is_array($input)) {
$out = array();
foreach($input as $key => $value) {
$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);
+ }
+}
// -------- Tag cloud
if ($targetPage == Router::$PAGE_TAGCLOUD)
{
- $tags= $LINKSDB->allTags();
+ $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
+ $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
+ $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
// We sort tags alphabetically, then choose a font size according to count.
// First, find max value.
$maxcount = max($maxcount, $value);
}
- // Sort tags alphabetically: case insensitive, support locale if available.
- uksort($tags, function($a, $b) {
- // Collator is part of PHP intl.
- if (class_exists('Collator')) {
- $c = new Collator(setlocale(LC_COLLATE, 0));
- if (!intl_is_failure(intl_get_error_code())) {
- return $c->compare($a, $b);
- }
- }
- return strcasecmp($a, $b);
- });
+ alphabetical_sort($tags, true, true);
$tagList = array();
foreach($tags as $key => $value) {
}
$data = array(
+ 'search_tags' => implode(' ', $filteringTags),
'tags' => $tagList,
);
$pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => isLoggedIn()));
$PAGE->assign($key, $value);
}
- $PAGE->renderPage('tagcloud');
+ $PAGE->renderPage('tag.cloud');
+ exit;
+ }
+
+ // -------- Tag cloud
+ if ($targetPage == Router::$PAGE_TAGLIST)
+ {
+ $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
+ $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
+ $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
+
+ if (! empty($_GET['sort']) && $_GET['sort'] === 'alpha') {
+ alphabetical_sort($tags, false, true);
+ }
+
+ $data = [
+ 'search_tags' => implode(' ', $filteringTags),
+ 'tags' => $tags,
+ ];
+ $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => isLoggedIn()]);
+
+ foreach ($data as $key => $value) {
+ $PAGE->assign($key, $value);
+ }
+
+ $PAGE->renderPage('tag.list');
exit;
}
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;
}
// -------- 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);
'link' => $link,
'link_is_new' => false,
'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
- 'tags' => $LINKSDB->allTags(),
+ 'tags' => $LINKSDB->linksCountPerTag(),
);
$pluginManager->executeHooks('render_editlink', $data);
'link_is_new' => $link_is_new,
'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
- 'tags' => $LINKSDB->allTags(),
+ 'tags' => $LINKSDB->linksCountPerTag(),
'default_private_links' => $conf->get('privacy.default_private_links', false),
);
$pluginManager->executeHooks('render_editlink', $data);
exit;
}
+ // Get a fresh token
+ if ($targetPage == Router::$GET_TOKEN) {
+ header('Content-Type:text/plain');
+ echo getToken($conf);
+ exit;
+ }
+
// -------- Otherwise, simply display search form and links:
showLinkList($PAGE, $LINKSDB, $conf, $pluginManager);
exit;
function buildLinkList($PAGE,$LINKSDB, $conf, $pluginManager)
{
// Used in templates
- $searchtags = !empty($_GET['searchtags']) ? escape(normalize_spaces($_GET['searchtags'])) : '';
+ if (isset($_GET['searchtags'])) {
+ if (! empty($_GET['searchtags'])) {
+ $searchtags = escape(normalize_spaces($_GET['searchtags']));
+ } else {
+ $searchtags = false;
+ }
+ } else {
+ $searchtags = '';
+ }
$searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : '';
// Smallhash filter
} else {
// Filter links according search parameters.
$visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
- $linksToDisplay = $LINKSDB->filterSearch($_GET, false, $visibility);
+ $request = [
+ 'searchtags' => $searchtags,
+ 'searchterm' => $searchterm,
+ ];
+ $linksToDisplay = $LINKSDB->filterSearch($request, false, $visibility);
}
// ---- Handle paging.
}
// Compute paging navigation
- $searchtagsUrl = empty($searchtags) ? '' : '&searchtags=' . urlencode($searchtags);
+ $searchtagsUrl = $searchtags === '' ? '' : '&searchtags=' . urlencode($searchtags);
$searchtermUrl = empty($searchterm) ? '' : '&searchterm=' . urlencode($searchterm);
$previous_page_url = '';
if ($i != count($keys)) {
'sTuff' => 2,
'ut' => 1,
),
- self::$publicLinkDB->allTags()
+ self::$publicLinkDB->linksCountPerTag()
);
$this->assertEquals(
'tag4' => 1,
'ut' => 1,
),
- self::$privateLinkDB->allTags()
+ self::$privateLinkDB->linksCountPerTag()
+ );
+ $this->assertEquals(
+ array(
+ 'web' => 4,
+ 'cartoon' => 2,
+ 'gnu' => 1,
+ 'dev' => 1,
+ 'samba' => 1,
+ 'media' => 1,
+ 'html' => 1,
+ 'w3c' => 1,
+ 'css' => 1,
+ 'Mercurial' => 1,
+ '.hidden' => 1,
+ 'hashtag' => 1,
+ ),
+ self::$privateLinkDB->linksCountPerTag(['web'])
+ );
+ $this->assertEquals(
+ array(
+ 'web' => 1,
+ 'html' => 1,
+ 'w3c' => 1,
+ 'css' => 1,
+ 'Mercurial' => 1,
+ ),
+ self::$privateLinkDB->linksCountPerTag(['web'], 'private')
);
}
public function testReorderLinksDesc()
{
self::$privateLinkDB->reorder('ASC');
- $linkIds = array(42, 4, 1, 0, 7, 6, 8, 41);
+ $linkIds = array(42, 4, 9, 1, 0, 7, 6, 8, 41);
$cpt = 0;
foreach (self::$privateLinkDB as $key => $value) {
$this->assertEquals($linkIds[$cpt++], $key);
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, ''))
public function testFilterDay()
{
$this->assertEquals(
- 3,
+ 4,
count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20121206'))
);
}
);
$this->assertEquals(
- 7,
+ ReferenceLinkDB::$NB_LINKS_TOTAL - 1,
count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, '-revolution'))
);
}
);
$this->assertEquals(
- 7,
+ ReferenceLinkDB::$NB_LINKS_TOTAL - 1,
count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free'))
);
}
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(
$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);
+ }
}
$this->assertEquals($this->refDB->countLinks(), count($data));
// Check order
- $order = [41, 8, 6, 7, 0, 1, 4, 42];
+ $order = [41, 8, 6, 7, 0, 1, 9, 4, 42];
$cpt = 0;
foreach ($data as $link) {
$this->assertEquals(self::NB_FIELDS_LINK, count($link));
$data = json_decode((string) $response->getBody(), true);
$this->assertEquals($this->refDB->countLinks(), count($data));
// Check order
- $order = [41, 8, 6, 7, 0, 1, 4, 42];
+ $order = [41, 8, 6, 7, 0, 1, 9, 4, 42];
$cpt = 0;
foreach ($data as $link) {
$this->assertEquals(self::NB_FIELDS_LINK, count($link));
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
- $this->assertEquals(8, $data['global_counter']);
+ $this->assertEquals(\ReferenceLinkDB::$NB_LINKS_TOTAL, $data['global_counter']);
$this->assertEquals(2, $data['private_counter']);
$this->assertEquals('Shaarli', $data['settings']['title']);
$this->assertEquals('?', $data['settings']['header_link']);
$this->assertEquals(200, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
- $this->assertEquals(8, $data['global_counter']);
+ $this->assertEquals(\ReferenceLinkDB::$NB_LINKS_TOTAL, $data['global_counter']);
$this->assertEquals(2, $data['private_counter']);
$this->assertEquals($title, $data['settings']['title']);
$this->assertEquals($headerLink, $data['settings']['header_link']);
*/
class ReferenceLinkDB
{
- public static $NB_LINKS_TOTAL = 8;
+ public static $NB_LINKS_TOTAL = 9;
private $_links = array();
private $_publicCount = 0;
'ut'
);
+ $this->addLink(
+ 9,
+ 'PSR-2: Coding Style Guide',
+ 'http://www.php-fig.org/psr/psr-2/',
+ 'This guide extends and expands on PSR-1, the basic coding standard.',
+ 0,
+ DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_152312'),
+ ''
+ );
+
$this->addLink(
8,
'Free as in Freedom 2.0 @website',
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;
<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}
<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"}
}
}
-#search, #search-linklist {
+#search, #search-linklist, #search-tagcloud {
text-align: center;
width: 100%;
}
}
#search button,
+#search-tagcloud button,
#search-linklist button {
background: transparent;
border: none;
#search-linklist button:hover {
color: #fff;
}
+#search-tagcloud button:hover {
+ color: #d0d0d0;
+}
#search-linklist {
padding: 5px 0;
}
}
+.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;
}
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{
.page-form a {
color: #1b926c;
font-weight: bold;
+ text-decoration: none;
}
.page-form p {
- padding: 0 10px;
+ padding: 5px 10px;
margin: 0;
}
}
#cloudtag, #cloudtag a {
- color: #000;
+ color: #252525;
text-decoration: none;
}
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
*/
.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;
+}
/**
* 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
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("/"));
* @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 {
{/if}
</div>
+<input type="hidden" name="token" value="{$token}">
+
<div id="search-linklist">
<div class="pure-g">
<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>
</div>
<div class="pure-u-2-24"></div>
</div>
+
+<input type="hidden" name="token" value="{$token}" id="token" />
+
{loop="$plugins_footer.endofpage"}
{$value}
{/loop}
</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">
<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}
<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}
</div>
</div>
+{include="tag.sort"}
+
{include="page.footer"}
</body>
</html>
--- /dev/null
+<!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>
+ <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>
+
--- /dev/null
+<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> ·
+ <a href="?do=taglist&sort=usage" title="cloud">{'Most used'|t}</a> ·
+ <a href="?do=taglist&sort=alpha" title="cloud">{'Alphabetical'|t}</a>
+ </div>
+</div>
\ No newline at end of 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)+
'&title='%20+%20encodeURIComponent(title)+
- '&description='%20+%20encodeURIComponent(document.getSelection())+
+ '&description='%20+%20encodeURIComponent(desc)+
'&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&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&post='+
+ '&description='%20+%20encodeURIComponent(desc)+
+ '&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>
-
{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}
<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}
</a><br><br>
<a class="smallbutton"
onclick="return alertBookmarklet();"
- href="?private=1&post="><b>✚Add Note</b></a>
+ href="javascript:(
+ function(){
+ window.open(
+ '{$pageabsaddr}?private=1&post='+
+ '&description='%20+%20encodeURIComponent(document.getSelection())+
+ '&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>
⇐ Drag this link to your bookmarks toolbar (or right-click it and choose Bookmark This Link....).<br>