Completed implementation of search v2. It is not posible to find discarded products via search at this time.

This commit is contained in:
Erik Thuning 2019-06-04 17:29:55 +02:00
parent f6b8258a14
commit eb1987d83a
6 changed files with 352 additions and 271 deletions

@ -261,8 +261,10 @@
¤¤ tag ¤¤ ¤¤ tag ¤¤
<p> <p>
<span class="tag" <span class="tag">
data-name="¤tag¤"> <input type="hidden"
name="tag[]"
value="¤tag¤" />
¤tag¤ ¤tag¤
<a class="tagremove" <a class="tagremove"
onClick="JavaScript:removeTag(event)"> onClick="JavaScript:removeTag(event)">
@ -578,55 +580,41 @@
</td> </td>
</tr> </tr>
¤¤ search_help ¤¤ ¤¤ search_form ¤¤
<p>
Sökfunktionen matchar normalt på låntagares namn och användarnamn, <form onSubmit="JavaScript:doSearch(event)"
samt artiklars namn. Alla ord måste matcha för att generera en id="search"
träff. Flera sökningar kan kombineras mha nyckelordet 'or'. class="dark">
<p>
<input type="hidden"
name="page"
value="search" />
<input type="text"
onKeyPress="JavaScript:searchInput(event)"
name="q"
placeholder="Vad letar du efter?"
value="" />
<button type="submit">
Sök
</button>
</p>
<div id="terms">
¤terms¤
</div>
<div class="clear"></div>
</form>
¤¤ search_term ¤¤
<p class="left">
<span class="term">
<input type="hidden"
name="¤key¤"
value="¤value¤" />
¤term¤
<a class="termremove"
onClick="JavaScript:removeTerm(event)">
x
</a>
</span>
</p> </p>
<p>
Övriga fält är sökbara med syntaxen [fält]:[värde]. Saknas [värde]
så returneras alla träffar som överhuvud taget har fältet.
</p>
<p>
Alla sökningar matchar på delar av ord, utom taggar som bara matchas exakt.
</p>
<p>
De fält som alltid finns är:
<ul>
<li>
<strong>Artikel:</strong> id, namn, serienummer, fakturanummer, status<br/>
Giltiga värden för status är: inne, ute, utlånad, sen, försenad
</li>
<li>
<strong>Låntagare:</strong> id, namn, användarnamn, anteckningar
</li>
</ul>
Artiklar kan ha fler fält beroende på vad som lagts till.
</p>
<h3>Lite exempel:</h3>
<ul>
<li>
<strong>"mac"</strong>
- sök efter artiklar och låntagare vars namn eller användarnamn
innehåller strängen 'mac'
</li>
<li>
<strong>"tag:trasig"</strong>
- sök efter artiklar med taggen "trasig"<br/>
(Det är bara artiklar som kan ha taggar)
</li>
<li>
<strong>"anteckning:"</strong>
- sök efter artiklar som har ett fält vid namn "anteckning"<br/>
(Anteckningsfältet för låntagare heter 'anteckningar', så bara
artiklar kommer hittas)
</li>
<li>
<strong>"mac or tag:trasig"</strong>
- sök efter artiklar och låntagare som matchar "mac", samt
artiklar som har taggen "trasig"<br/>
(Bara artiklar kan ha taggar, men eftersom 'mac' är fritext så
kan den matcha både artiklar och låntagare)
</li>
</ul>

