Allow crossed search between terms and tags

* Partial fix of #449
  * Current use case: search term + click on tag.
  * LinkFilter now returns all links if no filter is given.
  * Unit tests.
This commit is contained in:
ArthurHoaro 2016-02-23 19:21:14 +01:00
parent 6c3d6a31f4
commit c51fae92dc
6 changed files with 178 additions and 65 deletions

View file

@ -353,8 +353,7 @@ public function getLinkFromUrl($url)
public function filter($type = '', $request = '', $casesensitive = false, $privateonly = false) public function filter($type = '', $request = '', $casesensitive = false, $privateonly = false)
{ {
$linkFilter = new LinkFilter($this->_links); $linkFilter = new LinkFilter($this->_links);
$requestFilter = is_array($request) ? implode(' ', $request) : $request; return $linkFilter->filter($type, $request, $casesensitive, $privateonly);
return $linkFilter->filter($type, trim($requestFilter), $casesensitive, $privateonly);
} }
/** /**

View file

@ -55,16 +55,25 @@ public function filter($type, $request, $casesensitive = false, $privateonly = f
switch($type) { switch($type) {
case self::$FILTER_HASH: case self::$FILTER_HASH:
return $this->filterSmallHash($request); return $this->filterSmallHash($request);
break; case self::$FILTER_TAG | self::$FILTER_TEXT:
if (!empty($request)) {
$filtered = $this->links;
if (isset($request[0])) {
$filtered = $this->filterTags($request[0], $casesensitive, $privateonly);
}
if (isset($request[1])) {
$lf = new LinkFilter($filtered);
$filtered = $lf->filterFulltext($request[1], $privateonly);
}
return $filtered;
}
return $this->noFilter($privateonly);
case self::$FILTER_TEXT: case self::$FILTER_TEXT:
return $this->filterFulltext($request, $privateonly); return $this->filterFulltext($request, $privateonly);
break;
case self::$FILTER_TAG: case self::$FILTER_TAG:
return $this->filterTags($request, $casesensitive, $privateonly); return $this->filterTags($request, $casesensitive, $privateonly);
break;
case self::$FILTER_DAY: case self::$FILTER_DAY:
return $this->filterDay($request); return $this->filterDay($request);
break;
default: default:
return $this->noFilter($privateonly); return $this->noFilter($privateonly);
} }
@ -138,6 +147,10 @@ private function filterSmallHash($smallHash)
*/ */
private function filterFulltext($searchterms, $privateonly = false) private function filterFulltext($searchterms, $privateonly = false)
{ {
if (empty($searchterms)) {
return $this->links;
}
$filtered = array(); $filtered = array();
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
$exactRegex = '/"([^"]+)"/'; $exactRegex = '/"([^"]+)"/';
@ -219,6 +232,12 @@ private function filterFulltext($searchterms, $privateonly = false)
*/ */
public function filterTags($tags, $casesensitive = false, $privateonly = false) public function filterTags($tags, $casesensitive = false, $privateonly = false)
{ {
// Implode if array for clean up.
$tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
if (empty($tags)) {
return $this->links;
}
$searchtags = self::tagsStrToArray($tags, $casesensitive); $searchtags = self::tagsStrToArray($tags, $casesensitive);
$filtered = array(); $filtered = array();
if (empty($searchtags)) { if (empty($searchtags)) {

View file

@ -33,6 +33,10 @@ h1 {
margin-bottom: 20px; margin-bottom: 20px;
} }
em {
font-style: italic;
}
/* Buttons */ /* Buttons */
.bigbutton { .bigbutton {
background-color: #c0c0c0; background-color: #c0c0c0;

120
index.php
View file

@ -623,7 +623,7 @@ private function initialize()
if (!empty($_GET['searchtags'])) { if (!empty($_GET['searchtags'])) {
$searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']); $searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']);
} }
elseif (!empty($_GET['searchterm'])) { if (!empty($_GET['searchterm'])) {
$searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']); $searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']);
} }
$this->tpl->assign('searchcrits', $searchcrits); $this->tpl->assign('searchcrits', $searchcrits);
@ -709,11 +709,19 @@ function showRSS()
// Read links from database (and filter private links if user it not logged in). // Read links from database (and filter private links if user it not logged in).
// Optionally filter the results: // Optionally filter the results:
if (!empty($_GET['searchterm'])) { $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : '';
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']); $searchterm = !empty($_GET['searchterm']) ? escape($_GET['searchterm']) : '';
if (! empty($searchtags) && ! empty($searchterm)) {
$linksToDisplay = $LINKSDB->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array($searchtags, $searchterm)
);
} }
elseif (!empty($_GET['searchtags'])) { elseif ($searchtags) {
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags'])); $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, $searchtags);
}
elseif ($searchterm) {
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $searchterm);
} }
else { else {
$linksToDisplay = $LINKSDB; $linksToDisplay = $LINKSDB;
@ -807,11 +815,19 @@ function showATOM()
); );
// Optionally filter the results: // Optionally filter the results:
if (!empty($_GET['searchterm'])) { $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : '';
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']); $searchterm = !empty($_GET['searchterm']) ? escape($_GET['searchterm']) : '';
if (! empty($searchtags) && ! empty($searchterm)) {
$linksToDisplay = $LINKSDB->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array($searchtags, $searchterm)
);
} }
else if (!empty($_GET['searchtags'])) { elseif ($searchtags) {
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags'])); $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, $searchtags);
}
elseif ($searchterm) {
$linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $searchterm);
} }
else { else {
$linksToDisplay = $LINKSDB; $linksToDisplay = $LINKSDB;
@ -1165,11 +1181,19 @@ function renderPage()
if ($targetPage == Router::$PAGE_PICWALL) if ($targetPage == Router::$PAGE_PICWALL)
{ {
// Optionally filter the results: // Optionally filter the results:
if (!empty($_GET['searchterm'])) { $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : '';
$links = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']); $searchterm = !empty($_GET['searchterm']) ? escape($_GET['searchterm']) : '';
if (! empty($searchtags) && ! empty($searchterm)) {
$links = $LINKSDB->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array($searchtags, $searchterm)
);
} }
elseif (! empty($_GET['searchtags'])) { elseif ($searchtags) {
$links = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags'])); $links = $LINKSDB->filter(LinkFilter::$FILTER_TAG, $searchtags);
}
elseif ($searchterm) {
$links = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $searchterm);
} }
else { else {
$links = $LINKSDB; $links = $LINKSDB;
@ -1963,29 +1987,46 @@ function importFile()
// This function fills all the necessary fields in the $PAGE for the template 'linklist.html' // This function fills all the necessary fields in the $PAGE for the template 'linklist.html'
function buildLinkList($PAGE,$LINKSDB) function buildLinkList($PAGE,$LINKSDB)
{ {
// ---- Filter link database according to parameters // Filter link database according to parameters.
$search_type = ''; $searchtags = !empty($_GET['searchtags']) ? escape($_GET['searchtags']) : '';
$search_crits = ''; $searchterm = !empty($_GET['searchterm']) ? escape(trim($_GET['searchterm'])) : '';
$privateonly = !empty($_SESSION['privateonly']) ? true : false; $privateonly = !empty($_SESSION['privateonly']) ? true : false;
// Fulltext search // Search tags + fullsearch.
if (isset($_GET['searchterm'])) { if (! empty($searchtags) && ! empty($searchterm)) {
$search_crits = escape(trim($_GET['searchterm'])); $linksToDisplay = $LINKSDB->filter(
$search_type = LinkFilter::$FILTER_TEXT; LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
$linksToDisplay = $LINKSDB->filter($search_type, $search_crits, false, $privateonly); array($searchtags, $searchterm),
false,
$privateonly
);
} }
// Search by tag // Search by tags.
elseif (isset($_GET['searchtags'])) { elseif (! empty($searchtags)) {
$search_crits = explode(' ', escape(trim($_GET['searchtags']))); $linksToDisplay = $LINKSDB->filter(
$search_type = LinkFilter::$FILTER_TAG; LinkFilter::$FILTER_TAG,
$linksToDisplay = $LINKSDB->filter($search_type, $search_crits, false, $privateonly); $searchtags,
false,
$privateonly
);
}
// Fulltext search.
elseif (! empty($searchterm)) {
$linksToDisplay = $LINKSDB->filter(
LinkFilter::$FILTER_TEXT,
$searchterm,
false,
$privateonly
);
} }
// Detect smallHashes in URL. // Detect smallHashes in URL.
elseif (isset($_SERVER['QUERY_STRING']) elseif (! empty($_SERVER['QUERY_STRING'])
&& preg_match('/[a-zA-Z0-9-_@]{6}(&.+?)?/', $_SERVER['QUERY_STRING'])) { && preg_match('/[a-zA-Z0-9-_@]{6}(&.+?)?/', $_SERVER['QUERY_STRING'])
$search_type = LinkFilter::$FILTER_HASH; ) {
$search_crits = substr(trim($_SERVER["QUERY_STRING"], '/'), 0, 6); $linksToDisplay = $LINKSDB->filter(
$linksToDisplay = $LINKSDB->filter($search_type, $search_crits); LinkFilter::$FILTER_HASH,
substr(trim($_SERVER["QUERY_STRING"], '/'), 0, 6)
);
if (count($linksToDisplay) == 0) { if (count($linksToDisplay) == 0) {
$PAGE->render404('The link you are trying to reach does not exist or has been deleted.'); $PAGE->render404('The link you are trying to reach does not exist or has been deleted.');
@ -2041,21 +2082,18 @@ function buildLinkList($PAGE,$LINKSDB)
} }
// Compute paging navigation // Compute paging navigation
$searchterm = empty($_GET['searchterm']) ? '' : '&searchterm=' . $_GET['searchterm']; $searchtagsUrl = empty($searchtags) ? '' : '&searchtags=' . urlencode($searchtags);
$searchtags = empty($_GET['searchtags']) ? '' : '&searchtags=' . $_GET['searchtags']; $searchtermUrl = empty($searchterm) ? '' : '&searchterm=' . urlencode($searchterm);
$previous_page_url = ''; $previous_page_url = '';
if ($i != count($keys)) { if ($i != count($keys)) {
$previous_page_url = '?page=' . ($page+1) . $searchterm . $searchtags; $previous_page_url = '?page=' . ($page+1) . $searchtermUrl . $searchtagsUrl;
} }
$next_page_url=''; $next_page_url='';
if ($page>1) { if ($page>1) {
$next_page_url = '?page=' . ($page-1) . $searchterm . $searchtags; $next_page_url = '?page=' . ($page-1) . $searchtermUrl . $searchtagsUrl;
} }
$token = ''; $token = isLoggedIn() ? getToken() : '';
if (isLoggedIn()) {
$token = getToken();
}
// Fill all template fields. // Fill all template fields.
$data = array( $data = array(
@ -2065,8 +2103,8 @@ function buildLinkList($PAGE,$LINKSDB)
'page_current' => $page, 'page_current' => $page,
'page_max' => $pagecount, 'page_max' => $pagecount,
'result_count' => count($linksToDisplay), 'result_count' => count($linksToDisplay),
'search_type' => $search_type, 'search_term' => $searchterm,
'search_crits' => $search_crits, 'search_tags' => $searchtags,
'redirector' => empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector'], // Optional redirector URL. 'redirector' => empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector'], // Optional redirector URL.
'token' => $token, 'token' => $token,
'links' => $linkDisp, 'links' => $linkDisp,

View file

@ -12,6 +12,8 @@ class LinkFilterTest extends PHPUnit_Framework_TestCase
*/ */
protected static $linkFilter; protected static $linkFilter;
protected static $NB_LINKS_REFDB = 7;
/** /**
* Instanciate linkFilter with ReferenceLinkDB data. * Instanciate linkFilter with ReferenceLinkDB data.
*/ */
@ -27,7 +29,7 @@ public static function setUpBeforeClass()
public function testFilter() public function testFilter()
{ {
$this->assertEquals( $this->assertEquals(
7, self::$NB_LINKS_REFDB,
count(self::$linkFilter->filter('', '')) count(self::$linkFilter->filter('', ''))
); );
@ -36,6 +38,16 @@ public function testFilter()
2, 2,
count(self::$linkFilter->filter('', '', false, true)) count(self::$linkFilter->filter('', '', false, true))
); );
$this->assertEquals(
self::$NB_LINKS_REFDB,
count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, ''))
);
$this->assertEquals(
self::$NB_LINKS_REFDB,
count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, ''))
);
} }
/** /**
@ -341,4 +353,41 @@ public function testTagFilterWithExclusion()
count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free')) count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, '-free'))
); );
} }
/**
* Test crossed search (terms + tags).
*/
public function testFilterCrossedSearch()
{
$terms = '"Free Software " stallman "read this" @website stuff';
$tags = 'free';
$this->assertEquals(
1,
count(self::$linkFilter->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array($tags, $terms)
))
);
$this->assertEquals(
2,
count(self::$linkFilter->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array('', $terms)
))
);
$this->assertEquals(
1,
count(self::$linkFilter->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
array($tags, '')
))
);
$this->assertEquals(
self::$NB_LINKS_REFDB,
count(self::$linkFilter->filter(
LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT,
''
))
);
}
} }

