diff --git a/html/fragments.html b/html/fragments.html index a277137..714122f 100644 --- a/html/fragments.html +++ b/html/fragments.html @@ -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> diff --git a/include/db.php b/include/db.php index e29f3df..876fe95 100644 --- a/include/db.php +++ b/include/db.php @@ -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 { diff --git a/include/view.php b/include/view.php index b4b835c..355dc9f 100644 --- a/include/view.php +++ b/include/view.php @@ -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(''', + '"'), + strtolower($tag)); + } + return $tags; + } + + final protected function unescape_tags($tags) { + foreach($tags as $key => $tag) { + $tags[$key] = str_replace(array(''', + '"'), + 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(',', - ''', - '"'), - array(',', - "'", - '"'), - $tag); - } - return $tags; - } } class Result { diff --git a/script.js b/script.js index 9003b93..91a943f 100644 --- a/script.js +++ b/script.js @@ -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(/,/, ',') +function escapeText(text) { + return text .replace(/'/, ''') .replace(/"/, '"') } @@ -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) +} diff --git a/style.css b/style.css index 41d4423..4590750 100644 --- a/style.css +++ b/style.css @@ -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; +} diff --git a/template.css b/template.css index fe7e0f0..1b16325 100644 --- a/template.css +++ b/template.css @@ -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;