Bugzilla – Attachment 116293 Details for
Bug 27113
Elasticsearch: Autocomplete in search
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 27113: ElasticSearch: Autocomplete in input search
Bug-27113-ElasticSearch-Autocomplete-in-input-sear.patch (text/plain), 43.41 KB, created by
Ivan Dziuba
on 2021-02-03 19:42:35 UTC
(
hide
)
Description:
Bug 27113: ElasticSearch: Autocomplete in input search
Filename:
MIME Type:
Creator:
Ivan Dziuba
Created:
2021-02-03 19:42:35 UTC
Size:
43.41 KB
patch
obsolete
>From 8230f8badb56fe6f1208288fcef0dfb923f77d45 Mon Sep 17 00:00:00 2001 >From: Ivan Dziuba <ivan.dziuba@inlibro.com> >Date: Thu, 3 Dec 2020 16:24:48 -0500 >Subject: [PATCH] Bug 27113: ElasticSearch: Autocomplete in input search > >Bug 27113: ElasticSearch: Autocomplete in input search. Fix bugs. >--- > Koha/SearchEngine/Elasticsearch/Browse.pm | 64 +++++ > .../elasticsearch/field_config.yaml | 4 + > .../elasticsearch/index_config.yaml | 14 + > api/elasticsearch/autocomplete.pl | 69 +++++ > ...asticsearch_autocomplete_input_search.perl | 8 + > installer/data/mysql/mandatory/sysprefs.sql | 3 +- > .../js/elasticsearch/autocomplete.js | 270 ++++++++++++++++++ > .../prog/css/elasticsearch/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 | 4 + > .../bootstrap/en/modules/opac-advsearch.tt | 8 + > .../opac-elasticsearch/opac-autocomplete.js | 265 +++++++++++++++++ > opac/svc/elasticsearch/opac-autocomplete.pl | 69 +++++ > 19 files changed, 868 insertions(+), 6 deletions(-) > create mode 100755 api/elasticsearch/autocomplete.pl > create mode 100644 installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl > create mode 100644 koha-tmpl/intranet-tmpl/js/elasticsearch/autocomplete.js > create mode 100644 koha-tmpl/intranet-tmpl/prog/css/elasticsearch/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 > >diff --git a/Koha/SearchEngine/Elasticsearch/Browse.pm b/Koha/SearchEngine/Elasticsearch/Browse.pm >index 85070730a7..57c0a74023 100644 >--- a/Koha/SearchEngine/Elasticsearch/Browse.pm >+++ b/Koha/SearchEngine/Elasticsearch/Browse.pm >@@ -162,6 +162,70 @@ sub _build_query { > return $query; > } > >+sub autocomplete_one_idx { >+ my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_; >+ my @source; >+ my $elasticsearch = $self->get_elasticsearch(); >+ # we can change these variables >+ my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, ["<strong>"], ["</strong>"]); >+ >+ my $query = $self->_build_query_autocomplete($cgi_q, $prefix, $analyzer); >+ my $res = $elasticsearch->search( >+ index => $self->index_name, >+ body => $query >+ ); >+ $res->{'val'} = $cgi_q; >+ $res->{'prefix'} = $prefix; >+ $res->{'token_counter'} = $token_counter; >+ >+ return $res; >+} >+ >+sub autocomplete_idx { >+ my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_; >+ my %results; >+ my $idx = 0; >+ foreach my $pref ( @$prefix ) { >+ $results{$idx} = $self->autocomplete_one_idx($cgi_q, $pref, $analyzer, $token_counter); >+ $idx++; >+ } >+ $results{'val'} = $cgi_q; >+ $results{'prefix'} = join( ',', @$prefix ); >+ $results{'token_counter'} = $token_counter; >+ return \%results; >+} >+ >+sub _build_query_autocomplete { >+ my ($self, $cgi_q, $prefix, $analyzer) = @_; >+ my (@source); >+ #prefix + analyzer >+ my $prefix_analyzer = $prefix . '.' . $analyzer; >+ # we can change these variables >+ my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, ["<strong>"], ["</strong>"]); >+ push(@source, $prefix); >+ my $query = { >+ _source => \@source, >+ query => { >+ match => { >+ $prefix_analyzer => { >+ query => $cgi_q, >+ operator => 'and' >+ } >+ } >+ }, >+ highlight => { >+ number_of_fragments => $nb_fragments, >+ fragment_size => $size_fragment, >+ pre_tags => $pre_tags, >+ post_tags => $post_tags, >+ fields => { >+ $prefix_analyzer => {} >+ } >+ } >+ }; >+ return $query; >+} >+ > 1; > > __END__ >diff --git a/admin/searchengine/elasticsearch/field_config.yaml b/admin/searchengine/elasticsearch/field_config.yaml >index d78bdf06dd..cae35c9585 100644 >--- a/admin/searchengine/elasticsearch/field_config.yaml >+++ b/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 >diff --git a/admin/searchengine/elasticsearch/index_config.yaml b/admin/searchengine/elasticsearch/index_config.yaml >index b8b2ac6071..ab1e5a631b 100644 >--- a/admin/searchengine/elasticsearch/index_config.yaml >+++ b/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: >diff --git a/api/elasticsearch/autocomplete.pl b/api/elasticsearch/autocomplete.pl >new file mode 100755 >index 0000000000..babcbb7335 >--- /dev/null >+++ b/api/elasticsearch/autocomplete.pl >@@ -0,0 +1,69 @@ >+#!/usr/bin/perl >+ >+use strict; >+use warnings; >+use CGI qw ( -utf8 ); >+use v5.10; >+use JSON; >+use Switch; >+use utf8; >+use Text::Unaccent; >+use CGI::Session; >+use Koha::SearchEngine::Elasticsearch::Browse; >+ >+my $browser = Koha::SearchEngine::Elasticsearch::Browse->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"); >+ #fields for autocomplete >+ my $length = scalar @prefix; >+ #search by many prefix fields >+ if ($length > 1){ >+ my $res = $browser->autocomplete_idx(unac_string("UTF-8", $session->param("q")), \@prefix, $session->param("analyzer"), $session->param("token_counter")); >+ print $cgi->header("application/json"); >+ print to_json($res); >+ } >+ #search by one prefix field >+ elsif ($length == 1) { >+ my $res = $browser->autocomplete_one_idx(unac_string("UTF-8", $session->param("q")), $prefix[0], $session->param("analyzer"), $session->param("token_counter")); >+ print $cgi->header("application/json"); >+ print to_json($res); >+ } >+ #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"; >+} >diff --git a/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl b/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl >new file mode 100644 >index 0000000000..cdfc4bf876 >--- /dev/null >+++ b/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 with elasticsearch"); >+} >diff --git a/installer/data/mysql/mandatory/sysprefs.sql b/installer/data/mysql/mandatory/sysprefs.sql >index 83f33fef55..0141ca99fe 100644 >--- a/installer/data/mysql/mandatory/sysprefs.sql >+++ b/installer/data/mysql/mandatory/sysprefs.sql >@@ -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') > ; >diff --git a/koha-tmpl/intranet-tmpl/js/elasticsearch/autocomplete.js b/koha-tmpl/intranet-tmpl/js/elasticsearch/autocomplete.js >new file mode 100644 >index 0000000000..2c021a53e3 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/js/elasticsearch/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/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 += "<input type='hidden' value=''>"; >+ /*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 = this.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(); >diff --git a/koha-tmpl/intranet-tmpl/prog/css/elasticsearch/autocomplete.css b/koha-tmpl/intranet-tmpl/prog/css/elasticsearch/autocomplete.css >new file mode 100644 >index 0000000000..40dfefb0d6 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/css/elasticsearch/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; >+ } >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc >index c2833e3009..6999bc90ab 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc >+++ b/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 %] >+<!-- Intranet inc CSS AutocompleteElasticSearch --> >+[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ [% SET Optylesheet = 'elasticsearch/autocomplete.css' %] >+ <link rel="stylesheet" type="text/css" href="[% interface | url %]/[% theme | url %]/css/[% Optylesheet | url %]" /> >+[% END %] > > <script> > var Koha = {}; >diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc >index b832eec518..34ff501c6f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc >+++ b/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc >@@ -144,4 +144,8 @@ > }); > </script> > [% END %] >+<!-- Intranet inc JS AutocompleteElasticSearch --> >+[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ [% Asset.js("js/elasticsearch/autocomplete.js") %] >+[% END %] > <!-- / js_includes.inc --> >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref >index 71bcbfaa1c..37c96e3503 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref >+++ b/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. >+ - >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt >index 553ef59917..7680e26d3c 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt >@@ -94,11 +94,7 @@ > <fieldset id="searchterms"> > <legend>Search for </legend> > [% FOREACH search_box IN search_boxes_loop %] >- [% IF ( search_boxes_label ) %] >- <div class="search-term-row" style="text-indent: 4.5em;"> >- [% ELSE %] >- <div class="search-term-row"> >- [% END %] >+ [% IF ( search_boxes_label ) %]<div style="text-indent: 4.5em;">[% ELSE %]<div>[% END %] > [% IF ( expanded_options ) %] > [% IF ( search_box.boolean ) %] > <select name="op"> >@@ -325,6 +321,10 @@ > var dad = line.parentNode; > dad.appendChild(line.cloneNode(true)); > line.removeChild(ButtonPlus); >+ /* Intranet JS AutocompleteElasticSearch */ >+ [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ AutocompleteInitIntranet(); >+ [% END %] > } > var Sticky; > $(document).ready(function() { >diff --git a/koha-tmpl/intranet-tmpl/prog/js/staff-global.js b/koha-tmpl/intranet-tmpl/prog/js/staff-global.js >index 4f06af9465..1dc42595d5 100644 >--- a/koha-tmpl/intranet-tmpl/prog/js/staff-global.js >+++ b/koha-tmpl/intranet-tmpl/prog/js/staff-global.js >@@ -76,6 +76,12 @@ $.fn.selectTabByID = function (tabID) { > $(".keep_text").on("click",function(){ > var field_index = $(this).parent().index(); > keep_text( field_index ); >+ /* AutocompleteElasticSearch Tab */ >+ var tab = this.hash.substr(1, this.hash.length-1); >+ /* Koha.Preference('AutocompleteElasticSearch') == Show */ >+ if (typeof AutocompleteInitIntranet !== "undefined" && tab === 'catalog_search' ){ >+ AutocompleteInitIntranet(); >+ } > }); > > $(".toggle_element").on("click",function(e){ >diff --git a/koha-tmpl/opac-tmpl/bootstrap/css/opac-elasticsearch/opac-autocomplete.css b/koha-tmpl/opac-tmpl/bootstrap/css/opac-elasticsearch/opac-autocomplete.css >new file mode 100644 >index 0000000000..c717f35754 >--- /dev/null >+++ b/koha-tmpl/opac-tmpl/bootstrap/css/opac-elasticsearch/opac-autocomplete.css >@@ -0,0 +1,29 @@ >+/* CSS file AutocompleteElasticSearch */ >+.autocomplete { >+ /*the container must be positioned relative:*/ >+ position: relative; >+ display: inline-block; >+ width: 100%; >+} >+.autocomplete-items { >+ position: absolute; >+ border: 1px solid #d4d4d4; >+ border-bottom: none; >+ border-top: none; >+ z-index: 99; >+ } >+ .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; >+ } >\ No newline at end of file >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/doc-head-close.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/doc-head-close.inc >index e0963ca5d3..dda8ab6825 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/doc-head-close.inc >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/doc-head-close.inc >@@ -23,6 +23,11 @@ > [% SET opaclayoutstylesheet = 'opac.css' %] > [% END %] > [% END %] >+<!-- OPAC inc CSS AutocompleteElasticSearch --> >+[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ [% SET Optylesheet = 'opac-elasticsearch/opac-autocomplete.css' %] >+ <link rel="stylesheet" type="text/css" href="[% interface | url %]/[% theme | url %]/css/[% Optylesheet | url %]" /> >+[% END %] > [% IF (opaclayoutstylesheet.match('^https?:|^\/')) %] > <link rel="stylesheet" type="text/css" href="[% opaclayoutstylesheet | url %]" /> > [% ELSE %] >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc >index c103f0ec4c..e37b31582c 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc >@@ -318,6 +318,10 @@ $(document).ready(function() { > }); > </script> > [% PROCESS jsinclude %] >+<!-- OPAC *.inc JS AutocompleteElasticSearch --> >+[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ [% Asset.js("js/opac-elasticsearch/opac-autocomplete.js") %] >+[% END %] > [% IF ( Koha.Preference('OPACUserJS') ) %] > <script> > [% Koha.Preference('OPACUserJS') | $raw %] >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt >index ada84161e9..155c42ef9e 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt >@@ -526,6 +526,10 @@ $(document).ready(function() { > var newLine = thisLine.clone(); > newLine.find('input').val(''); > thisLine.after(newLine); >+ /* OPAC JS AutocompleteElasticSearch */ >+ [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ AutocompleteInitOpac(); >+ [% END %] > }); > > $(document).on("click", '.ButtonLess', function(e) { >@@ -534,6 +538,10 @@ $(document).ready(function() { > $('.ButtonLess').hide(); > } > $(this).parent().parent().remove(); >+ /* OPAC JS AutocompleteElasticSearch */ >+ [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %] >+ AutocompleteInitOpac(); >+ [% END %] > }); > > </script> >diff --git a/koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js b/koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js >new file mode 100644 >index 0000000000..80b8928e71 >--- /dev/null >+++ b/koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js >@@ -0,0 +1,265 @@ >+/* 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'] >+}; >+ >+var url_request = '/cgi-bin/koha/svc/elasticsearch/opac-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 AutocompleteInitOpac(){ >+ /* 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' */ >+ 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 += "<input type='hidden' value=''>"; >+ /*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 = this.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(); >diff --git a/opac/svc/elasticsearch/opac-autocomplete.pl b/opac/svc/elasticsearch/opac-autocomplete.pl >new file mode 100755 >index 0000000000..babcbb7335 >--- /dev/null >+++ b/opac/svc/elasticsearch/opac-autocomplete.pl >@@ -0,0 +1,69 @@ >+#!/usr/bin/perl >+ >+use strict; >+use warnings; >+use CGI qw ( -utf8 ); >+use v5.10; >+use JSON; >+use Switch; >+use utf8; >+use Text::Unaccent; >+use CGI::Session; >+use Koha::SearchEngine::Elasticsearch::Browse; >+ >+my $browser = Koha::SearchEngine::Elasticsearch::Browse->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"); >+ #fields for autocomplete >+ my $length = scalar @prefix; >+ #search by many prefix fields >+ if ($length > 1){ >+ my $res = $browser->autocomplete_idx(unac_string("UTF-8", $session->param("q")), \@prefix, $session->param("analyzer"), $session->param("token_counter")); >+ print $cgi->header("application/json"); >+ print to_json($res); >+ } >+ #search by one prefix field >+ elsif ($length == 1) { >+ my $res = $browser->autocomplete_one_idx(unac_string("UTF-8", $session->param("q")), $prefix[0], $session->param("analyzer"), $session->param("token_counter")); >+ print $cgi->header("application/json"); >+ print to_json($res); >+ } >+ #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"; >+} >-- >2.17.1
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 27113
:
114168
|
114169
|
114945
|
115117
|
116293
|
119979
|
120028
|
120029
|
120084
|
120088
|
120109
|
120140
|
120141
|
120806
|
121497
|
121498
|
121555
|
121556
|
121557
|
121558
|
131207
|
131208
|
131209
|
131210
|
131211
|
132360
|
132361
|
132362
|
132363
|
132364
|
132808
|
132945
|
132946
|
133042
|
133045
|
133407
|
133409
|
133410
|
136312
|
136456
|
136457
|
136464
|
136465
|
136653
|
136657
|
136658
|
136659
|
136660
|
137436
|
137437
|
137438
|
137439
|
137780
|
137781
|
137782
|
137783
|
139624
|
141466
|
145167
|
146015
|
159769
|
159770
|
159771
|
159772
|
159773
|
159774
|
159775
|
160134
|
160135
|
160136
|
160137
|
160138
|
160139
|
160140
|
160141
|
160142
|
161076
|
161077
|
163826
|
165895
|
165896
|
165897
|
165898
|
165899
|
165900
|
165901
|
165902
|
165903
|
165904
|
165913
|
165922
|
177537
|
177538
|
177539
|
177540
|
177541
|
177542
|
177543
|
177544
|
177545
|
177546
|
177547
|
177548
|
177657
|
178822
|
178823
|
178824
|
178825
|
178826
|
178827
|
178828
|
178829
|
178830
|
178831
|
178832
|
178833
|
179868
|
179869
|
179870
|
179871
|
179872
|
179873
|
179874
|
179875
|
179876
|
179877
|
179878
|
179879