View file

@ -11,16 +11,16 @@
<div id="headerform" class="search"> <div id="headerform" class="search">
<form method="GET" class="searchform" name="searchform"> <form method="GET" class="searchform" name="searchform">
<input type="text" tabindex="1" id="searchform_value" name="searchterm" placeholder="Search text" <input type="text" tabindex="1" id="searchform_value" name="searchterm" placeholder="Search text"
{if="!empty($search_crits) && $search_type=='fulltext'"} {if="!empty($search_term)"}
value="{$search_crits}" value="{$search_term}"
{/if} {/if}
> >
<input type="submit" value="Search" class="bigbutton"> <input type="submit" value="Search" class="bigbutton">
</form> </form>
<form method="GET" class="tagfilter" name="tagfilter"> <form method="GET" class="tagfilter" name="tagfilter">
<input type="text" tabindex="2" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" <input type="text" tabindex="2" name="searchtags" id="tagfilter_value" placeholder="Filter by tag"
{if="!empty($search_crits) && $search_type=='tags'"} {if="!empty($search_tags)"}
value="{function="implode(' ', $search_crits)"}" value="{$search_tags}"
{/if} {/if}
autocomplete="off" class="awesomplete" data-multiple data-minChars="1" autocomplete="off" class="awesomplete" data-multiple data-minChars="1"
data-list="{loop="$tags"}{$key}, {/loop}" data-list="{loop="$tags"}{$key}, {/loop}"
@ -44,19 +44,23 @@
</div> </div>
{if="count($links)==0"} {if="count($links)==0"}
<div id="searchcriteria">Nothing found.</i></div> <div id="searchcriteria">Nothing found.</div>
{else} {elseif="!empty($search_term) or !empty($search_tags)"}
{if="$search_type=='fulltext'"} <div id="searchcriteria">
<div id="searchcriteria">{$result_count} results for <i>{$search_crits}</i></div> {$result_count} results
{if="!empty($search_term)"}
for <em>{$search_term}</em>
{/if} {/if}
{if="$search_type=='tags'"} {if="!empty($search_tags)"}
<div id="searchcriteria">{$result_count} results for tags <i> {$exploded_tags=explode(' ', $search_tags)}
{loop="search_crits"} tagged
{loop="$exploded_tags"}
<span class="linktag" title="Remove tag"> <span class="linktag" title="Remove tag">
<a href="?removetag={function="urlencode($value)"}">{$value} <span class="remove">x</span></a> <a href="?removetag={function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
</span> </span>
{/loop}</i></div> {/loop}
{/if} {/if}
</div>
{/if} {/if}
<ul> <ul>
{loop="links"} {loop="links"}