@@ -, +, @@ - DEV atomic update: bug_27113-elasticsearch_autocomplete_input_search.perl - Upgrade to XXX done : Bug 27113 - Autocomplete input on main page with elasticsearch --- .../elasticsearch/field_config.yaml | 4 + .../elasticsearch/index_config.yaml | 14 + api/elasticsearch/intranet-autocomplete.pl | 105 +++++++ ...asticsearch_autocomplete_input_search.perl | 8 + installer/data/mysql/mandatory/sysprefs.sql | 17 +- .../intranet-autocomplete.js | 270 ++++++++++++++++++ .../intranet-autocomplete.css | 28 ++ .../prog/en/includes/doc-head-close.inc | 5 + .../prog/en/includes/js_includes.inc | 4 + .../modules/admin/preferences/searching.pref | 9 + .../prog/en/modules/catalogue/advsearch.tt | 10 +- .../intranet-tmpl/prog/js/staff-global.js | 6 + .../opac-elasticsearch/opac-autocomplete.css | 29 ++ .../bootstrap/en/includes/doc-head-close.inc | 5 + .../bootstrap/en/includes/opac-bottom.inc | 5 + .../bootstrap/en/modules/opac-advsearch.tt | 8 + .../opac-elasticsearch/opac-autocomplete.js | 265 +++++++++++++++++ opac/svc/elasticsearch/opac-autocomplete.pl | 108 +++++++ 18 files changed, 887 insertions(+), 13 deletions(-) create mode 100755 api/elasticsearch/intranet-autocomplete.pl create mode 100644 installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl create mode 100644 koha-tmpl/intranet-tmpl/js/intranet-elasticsearch/intranet-autocomplete.js create mode 100644 koha-tmpl/intranet-tmpl/prog/css/intranet-elasticsearch/intranet-autocomplete.css create mode 100644 koha-tmpl/opac-tmpl/bootstrap/css/opac-elasticsearch/opac-autocomplete.css create mode 100644 koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js create mode 100755 opac/svc/elasticsearch/opac-autocomplete.pl --- a/admin/searchengine/elasticsearch/field_config.yaml +++ a/admin/searchengine/elasticsearch/field_config.yaml @@ -47,6 +47,10 @@ search: type: text analyzer: analyzer_phrase search_analyzer: analyzer_phrase + autocomplete: + type: text + analyzer: autocomplete + search_analyzer: standard raw: type: keyword normalizer: nfkc_cf_normalizer --- a/admin/searchengine/elasticsearch/index_config.yaml +++ a/admin/searchengine/elasticsearch/index_config.yaml @@ -2,6 +2,14 @@ # Index configuration that defines how different analyzers work. index: analysis: + tokenizer: + autocomplete_tokenizer: + type: edge_ngram + min_gram: 1 + max_gram: 10 + token_chars: + - letter + - digit analyzer: # Phrase analyzer is used for phrases (exact phrase match) analyzer_phrase: @@ -10,6 +18,12 @@ index: - icu_folding char_filter: - punctuation + autocomplete: + type: custom + filter: + - icu_folding + - lowercase + tokenizer: autocomplete_tokenizer analyzer_standard: tokenizer: icu_tokenizer filter: --- a/api/elasticsearch/intranet-autocomplete.pl +++ a/api/elasticsearch/intranet-autocomplete.pl @@ -0,0 +1,105 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use CGI qw ( -utf8 ); +use v5.10; +use JSON; +use Koha::SearchEngine::Search; +use Switch; +use utf8; +use Text::Unaccent; +use CGI::Session; + +my $searcher = Koha::SearchEngine::Search->new({index => 'biblios'}); +my $cgi = CGI->new; +my $session = CGI::Session->new(); + +$session->param(-name=>'analyzer', -value=>"autocomplete"); +$session->param(-name=>'prefix', -value=>$cgi->param("prefix")); +$session->param(-name=>'q', -value=>$cgi->param("q")); +$session->param(-name=>'key', -value=>$cgi->param("key")); +$session->param(-name=>'token_counter', -value=>$cgi->param("token_counter")); +$session->expire('+1h'); + +#Chose GET in key parametre +given ($session->param("key")) { + when ("autocomplete") { + my @prefix = split /,/, $session->param("prefix"); + # how fields for autocomplete + my $length = scalar @prefix; + #search by many prefix fields + if ($length > 1){ + print $cgi->header("application/json"); + print to_json(GetAutocompleteAllIdx($session->param("q"), \@prefix, $session->param("analyzer"), $session->param("token_counter"), $searcher)); + } + #search by one prefix field + elsif ($length == 1) { + print $cgi->header("application/json"); + print to_json(GetAutocompleteOneIdx($session->param("q"), $prefix[0], $session->param("analyzer"), $session->param("token_counter"), $searcher)); + } + #no prefix 404 + else { + response404JSON(); + } + } + #no key 404 + default { + response404JSON(); + } +} + +sub response404JSON { + my $json = JSON->new->utf8; + my $header_type = "application/json"; + my $header_status = "404"; + my $output = $json->encode({ + "error" => "No data", + "description" => "Bad request", + }); + print $cgi->header( + -type => $header_type, + -charset => "utf-8", + -status => $header_status + ); + print $output; + print "\n"; +} + +sub GetAutocompleteOneIdx { + my ($cgi_q, $prefix, $analyzer, $token_counter, $searcher) = @_; + my (%query, $results, @source); + #prefix + analyzer + my $prefix_analyzer = $prefix . '.' . $analyzer; + # we can change this variables + my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, [""], [""]); + push(@source, $prefix); + $query{'_source'} = \@source; + $query{'query'}{'match'}{$prefix_analyzer}{'query'} = unac_string("UTF-8", $cgi_q); + $query{'query'}{'match'}{$prefix_analyzer}{'operator'} = 'and'; + #hightlight + $query{'highlight'}{'number_of_fragments'} = $nb_fragments; + $query{'highlight'}{'fragment_size'} = $size_fragment; + $query{'highlight'}{'pre_tags'} = $pre_tags; + $query{'highlight'}{'post_tags'} = $post_tags; + $query{'highlight'}{'fields'}{$prefix_analyzer} = {}; + $results = $searcher->search(\%query); + $results->{'val'} = $cgi_q; + $results->{'prefix'} = $prefix; + $results->{'token_counter'} = $token_counter; + return $results; +} + +sub GetAutocompleteAllIdx { + my ($cgi_q, $prefix, $analyzer, $token_counter, $searcher) = @_; + my %results; + my $idx = 0; + foreach my $pref ( @$prefix ) { + $results{$idx} = GetAutocompleteOneIdx($cgi_q, $pref, $analyzer, $token_counter, $searcher); + $idx++; + } + $results{'val'} = $cgi_q; + $results{'prefix'} = join( ',', @$prefix ); + $results{'token_counter'} = $token_counter; + return \%results; +} --- a/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl +++ a/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl @@ -0,0 +1,8 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + # you can use $dbh here like: + $dbh->do(q{INSERT IGNORE INTO systempreferences (variable,value,options,explanation,type) VALUES ('AutocompleteElasticSearch', '0', NULL, NULL, 'YesNo')}); + + # Always end with this (adjust the bug info) + NewVersion( $DBversion, 27113, "Autocomplete input on main page with elasticsearch"); +} --- a/installer/data/mysql/mandatory/sysprefs.sql +++ a/installer/data/mysql/mandatory/sysprefs.sql @@ -53,11 +53,11 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('AmazonCoverImages','0','','Display Cover Images in staff interface from Amazon Web Services','YesNo'), ('AmazonLocale','US','US|CA|DE|FR|IN|JP|UK','Use to set the Locale of your Amazon.com Web Services','Choice'), ('AnonSuggestions','0',NULL,'Set to enable Anonymous suggestions to AnonymousPatron borrowernumber','YesNo'), -('AnonymousPatron','0',NULL,'Set the identifier (borrowernumber) of the anonymous patron. Used for suggestion and checkout history privacy',''), +('AnonymousPatron','0',NULL,'Set the identifier (borrowernumber) of the anonymous patron. Used for Suggestion and reading history privacy',''), ('ArticleRequests', '0', NULL, 'Enables the article request feature', 'YesNo'), ('ArticleRequestsLinkControl', 'calc', 'always|calc', 'Control display of article request link on search results', 'Choice'), ('ArticleRequestsMandatoryFields', '', NULL, 'Comma delimited list of required fields for bibs where article requests rule = ''yes''', 'multiple'), -('ArticleRequestsMandatoryFieldsItemOnly', '', NULL, 'Comma delimited list of required fields for bibs where article requests rule = ''item_only''', 'multiple'), +('ArticleRequestsMandatoryFieldsItemsOnly', '', NULL, 'Comma delimited list of required fields for bibs where article requests rule = ''item_only''', 'multiple'), ('ArticleRequestsMandatoryFieldsRecordOnly', '', NULL, 'Comma delimited list of required fields for bibs where article requests rule = ''bib_only''', 'multiple'), ('AudioAlerts','0','','Enable circulation sounds during checkin and checkout in the staff interface. Not supported by all web browsers yet.','YesNo'), ('AuthDisplayHierarchy','0','','Display authority hierarchies','YesNo'), @@ -271,7 +271,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('IntranetmainUserblock','','70|10','Add a block of HTML that will display on the intranet home page','Textarea'), ('IntranetNav','','70|10','Use HTML tabs to add navigational links to the top-hand navigational bar in the staff interface','Textarea'), ('IntranetNumbersPreferPhrase','0',NULL,'Control the use of phr operator in callnumber and standard number staff interface searches','YesNo'), -('intranetreadinghistory','1','','If ON, Checkout history is enabled for all patrons','YesNo'), +('intranetreadinghistory','1','','If ON, Reading History is enabled for all patrons','YesNo'), ('IntranetReportsHomeHTML', '', NULL, 'Show the following HTML in a div on the bottom of the reports home page', 'Free'), ('IntranetSlipPrinterJS','','','Use this JavaScript for printing slips. Define at least function printThenClose(). For use e.g. with Firefox PlugIn jsPrintSetup, see http://jsprintsetup.mozdev.org/','Free'), ('intranetstylesheet','','50','Enter a complete URL to use an alternate layout stylesheet in Intranet','free'), @@ -285,7 +285,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('item-level_itypes','1','','If ON, enables Item-level Itemtype / Issuing Rules','YesNo'), ('itemBarcodeFallbackSearch','',NULL,'If set, uses scanned item barcodes as a catalogue search if not found as barcodes','YesNo'), ('itemBarcodeInputFilter','','whitespace|T-prefix|cuecat|libsuite8|EAN13','If set, allows specification of a item barcode input filter','Choice'), -('itemcallnumber','',NULL,'The MARC field/subfield that is used to calculate the itemcallnumber (Dewey would be 082ab or 092ab; LOC would be 050ab or 090ab) could be 852hi from an item record','free'), +('itemcallnumber','082ab',NULL,'The MARC field/subfield that is used to calculate the itemcallnumber (Dewey would be 082ab or 092ab; LOC would be 050ab or 090ab) could be 852hi from an item record','free'), ('ItemsDeniedRenewal','','','This syspref allows to define custom rules for denying renewal of specific items.','Textarea'), ('KohaAdminEmailAddress','root@localhost','','Define the email address where patron modification requests are sent','free'), ('KohaManualBaseURL','https://koha-community.org/manual/','','Where is the Koha manual/documentation located?','Free'), @@ -423,7 +423,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('OpacMaxItemsToDisplay','50','','Max items to display at the OPAC on a biblio detail','Integer'), ('OpacMetaDescription','','','This description will show in search engine results (160 characters).','Textarea'), ('OpacMoreSearches', '', NULL, 'Add additional elements to the OPAC more searches bar', 'Textarea'), -('OPACMySummaryHTML','','70|10','Enter the HTML that will appear in a column on the \'my summary\' and \'my checkout history\' tabs when a user is logged in to the OPAC. Enter {BIBLIONUMBER}, {TITLE}, {AUTHOR}, or {ISBN} in place of their respective variables in the HTML. Leave blank to disable.','Textarea'), +('OPACMySummaryHTML','','70|10','Enter the HTML that will appear in a column on the \'my summary\' and \'my reading history\' tabs when a user is logged in to the OPAC. Enter {BIBLIONUMBER}, {TITLE}, {AUTHOR}, or {ISBN} in place of their respective variables in the HTML. Leave blank to disable.','Textarea'), ('OPACMySummaryNote','','','Note to display on the patron summary page. This note only appears if the patron is connected.','Free'), ('OpacNav','Important links here.','70|10','Use HTML tags to add navigational links to the left-hand navigational bar in OPAC','Textarea'), ('OpacNavBottom','Important links here.','70|10','Use HTML tags to add navigational links to the left-hand navigational bar in OPAC','Textarea'), @@ -436,10 +436,10 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('OpacPasswordChange','1',NULL,'If ON, enables patron-initiated password change in OPAC (disable it when using LDAP auth)','YesNo'), ('OPACPatronDetails','1','','If OFF the patron details tab in the OPAC is disabled.','YesNo'), ('OPACpatronimages','0',NULL,'Enable patron images in the OPAC','YesNo'), -('OpacPrivacy','0',NULL,'if ON, allows patrons to define their privacy rules (checkout history)','YesNo'), +('OpacPrivacy','0',NULL,'if ON, allows patrons to define their privacy rules (reading history)','YesNo'), ('OpacPublic','1',NULL,'Turn on/off public OPAC','YesNo'), ('opacreadinghistory','1','','If ON, enables display of Patron Circulation History in OPAC','YesNo'), -('OpacRenewalAllowed','1',NULL,'If ON, users can renew their issues directly from their OPAC account','YesNo'), +('OpacRenewalAllowed','0',NULL,'If ON, users can renew their issues directly from their OPAC account','YesNo'), ('OpacRenewalBranch','checkoutbranch','itemhomebranch|patronhomebranch|checkoutbranch|none','Choose how the branch for an OPAC renewal is recorded in statistics','Choice'), ('OPACReportProblem', 0, NULL, 'Allow patrons to submit problem reports for OPAC pages to the library or Koha Administrator', 'YesNo'), ('OpacResetPassword','0','','Shows the ''Forgot your password?'' link in the OPAC','YesNo'), @@ -722,5 +722,6 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('XSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on intranet','Free'), ('XSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on intranet','Free'), ('z3950AuthorAuthFields','701,702,700',NULL,'Define the MARC biblio fields for Personal Name Authorities to fill biblio.author','free'), -('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo') +('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo'), +('AutocompleteElasticSearch','0',NULL,NULL,'YesNo') ; --- a/koha-tmpl/intranet-tmpl/js/intranet-elasticsearch/intranet-autocomplete.js +++ a/koha-tmpl/intranet-tmpl/js/intranet-elasticsearch/intranet-autocomplete.js @@ -0,0 +1,270 @@ +/* OPAC JS file AutocompleteElasticSearch */ +/* prefix for search ES */ +var es_prefix = { + 'au': 'author', + 'pb': 'publisher', + 'se': 'title-series', + 'su': 'subject', + 'ti': 'title-cover', + /* for all */ + '': ['title-cover', 'author', 'subject', 'title-series', 'publisher'], + 'kw': ['title-cover', 'author', 'subject', 'title-series', 'publisher'] +}; + +/* stop class for elements name=["q"] */ +var stop_class_input = ["form-field-value"]; + +var url_request = '/cgi-bin/koha/api/elasticsearch/intranet-autocomplete.pl?q='; +/* query for elasticsearch encode*/ +var query_url_encode = { + '\'':'%5C%27', /* \\' for decode */ + '+': '' +}; +/* query for elasticsearch decode*/ +var query_url_decode = { + '\'':"\\'", + '+': '' +}; +/* count of lines for autocomplete */ +var nb_autocomplete = 10; +/* key API */ +var key = 'autocomplete'; + +function AutocompleteInitIntranet(){ + /* vars for class position absolute autocomplete */ + var left = "0px"; + var right = "0px"; + var top = ""; + /* get all input name q for search */ + var input_q = document.getElementsByName("q"); + for (var nb = 0; nb < input_q.length; nb++){ + /* addEventListener for every 'input' */ + if (!stop_class_input.includes(input_q[nb].className)){ + autocomplete(input_q[nb], nb, left, right, top); + } + }; +}; + +function autocomplete(inp, nb, left, right) { + var select_idx = document.getElementsByName("idx"); + /* autocomplete off for input */ + inp.setAttribute("autocomplete", "off"); + /* get parent of input */ + var parent_inp = $(inp).parent(); + /* get element after input */ + var next_elem_inp = inp.nextElementSibling; + /* create new div with position relative for class .autocomplete with absolute */ + var div_relative = document.createElement('div'); + $(div_relative).addClass( "autocomplete" ); + div_relative.append(inp); + /* input doesn't have an elem after, add it to parent */ + if (next_elem_inp === null){ + parent_inp.append( div_relative ); + } else { // input has an elem after, add elem after it + next_elem_inp.before(div_relative); + }; + var currentFocus; + /*execute a function when someone writes in the text field:*/ + var token_counter = 0; + inp.addEventListener("input", function(e) { + var a, val = this.value; + /* var for async compare */ + var tmp_input = this.value.replace(/[+']/g, function(matched){ + return query_url_decode[matched]; + }); + token_counter++; + currentFocus = -1; + if (document.getElementsByClassName("autocomplete-items").length !== 0){ + a = document.getElementsByClassName("autocomplete-items")[0]; + } else { + /*create a DIV element that will contain the items (values):*/ + a = document.createElement("DIV"); + a.setAttribute("id", this.id + "autocomplete-list"); + a.setAttribute("class", "autocomplete-items"); + /*append the DIV element as a child of the autocomplete container:*/ + this.parentNode.appendChild(a); + /*append position absolute left/right:*/ + $(".autocomplete-items").css("left",left); + $(".autocomplete-items").css("right",right); + }; + /* get es_prefix key for builder */ + var chose_prefix = (select_idx == null || select_idx.length == 0) ? '' : GetValueIdx(select_idx, nb); + chose_prefix = chose_prefix.replace(/([^,])[,-]([^,].*)?$/, '$1'); + if (chose_prefix !== null){ + var prefix = es_prefix[chose_prefix].toString(); + val = val.replace(/[+']/g, function(matched){ + return query_url_encode[matched]; + }); + if (tmp_input == '' || tmp_input == null){ + closeAllLists(); + token_counter = 0; + } else { + $.ajax({ + type: 'GET', + url: url_request + val + '&key=' + key + '&prefix=' + prefix + '&token_counter=' + token_counter, + success: function (data) { + //console.log(data); + if (data.length !== 0){ + var myset; //Set for Autocomplete unique + /* autocomplete for all prefix */ + if (chose_prefix === 'kw' || chose_prefix === ''){ + myset = GetSetAutocompleteAllIdx(data, prefix, key); + } else { // autocomplete for one prefix + myset = GetSetAutocompleteOneIdx(data, prefix, key); + }; + /* append set to autocomplete */ + if ( tmp_input + prefix == data['val'] + data['prefix'] && token_counter === parseInt(data['token_counter'], 10)){ + a.innerHTML = ""; + for (let item of myset){ + a.appendChild(CreateDivItemAutocomplete(item, val)); + }; + }; + } else { + closeAllLists(this); + }; + }, + error: function (data) { + console.log(data); + }, + }); + } + + }; + }); + /* get value for tag with name idx */ + function GetValueIdx(elem, nb){ + switch (elem[0].tagName){ + case 'INPUT': + return elem[0].value; + case 'SELECT': + return select_idx[nb].options[select_idx[nb].selectedIndex].value; + default: + return null; + }; + }; + /* get autocomplete for only one prefix title/author/etc... */ + function GetSetAutocompleteOneIdx(data, prefix, key){ + let myset = new Set(); + let tmp_data = data['hits']['hits']; + for (let i = 0; i < tmp_data.length; i++) { + for (let j = 0; j < tmp_data[i]['highlight'][prefix + '.' + key].length; j++){ + /* div with data for autocomplete */ + let tmp = tmp_data[i]['highlight'][prefix + '.' + key][j]; + tmp = tmp.replace(/^\[/g, ''); + tmp = tmp.replace(/\]+$/g, ''); + myset.add(tmp.replace(/^[ &\/\\#,+)$~%.'":*?>{}!;]+|[ &\/\\#,+($~%.'":*?<{}!;]+$/g, '')); + if (myset.size >= nb_autocomplete) break; + }; + if (myset.size >= nb_autocomplete) break; + }; + return myset; + }; + /* get autocomplete for all prefix */ + function GetSetAutocompleteAllIdx(data, prefix, key){ + let myset = new Set(); + var pref = prefix.split(","); + for (k = 0; k < Object.keys(data).length; k++){ //Object.keys(data).length + if (data[k] != '' && data[k] != null){ + let tmp_data = data[k]['hits']['hits']; + for (i = 0; i < tmp_data.length; i++) { + for (j = 0; j < tmp_data[i]['highlight'][pref[k] + '.' + key].length; j++){ + /* div with data for autocomplete */ + let tmp = tmp_data[i]['highlight'][pref[k] + '.' + key][j] + myset.add(tmp.replace(/[ &#,+()$~%.'":*?<{}!/;]+$/g, '')); + if (myset.size >= nb_autocomplete) break; + }; + if (myset.size >= nb_autocomplete) break; + }; + if (myset.size >= nb_autocomplete) break; + } + } + return myset; + }; + + /*execute a function presses a key on the keyboard:*/ + inp.addEventListener("keydown", function(e) { + var x = document.getElementById(this.id + "autocomplete-list"); + if (x) x = x.getElementsByTagName("div"); + if (e.keyCode == 40) { //DOWN + /*If the arrow DOWN key is pressed, + increase the currentFocus variable:*/ + currentFocus++; + /*and and make the current item more visible:*/ + addActive(x); + } else if (e.keyCode == 38) { //up + /*If the arrow UP key is pressed, + decrease the currentFocus variable:*/ + currentFocus--; + /*and and make the current item more visible:*/ + addActive(x); + e.preventDefault(); + } else if (e.keyCode == 13) { + /*If the ENTER key is pressed, prevent the form from being submitted,*/ + //e.preventDefault(); + if (currentFocus > -1) { + /*and simulate a click on the "active" item:*/ + if (x) x[currentFocus].click(); + } + } + /* press Esc clear all autocomplete */ + else if (e.keyCode == 27) { + closeAllLists(); + } + /* press Esc clear all autocomplete */ + else if (e.keyCode == 8) { + closeAllLists(); + }; + }); + function addActive(x) { + /*a function to classify an item as "active":*/ + if (!x) return false; + /*start by removing the "active" class on all items:*/ + removeActive(x); + if (currentFocus >= x.length) currentFocus = 0; + if (currentFocus < 0) currentFocus = (x.length - 1); + /*add class "autocomplete-active":*/ + x[currentFocus].classList.add("autocomplete-active"); + inp.value = (x[currentFocus].textContent.replace(/<\/?[^>]+(>|$)/g, "")).trim(); + }; + function removeActive(x) { + /*a function to remove the "active" class from all autocomplete items:*/ + for (var i = 0; i < x.length; i++) { + x[i].classList.remove("autocomplete-active"); + }; + }; + + function closeAllLists(elem) { + /*close all autocomplete lists in the document with class autocomplete-items */ + var x = document.getElementsByClassName("autocomplete-items"); + for (var i = 0; i < x.length; i++) { + x[i].parentNode.removeChild(x[i]) + }; + }; + + /* div for one item autocomplete */ + function CreateDivItemAutocomplete (elem){ + var b = document.createElement("DIV"); + // add element "; + b.innerHTML += elem; + /*insert a input field that will hold the current array item's value:*/ + b.innerHTML += ""; + /*execute a function when someone clicks on the item value (DIV element):*/ + b.addEventListener("click", function(e) { + /* insert the value for the autocomplete text field: */ + inp.value = this.getElementsByTagName("input")[0].value; + /* normalyzer hightlight without tags */ + //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim(); + inp.value = e.target.innerText; + /* Submit form click mouse in div */ + this.closest("form").submit(); + }); + return b; + }; + + /*execute a function when someone clicks in the document:*/ + document.addEventListener("click", function (e) { + closeAllLists(e.target); + }); +}; + +AutocompleteInitIntranet(); --- a/koha-tmpl/intranet-tmpl/prog/css/intranet-elasticsearch/intranet-autocomplete.css +++ a/koha-tmpl/intranet-tmpl/prog/css/intranet-elasticsearch/intranet-autocomplete.css @@ -0,0 +1,28 @@ +.autocomplete { + /*the container must be positioned relative:*/ + position: relative; + display: inline-block; +} +.autocomplete-items { + position: absolute; + border: 1px solid #d4d4d4; + border-bottom: none; + border-top: none; + z-index: 99; + /*position the autocomplete items to be the same width as the container:*/ + } + .autocomplete-items div { + padding: 10px; + cursor: pointer; + background-color: #fff; + border-bottom: 1px solid #d4d4d4; + } + .autocomplete-items div:hover { + /*when hovering an item:*/ + background-color: #e9e9e9; + } + .autocomplete-active { + /*when navigating through the items using the arrow keys:*/ + background-color: #cedfb1 !important; + color: #ffffff; + } --- a/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc @@ -21,6 +21,11 @@ [% Asset.css("css/print.css", { media = "print" }) | $raw %] [% INCLUDE intranetstylesheet.inc %] [% IF ( bidi ) %][% Asset.css("css/right-to-left.css") | $raw %][% END %] + +[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] + [% SET Optylesheet = 'intranet-elasticsearch/intranet-autocomplete.css' %] + +[% END %] [% END %] + +[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] + [% Asset.js("js/intranet-elasticsearch/intranet-autocomplete.js") %] +[% END %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref @@ -283,3 +283,12 @@ Searching: - LIBRIS base URL - pref: LibrisURL - "Please only change this if you are sure it needs changing." + - + - pref: AutocompleteElasticSearch + type: boolean + default: no + choices: + yes: Show + no: "Don't show" + - looking terms based on a provided text by using an ElasticSearch. + - --- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt @@ -94,11 +94,7 @@
Search for [% FOREACH search_box IN search_boxes_loop %] - [% IF ( search_boxes_label ) %] -
- [% ELSE %] -
- [% END %] + [% IF ( search_boxes_label ) %]
[% ELSE %]
[% END %] [% IF ( expanded_options ) %] [% IF ( search_box.boolean ) %] "; + /*execute a function when someone clicks on the item value (DIV element):*/ + b.addEventListener("click", function(e) { + /* insert the value for the autocomplete text field: */ + inp.value = this.getElementsByTagName("input")[0].value; + /* normalyzer hightlight without tags */ + //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim(); + inp.value = e.target.innerText; + /* Submit form click mouse in div */ + this.closest("form").submit(); + }); + return b; + }; + + /*execute a function when someone clicks in the document:*/ + document.addEventListener("click", function (e) { + closeAllLists(e.target); + }); +}; + +AutocompleteInitOpac(); --- a/opac/svc/elasticsearch/opac-autocomplete.pl +++ a/opac/svc/elasticsearch/opac-autocomplete.pl @@ -0,0 +1,108 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use CGI qw ( -utf8 ); +use v5.10; +use JSON::XS; +use JSON; +use Koha::SearchEngine::Search; +use Switch; +use utf8; +use Text::Unaccent; +use CGI::Session; + +my $searcher = Koha::SearchEngine::Search->new({index => 'biblios'}); +my $cgi = CGI->new; +my $session = CGI::Session->new(); +#name analyzer +$session->param(-name=>'analyzer', -value=>"autocomplete"); +#GET request +$session->param(-name=>'prefix', -value=>$cgi->param("prefix")); +$session->param(-name=>'q', -value=>$cgi->param("q")); +$session->param(-name=>'key', -value=>$cgi->param("key")); +$session->param(-name=>'token_counter', -value=>$cgi->param("token_counter")); +$session->expire('+1h'); + +#Chose GET in key parametre +given ($session->param("key")) { + #GET Autocomplete + when ("autocomplete") { + my @prefix = split /,/, $session->param("prefix"); + # how fields for autocomplete + my $length = scalar @prefix; + #search by many prefix fields + if ($length > 1){ + print $cgi->header("application/json"); + print to_json(GetAutocompleteAllIdx($session->param("q"), \@prefix, $session->param("analyzer"), $session->param("token_counter"), $searcher)); + } + #search by one prefix field + elsif ($length == 1) { + print $cgi->header("application/json"); + print to_json(GetAutocompleteOneIdx($session->param("q"), $prefix[0], $session->param("analyzer"), $session->param("token_counter"), $searcher)); + } + #no prefix 404 + else { + response404JSON(); + } + } + #no key 404 + default { + response404JSON(); + } +} +#404 Error +sub response404JSON { + my $json = JSON->new->utf8; + my $header_type = "application/json"; + my $header_status = "404"; + my $output = $json->encode({ + "error" => "No data", + "description" => "Bad request", + }); + print $cgi->header( + -type => $header_type, + -charset => "utf-8", + -status => $header_status + ); + print $output; + print "\n"; +} + +sub GetAutocompleteOneIdx { + my ($cgi_q, $prefix, $analyzer, $token_counter, $searcher) = @_; + my (%query, $results, @source); + #prefix + analyzer + my $prefix_analyzer = $prefix . '.' . $analyzer; + # we can change this variables + my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, [""], [""]); + push(@source, $prefix); + $query{'_source'} = \@source; + $query{'query'}{'match'}{$prefix_analyzer}{'query'} = unac_string("UTF-8", $cgi_q); + $query{'query'}{'match'}{$prefix_analyzer}{'operator'} = 'and'; + #hightlight + $query{'highlight'}{'number_of_fragments'} = $nb_fragments; + $query{'highlight'}{'fragment_size'} = $size_fragment; + $query{'highlight'}{'pre_tags'} = $pre_tags; + $query{'highlight'}{'post_tags'} = $post_tags; + $query{'highlight'}{'fields'}{$prefix_analyzer} = {}; + $results = $searcher->search(\%query); + $results->{'val'} = $cgi_q; + $results->{'prefix'} = $prefix; + $results->{'token_counter'} = $token_counter; + return $results; +} + +sub GetAutocompleteAllIdx { + my ($cgi_q, $prefix, $analyzer, $token_counter, $searcher) = @_; + my %results; + my $idx = 0; + foreach my $pref ( @$prefix ) { + $results{$idx} = GetAutocompleteOneIdx($cgi_q, $pref, $analyzer, $token_counter, $searcher); + $idx++; + } + $results{'val'} = $cgi_q; + $results{'prefix'} = join( ',', @$prefix ); + $results{'token_counter'} = $token_counter; + return \%results; +} --