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 ¤¤
<p>
<span class="tag"
data-name="¤tag¤">
<span class="tag">
<input type="hidden"
name="tag[]"
value="¤tag¤" />
¤tag¤
<a class="tagremove"
onClick="JavaScript:removeTag(event)">
@ -578,55 +580,41 @@
</td>
</tr>
¤¤ search_help ¤¤
<p>
Sökfunktionen matchar normalt på låntagares namn och användarnamn,
samt artiklars namn. Alla ord måste matcha för att generera en
träff. Flera sökningar kan kombineras mha nyckelordet 'or'.
¤¤ search_form ¤¤
<form onSubmit="JavaScript:doSearch(event)"
id="search"
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>
Ö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;
}
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 {
private $id = 0;
private $name = '';
@ -228,61 +242,31 @@ class Product {
}
public function matches($terms) {
foreach($terms as $fieldtype => $values) {
$testfield = null;
switch($fieldtype) {
case 'tag':
foreach($values as $value) {
if(!in_array($value, $this->tags)) {
return false;
}
}
break;
case 'status':
$loan = $this->get_active_loan();
foreach($values as $value) {
switch($value) {
case 'on_loan':
if(!$loan) {
return false;
}
break;
case 'no_loan':
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;
}
foreach($terms as $field => $values) {
$matchvalues = array();
if(property_exists($this, $field)) {
$matchvalues[] = $this->$field;
} else if(array_key_exists($field, $this->get_info())) {
$matchvalues[] = $this->get_info()[$field];
} else {
switch($field) {
case 'tag':
$matchvalues = $this->get_tags();
case 'status':
$matchvalues[] = $this->get_loan_status();
case 'fritext':
$matchvalues[] = $this->name;
$matchvalues[] = $this->serial;
$matchvalues[] = $this->invoice;
$matchvalues = array_merge($matchvalues,
$this->get_tags(),
array_values(
$this->get_info()));
}
}
if(!match($values, $matchvalues)) {
return false;
}
}
return true;
}
@ -427,6 +411,20 @@ class Product {
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() {
$find = prepare('select `id` from `loan`
where `returntime` is null and product=?');
@ -694,35 +692,27 @@ class User {
}
public function matches($terms) {
foreach($terms as $fieldtype => $values) {
switch($fieldtype) {
case 'words':
foreach($values as $value) {
if(strpos($this->name, $value) !== false) {
continue;
}
$name = strtolower($this->get_displayname());
if(strpos($name, $value) !== false) {
continue;
}
return false;
}
break;
default:
if(!property_exists($this, $fieldtype)) {
return false;
}
foreach($values as $value) {
if($this->$fieldtype != $value) {
return false;
}
}
break;
foreach($terms as $field => $values) {
$matchvalues = array();
if($field == 'name') {
$matchvalues[] = $this->name;
$matchvalues[] = $this->get_displayname();
} else if(property_exists($this, $field)) {
$matchvalues[] = $this->$field;
} else if($field == 'fritext') {
$matchvalues[] = $this->name;
$matchvalues[] = $this->get_displayname();
$matchvalues[] = $this->notes;
} else {
return false;
}
if(!match($values, $matchvalues)) {
return false;
}
}
return true;
}
public function get_displayname() {
global $ldap;
try {

@ -32,6 +32,28 @@ abstract class Responder {
public function __construct() {
$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 {
@ -46,7 +68,8 @@ abstract class Page extends Responder {
'products' => 'Artiklar',
'users' => 'Låntagare',
'inventory' => 'Inventera',
'history' => 'Historik');
'history' => 'Historik',
'search' => 'Sök');
private $template_parts = array();
public function __construct() {
@ -94,12 +117,17 @@ abstract class Page extends Responder {
private function build_menu() {
$menu = '';
foreach($this->menuitems as $page => $title) {
$align = 'left';
$active = '';
if($this->page == $page) {
$active = 'active';
}
if($page == 'search') {
$align = 'right';
}
$menu .= replace(array('title' => $title,
'page' => $page,
'align' => $align,
'active' => $active),
$this->template_parts['menuitem']);
}
@ -334,121 +362,152 @@ abstract class Page extends Responder {
}
class SearchPage extends Page {
private $querystr = '';
private $terms = array();
public function __construct() {
parent::__construct();
$this->subtitle = 'Sökresultat för ';
if(isset($_GET['q'])) {
$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;
}
}
$this->terms = $_GET;
unset($this->terms['q'], $this->terms['page']);
}
private function do_search() {
$out = array('users' => array(),
'products' => array());
if(!$this->querystr) {
$out = array();
if(!$this->terms) {
return $out;
}
$out['users'] = $this->search('user');
$out['products'] = $this->search('product');
$terms = $this->translate_keys($this->terms);
foreach(array('user', 'product') as $type) {
$result = $this->search($type, $terms);
if($result) {
$out[$type] = $result;
}
}
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);
$out = array();
foreach($items as $item) {
foreach($this->terms as $term) {
if($item->matches($term)) {
$out[] = $item;
}
if($item->matches($terms)) {
$out[] = $item;
}
}
return $out;
}
protected function render_body() {
$hits = $this->do_search();
$nohits = true;
if($hits['users']) {
print(replace(array('title' => 'Låntagare'),
$this->fragments['subtitle']));
print($this->build_user_table($hits['users']));
$nohits = false;
$terms = '';
foreach($this->terms as $key => $value) {
if(!is_array($value)) {
$terms .= replace(array('term' => ucfirst($key).": $value",
'key' => $key,
'value' => $value),
$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('title' => 'Artiklar'),
$this->fragments['subtitle']));
print($this->build_product_table($hits['products']));
$nohits = false;
print(replace(array('terms' => $terms),
$this->fragments['search_form']));
if($this->terms) {
$hits = $this->do_search();
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']);
}
$tags = '';
foreach($this->product->get_tags() as $tag) {
foreach($this->escape_tags($this->product->get_tags()) as $tag) {
$tags .= replace(array('tag' => ucfirst($tag)),
$this->fragments['tag']);
}
@ -962,8 +1021,8 @@ class Ajax extends Responder {
$name = $info['name'];
$serial = $info['serial'];
$invoice = $info['invoice'];
$tags = $this->extract_tags($info['tags']);
foreach(array('id', 'name', 'serial', 'invoice', 'tags') as $key) {
$tags = $this->unescape_tags($info['tag']);
foreach(array('id', 'name', 'serial', 'invoice', 'tag') as $key) {
unset($info[$key]);
}
if(!$name) {
@ -1062,7 +1121,7 @@ class Ajax extends Responder {
private function save_template() {
$info = $_POST;
$name = $info['template'];
$tags = $this->extract_tags($info['tags']);
$tags = $this->unescape_tags($info['tag']);
foreach(array('template',
'id',
'name',
@ -1137,21 +1196,6 @@ class Ajax extends Responder {
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 {

@ -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}) {
var out = []
var fields = form.querySelectorAll('input,textarea')
fixDuplicateInputNames(fields)
for(var i = 0; i < fields.length; i++) {
if(filter(fields[i])) {
out.push([fields[i].name, fields[i].value])
@ -166,9 +188,9 @@ function suggest(input, type) {
}
break
case 'tag':
var taglist = document.querySelectorAll('#tags > p')
var taglist = document.querySelectorAll('#tags .tag > input')
for(var i = 0; i < taglist.length; i++) {
var tag = taglist[i].firstElementChild.dataset.name
var tag = taglist[i].name
existing.push(tag.toLowerCase())
}
break
@ -191,8 +213,7 @@ function suggest(input, type) {
}
var next = document.createElement('option')
if(capitalize) {
next.value = suggestion.charAt(0).toUpperCase()
+ suggestion.slice(1)
next.value = ucfirst(suggestion)
} else {
next.value = suggestion
}
@ -220,7 +241,7 @@ function addField(event) {
{'type': 'error',
'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 temp = document.createElement('template')
fragment = replace(fragment, [
@ -245,9 +266,8 @@ function addField(event) {
getFragment('info_item', render)
}
function escapeTag(tag) {
return tag
.replace(/,/, '&#44;')
function escapeText(text) {
return text
.replace(/'/, '&#39;')
.replace(/"/, '&#34;')
}
@ -259,15 +279,15 @@ function addTag(event) {
event.preventDefault()
var tr = event.currentTarget.parentNode.parentNode
var field = tr.querySelector('.newtag')
var tagname = escapeTag(field.value)
var tagname = escapeText(field.value)
if(!tagname) {
return showResult({'type': 'error',
'message': 'Taggen måste ha ett namn.'})
}
tagname = tagname.charAt(0).toUpperCase() + tagname.slice(1)
var tagElements = tr.querySelectorAll('.tag')
tagname = ucfirst(tagname)
var tagElements = tr.querySelectorAll('.tag > input')
for(var i = 0; i < tagElements.length; i++) {
var oldtag = tagElements[i].dataset['name']
var oldtag = tagElements[i].name
if(tagname.toLowerCase() == oldtag.toLowerCase()) {
return showResult({'type': 'error',
'message': 'Det finns redan en sån tagg på artikeln.'})
@ -383,12 +403,6 @@ function productDataList(form) {
return true
}
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
}
@ -419,3 +433,48 @@ function discardProduct(event) {
}
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;
}
.tagremove {
.tagremove, .termremove {
background-color: #e17e33;
padding-left: 2px;
padding-right: 2px;
color: white;
}
.tagremove:hover {
.tagremove:hover, .termremove:hover {
cursor: pointer;
color: white;
}
.term {
background-color: #ebf0f5;
margin: 5px;
padding-left: 2px;
}

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