@ -120,6 +120,20 @@ function suggest($type) {
return $out; return $out;
} }
function match($testvalues, $matchvalues) {
if(!is_array($testvalues)) {
$testvalues = array($testvalues);
}
foreach($testvalues as $value) {
foreach($matchvalues as $candidate) {
if(fnmatch($value, $candidate, FNM_CASEFOLD)) {
return true;
}
}
}
return false;
}
class Product { class Product {
private $id = 0; private $id = 0;
private $name = ''; private $name = '';
@ -228,61 +242,31 @@ class Product {
} }
public function matches($terms) { public function matches($terms) {
foreach($terms as $fieldtype => $values) { foreach($terms as $field => $values) {
$testfield = null; $matchvalues = array();
switch($fieldtype) { if(property_exists($this, $field)) {
case 'tag': $matchvalues[] = $this->$field;
foreach($values as $value) { } else if(array_key_exists($field, $this->get_info())) {
if(!in_array($value, $this->tags)) { $matchvalues[] = $this->get_info()[$field];
return false; } else {
} switch($field) {
} case 'tag':
break; $matchvalues = $this->get_tags();
case 'status': case 'status':
$loan = $this->get_active_loan(); $matchvalues[] = $this->get_loan_status();
foreach($values as $value) { case 'fritext':
switch($value) { $matchvalues[] = $this->name;
case 'on_loan': $matchvalues[] = $this->serial;
if(!$loan) { $matchvalues[] = $this->invoice;
return false; $matchvalues = array_merge($matchvalues,
} $this->get_tags(),
break; array_values(
case 'no_loan': $this->get_info()));
if($loan) {
return false;
}
break;
case 'overdue':
if(!$loan || !$loan->is_overdue()) {
return false;
}
break;
default:
return false;
}
}
break;
case 'words':
$testfield = $this->name;
break;
default:
if(property_exists($this, $fieldtype)) {
$testfield = $this->$fieldtype;
} elseif(array_key_exists($fieldtype, $this->info)) {
$tesfield = $this->info[$fieldtype];
} else {
return false;
}
break;
}
if($testfield !== null) {
foreach($values as $value) {
$test = strtolower($testfield);
if(strpos($test, $value) === false) {
return false;
}
} }
} }
if(!match($values, $matchvalues)) {
return false;
}
} }
return true; return true;
} }
@ -427,6 +411,20 @@ class Product {
return true; return true;
} }
public function get_loan_status() {
if($this->get_discardtime(false)) {
return 'discarded';
}
$loan = $this->get_active_loan();
if(!$loan) {
return 'no_loan';
}
if($loan->is_overdue()) {
return 'overdue';
}
return 'on_loan';
}
public function get_active_loan() { public function get_active_loan() {
$find = prepare('select `id` from `loan` $find = prepare('select `id` from `loan`
where `returntime` is null and product=?'); where `returntime` is null and product=?');
@ -694,35 +692,27 @@ class User {
} }
public function matches($terms) { public function matches($terms) {
foreach($terms as $fieldtype => $values) { foreach($terms as $field => $values) {
switch($fieldtype) { $matchvalues = array();
case 'words': if($field == 'name') {
foreach($values as $value) { $matchvalues[] = $this->name;
if(strpos($this->name, $value) !== false) { $matchvalues[] = $this->get_displayname();
continue; } else if(property_exists($this, $field)) {
} $matchvalues[] = $this->$field;
$name = strtolower($this->get_displayname()); } else if($field == 'fritext') {
if(strpos($name, $value) !== false) { $matchvalues[] = $this->name;
continue; $matchvalues[] = $this->get_displayname();
} $matchvalues[] = $this->notes;
return false; } else {
} return false;
break; }
default: if(!match($values, $matchvalues)) {
if(!property_exists($this, $fieldtype)) { return false;
return false;
}
foreach($values as $value) {
if($this->$fieldtype != $value) {
return false;
}
}
break;
} }
} }
return true; return true;
} }
public function get_displayname() { public function get_displayname() {
global $ldap; global $ldap;
try { try {

@ -32,6 +32,28 @@ abstract class Responder {
public function __construct() { public function __construct() {
$this->fragments = get_fragments('./html/fragments.html'); $this->fragments = get_fragments('./html/fragments.html');
} }
final protected function escape_tags($tags) {
foreach($tags as $key => $tag) {
$tags[$key] = str_replace(array("'",
'"'),
array('&#39;',
'&#34;'),
strtolower($tag));
}
return $tags;
}
final protected function unescape_tags($tags) {
foreach($tags as $key => $tag) {
$tags[$key] = str_replace(array('&#39;',
'&#34;'),
array("'",
'"'),
strtolower($tag));
}
return $tags;
}
} }
abstract class Page extends Responder { abstract class Page extends Responder {
@ -46,7 +68,8 @@ abstract class Page extends Responder {
'products' => 'Artiklar', 'products' => 'Artiklar',
'users' => 'Låntagare', 'users' => 'Låntagare',
'inventory' => 'Inventera', 'inventory' => 'Inventera',
'history' => 'Historik'); 'history' => 'Historik',
'search' => 'Sök');
private $template_parts = array(); private $template_parts = array();
public function __construct() { public function __construct() {
@ -94,12 +117,17 @@ abstract class Page extends Responder {
private function build_menu() { private function build_menu() {
$menu = ''; $menu = '';
foreach($this->menuitems as $page => $title) { foreach($this->menuitems as $page => $title) {
$align = 'left';
$active = ''; $active = '';
if($this->page == $page) { if($this->page == $page) {
$active = 'active'; $active = 'active';
} }
if($page == 'search') {
$align = 'right';
}
$menu .= replace(array('title' => $title, $menu .= replace(array('title' => $title,
'page' => $page, 'page' => $page,
'align' => $align,
'active' => $active), 'active' => $active),
$this->template_parts['menuitem']); $this->template_parts['menuitem']);
} }
@ -334,121 +362,152 @@ abstract class Page extends Responder {
} }
class SearchPage extends Page { class SearchPage extends Page {
private $querystr = '';
private $terms = array(); private $terms = array();
public function __construct() { public function __construct() {
parent::__construct(); parent::__construct();
$this->subtitle = 'Sökresultat för '; $this->terms = $_GET;
if(isset($_GET['q'])) { unset($this->terms['q'], $this->terms['page']);
$this->querystr = $_GET['q'];
$this->subtitle .= "'$this->querystr'";
$orterms = preg_split('/[[:space:]]+or[[:space:]]+/',
strtolower($this->querystr),
-1,
PREG_SPLIT_NO_EMPTY);
foreach($orterms as $orterm) {
$searchpart = array();
$terms = preg_split('/[[:space:]]+/',
$orterm,
-1,
PREG_SPLIT_NO_EMPTY);
foreach($terms as $term) {
$key = '';
$value = '';
if(strpos($term, ':') !== false) {
$pair = explode(':', $term);
$key = $pair[0];
switch($key) {
case 'namn':
$key = 'name';
break;
case 'fakturanummer':
$key = 'invoice';
break;
case 'serienummer':
$key = 'serial';
break;
case 'anteckningar':
$key = 'notes';
break;
}
$value = $pair[1];
if($key == 'status') {
switch($value) {
case 'inne':
$value = 'no_loan';
break;
case 'ute':
case 'utlånad':
$value = 'on_loan';
break;
case 'sen':
case 'försenad':
case 'försenat':
$value = 'overdue';
break;
}
}
} else {
$key = 'words';
$value = $term;
}
if(!isset($searchpart[$key])) {
$searchpart[$key] = array();
}
$searchpart[$key][] = $value;
}
$this->terms[] = $searchpart;
}
}
} }
private function do_search() { private function do_search() {
$out = array('users' => array(), $out = array();
'products' => array()); if(!$this->terms) {
if(!$this->querystr) {
return $out; return $out;
} }
$out['users'] = $this->search('user'); $terms = $this->translate_keys($this->terms);
$out['products'] = $this->search('product'); foreach(array('user', 'product') as $type) {
$result = $this->search($type, $terms);
if($result) {
$out[$type] = $result;
}
}
return $out; return $out;
} }
private function search($type) { private function translate_keys($terms) {
$translated = array();
foreach($terms as $key => $value) {
$newkey = $key;
switch($key) {
case 'namn':
$newkey = 'name';
break;
case 'faktura':
case 'fakturanummer':
$newkey = 'invoice';
break;
case 'serienummer':
$newkey = 'serial';
break;
case 'tagg':
$newkey = 'tag';
break;
case 'status':
$value = $this->translate_values($value);
break;
}
if(!array_key_exists($newkey, $translated)) {
$translated[$newkey] = $value;
} else {
$temp = $translated[$newkey];
$translated[$newkey] = array_merge((array)$temp, (array)$value);
}
}
return $translated;
}
private function translate_values($value) {
if(!is_array($value)) {
$value = array($value);
}
$translated = array();
foreach($value as $item) {
$newitem = $item;
switch($item) {
case 'ute':
case 'utlånad':
case 'utlånat':
case 'lånad':
case 'lånat':
$newitem = 'on_loan';
break;
case 'inne':
case 'ledig':
case 'ledigt':
case 'tillgänglig':
case 'tillgängligt':
$newitem = 'no_loan';
break;
case 'sen':
case 'sent':
case 'försenad':
case 'försenat':
case 'överdraget':
$newitem = 'overdue';
break;
case 'skrotad':
case 'skrotat':
case 'slängd':
case 'slängt':
$newitem = 'discarded';
break;
}
$translated[] = $newitem;
}
return $translated;
}
private function search($type, $terms) {
$items = get_items($type); $items = get_items($type);
$out = array(); $out = array();
foreach($items as $item) { foreach($items as $item) {
foreach($this->terms as $term) { if($item->matches($terms)) {
if($item->matches($term)) { $out[] = $item;
$out[] = $item;
}
} }
} }
return $out; return $out;
} }
protected function render_body() { protected function render_body() {
$hits = $this->do_search(); $terms = '';
$nohits = true; foreach($this->terms as $key => $value) {
if($hits['users']) { if(!is_array($value)) {
print(replace(array('title' => 'Låntagare'), $terms .= replace(array('term' => ucfirst($key).": $value",
$this->fragments['subtitle'])); 'key' => $key,
print($this->build_user_table($hits['users'])); 'value' => $value),
$nohits = false; $this->fragments['search_term']);
} else {
foreach($value as $item) {
$terms .= replace(array('term' => ucfirst($key).": $item",
'key' => $key,
'value' => $item),
$this->fragments['search_term']);
}
}
} }
if($hits['products']) { print(replace(array('terms' => $terms),
print(replace(array('title' => 'Artiklar'), $this->fragments['search_form']));
$this->fragments['subtitle'])); if($this->terms) {
print($this->build_product_table($hits['products'])); $hits = $this->do_search();
$nohits = false; print(replace(array('title' => 'Sökresultat'),
$this->fragments['title']));
$result = '';
if(isset($hits['user'])) {
$result = replace(array('title' => 'Låntagare'),
$this->fragments['subtitle']);
$result .= $this->build_user_table($hits['user']);
}
if(isset($hits['product'])) {
$result .= replace(array('title' => 'Artiklar'),
$this->fragments['subtitle']);
$result .= $this->build_product_table($hits['product']);
}
if(!$result) {
$result = 'Inga träffar.';
}
print($result);
} }
if($nohits) {
print('Inga träffar.');
}
print(replace(array('title' => 'Hjälp'),
$this->fragments['subtitle']));
print($this->fragments['search_help']);
} }
} }
@ -522,7 +581,7 @@ class ProductPage extends Page {
$this->fragments['info_item']); $this->fragments['info_item']);
} }
$tags = ''; $tags = '';
foreach($this->product->get_tags() as $tag) { foreach($this->escape_tags($this->product->get_tags()) as $tag) {
$tags .= replace(array('tag' => ucfirst($tag)), $tags .= replace(array('tag' => ucfirst($tag)),
$this->fragments['tag']); $this->fragments['tag']);
} }
@ -962,8 +1021,8 @@ class Ajax extends Responder {
$name = $info['name']; $name = $info['name'];
$serial = $info['serial']; $serial = $info['serial'];
$invoice = $info['invoice']; $invoice = $info['invoice'];
$tags = $this->extract_tags($info['tags']); $tags = $this->unescape_tags($info['tag']);
foreach(array('id', 'name', 'serial', 'invoice', 'tags') as $key) { foreach(array('id', 'name', 'serial', 'invoice', 'tag') as $key) {
unset($info[$key]); unset($info[$key]);
} }
if(!$name) { if(!$name) {
@ -1062,7 +1121,7 @@ class Ajax extends Responder {
private function save_template() { private function save_template() {
$info = $_POST; $info = $_POST;
$name = $info['template']; $name = $info['template'];
$tags = $this->extract_tags($info['tags']); $tags = $this->unescape_tags($info['tag']);
foreach(array('template', foreach(array('template',
'id', 'id',
'name', 'name',
@ -1137,21 +1196,6 @@ class Ajax extends Responder {
return new Failure('Artikeln är redan skrotad.'); return new Failure('Artikeln är redan skrotad.');
} }
} }
private function extract_tags($string) {
$tags = explode(',', strtolower($string));
# Unescape specials
foreach($tags as $key => $tag) {
$tags[$key] = str_replace(array('&#44;',
'&#39;',
'&#34;'),
array(',',
"'",
'"'),
$tag);
}
return $tags;
}
} }
class Result { class Result {

@ -61,9 +61,31 @@ function reloadOrError(result) {
} }
} }
function ucfirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase()
}
function fixDuplicateInputNames(fields) {
var names = {}
for(var i = 0; i < fields.length; i++) {
var name = fields[i].name
if(name.endsWith('[]')) {
continue
}
if(names.hasOwnProperty(name)) {
fields[i].name = name + '[]'
fields[names[name]].name = name + '[]'
} else {
names[name] = i
}
}
return true
}
function dataListFromForm(form, filter = function(field) {return true}) { function dataListFromForm(form, filter = function(field) {return true}) {
var out = [] var out = []
var fields = form.querySelectorAll('input,textarea') var fields = form.querySelectorAll('input,textarea')
fixDuplicateInputNames(fields)
for(var i = 0; i < fields.length; i++) { for(var i = 0; i < fields.length; i++) {
if(filter(fields[i])) { if(filter(fields[i])) {
out.push([fields[i].name, fields[i].value]) out.push([fields[i].name, fields[i].value])
@ -166,9 +188,9 @@ function suggest(input, type) {
} }
break break
case 'tag': case 'tag':
var taglist = document.querySelectorAll('#tags > p') var taglist = document.querySelectorAll('#tags .tag > input')
for(var i = 0; i < taglist.length; i++) { for(var i = 0; i < taglist.length; i++) {
var tag = taglist[i].firstElementChild.dataset.name var tag = taglist[i].name
existing.push(tag.toLowerCase()) existing.push(tag.toLowerCase())
} }
break break
@ -191,8 +213,7 @@ function suggest(input, type) {
} }
var next = document.createElement('option') var next = document.createElement('option')
if(capitalize) { if(capitalize) {
next.value = suggestion.charAt(0).toUpperCase() next.value = ucfirst(suggestion)
+ suggestion.slice(1)
} else { } else {
next.value = suggestion next.value = suggestion
} }
@ -220,7 +241,7 @@ function addField(event) {
{'type': 'error', {'type': 'error',
'message': 'Det finns redan ett fält med det namnet.'}) 'message': 'Det finns redan ett fält med det namnet.'})
} }
var name = key.charAt(0).toUpperCase() + key.slice(1) var name = ucfirst(key)
var render = function(fragment) { var render = function(fragment) {
var temp = document.createElement('template') var temp = document.createElement('template')
fragment = replace(fragment, [ fragment = replace(fragment, [
@ -245,9 +266,8 @@ function addField(event) {
getFragment('info_item', render) getFragment('info_item', render)
} }
function escapeTag(tag) { function escapeText(text) {
return tag return text
.replace(/,/, '&#44;')
.replace(/'/, '&#39;') .replace(/'/, '&#39;')
.replace(/"/, '&#34;') .replace(/"/, '&#34;')
} }
@ -259,15 +279,15 @@ function addTag(event) {
event.preventDefault() event.preventDefault()
var tr = event.currentTarget.parentNode.parentNode var tr = event.currentTarget.parentNode.parentNode
var field = tr.querySelector('.newtag') var field = tr.querySelector('.newtag')
var tagname = escapeTag(field.value) var tagname = escapeText(field.value)
if(!tagname) { if(!tagname) {
return showResult({'type': 'error', return showResult({'type': 'error',
'message': 'Taggen måste ha ett namn.'}) 'message': 'Taggen måste ha ett namn.'})
} }
tagname = tagname.charAt(0).toUpperCase() + tagname.slice(1) tagname = ucfirst(tagname)
var tagElements = tr.querySelectorAll('.tag') var tagElements = tr.querySelectorAll('.tag > input')
for(var i = 0; i < tagElements.length; i++) { for(var i = 0; i < tagElements.length; i++) {
var oldtag = tagElements[i].dataset['name'] var oldtag = tagElements[i].name
if(tagname.toLowerCase() == oldtag.toLowerCase()) { if(tagname.toLowerCase() == oldtag.toLowerCase()) {
return showResult({'type': 'error', return showResult({'type': 'error',
'message': 'Det finns redan en sån tagg på artikeln.'}) 'message': 'Det finns redan en sån tagg på artikeln.'})
@ -383,12 +403,6 @@ function productDataList(form) {
return true return true
} }
var datalist = dataListFromForm(form, filter) var datalist = dataListFromForm(form, filter)
var tagElements = form.querySelectorAll('.tag')
var tags = []
for(var i = 0; i < tagElements.length; i++) {
tags.push(escapeTag(tagElements[i].dataset['name']))
}
datalist.push(['tags', tags])
return datalist return datalist
} }
@ -419,3 +433,48 @@ function discardProduct(event) {
} }
ajaxRequest('discardproduct', dataListFromForm(form), render) ajaxRequest('discardproduct', dataListFromForm(form), render)
} }
function searchInput(event) {
if(event.key != "Enter") {
return
}
var input = event.target
var term = input.value.toLowerCase()
if(term === '') {
return
}
event.preventDefault()
var terms = document.querySelector('#terms')
var parts = escapeText(term).trim().split(':')
var parsedTerm = 'Fritext: ' + parts[0]
var key = 'fritext'
var value = parts[0]
if(parts.length > 1) {
key = parts[0].trim()
value = parts.slice(1).join(':').trim()
parsedTerm = ucfirst(key) + ': ' + value
}
var render = function(fragment) {
var temp = document.createElement('template')
fragment = replace(fragment, [['term', parsedTerm],
['key', key],
['value', value]])
temp.innerHTML = fragment
terms.append(temp.content.firstChild)
input.value = ''
}
getFragment('search_term', render)
}
function doSearch(event) {
var form = document.querySelector('#search')
var fields = form.querySelectorAll('input,textarea')
fixDuplicateInputNames(fields)
}
function removeTerm(event) {
event.preventDefault()
var term = event.currentTarget.parentNode
var parent = term.parentNode
parent.remove(term)
}

@ -118,14 +118,20 @@ input[type="text"].newtemplate {
padding-left: 2px; padding-left: 2px;
} }
.tagremove { .tagremove, .termremove {
background-color: #e17e33; background-color: #e17e33;
padding-left: 2px; padding-left: 2px;
padding-right: 2px; padding-right: 2px;
color: white; color: white;
} }
.tagremove:hover { .tagremove:hover, .termremove:hover {
cursor: pointer; cursor: pointer;
color: white; color: white;
} }
.term {
background-color: #ebf0f5;
margin: 5px;
padding-left: 2px;
}

@ -220,28 +220,22 @@ button:disabled, input[type="submit"]:disabled {
background-color: #002E5F; background-color: #002E5F;
margin: 0px; margin: 0px;
font-size: 130%; font-size: 130%;
position: relative;
} }
#menu .item { #menu .item {
padding-left: 12px; padding: 8px 12px;
padding-right: 12px;
margin-left:6px; margin-left:6px;
margin-right: 6px; margin-right: 6px;
text-decoration: none; text-decoration: none;
color: #005B7F; color: #005B7F;
height: 100%; height: 100%;
padding-top: 8px;
} }
#menu .active { #menu .active {
background-color: #FFF; background-color: #FFF;
} }
#menu #search {
padding-top: 7px;
padding-right: 0px;
}
#contents { #contents {
font-family: Georgia, "Times New Roman", Times, serif; font-family: Georgia, "Times New Roman", Times, serif;
margin-left: auto; margin-left: auto;