View | Details | Raw Unified | Return to bug 27113
Collapse All | Expand All

(-)a/Koha/SearchEngine/Elasticsearch/Browse.pm (+64 lines)
Lines 162-167 sub _build_query { Link Here
162
    return $query;
162
    return $query;
163
}
163
}
164
164
165
sub autocomplete_one_idx {
166
    my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_;
167
    my @source;
168
    my $elasticsearch = $self->get_elasticsearch();
169
    # we can change these variables
170
    my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, ["<strong>"], ["</strong>"]);
171
172
    my $query = $self->_build_query_autocomplete($cgi_q, $prefix, $analyzer);
173
    my $res = $elasticsearch->search(
174
        index => $self->index_name,
175
        body => $query
176
    );
177
    $res->{'val'} = $cgi_q;
178
    $res->{'prefix'} = $prefix;
179
    $res->{'token_counter'} = $token_counter;
180
181
  return $res;
182
}
183
184
sub autocomplete_idx {
185
  my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_;
186
  my %results;
187
  my $idx = 0;
188
  foreach my $pref ( @$prefix ) {
189
      $results{$idx} = $self->autocomplete_one_idx($cgi_q, $pref, $analyzer, $token_counter);
190
      $idx++;
191
  }
192
  $results{'val'} = $cgi_q;
193
  $results{'prefix'} = join( ',', @$prefix );
194
  $results{'token_counter'} = $token_counter;
195
  return \%results;
196
}
197
198
sub _build_query_autocomplete {
199
    my ($self, $cgi_q, $prefix, $analyzer) = @_;
200
    my (@source);
201
    #prefix + analyzer
202
    my $prefix_analyzer = $prefix . '.' . $analyzer;
203
    # we can change these variables
204
    my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, ["<strong>"], ["</strong>"]);
205
    push(@source, $prefix);
206
    my $query = {
207
        _source    => \@source,
208
        query => {
209
            match => {
210
                $prefix_analyzer    => {
211
                    query => $cgi_q,
212
                    operator => 'and'
213
                }
214
            }
215
        },
216
        highlight => {
217
            number_of_fragments => $nb_fragments,
218
            fragment_size => $size_fragment,
219
            pre_tags => $pre_tags,
220
            post_tags => $post_tags,
221
            fields => {
222
                $prefix_analyzer => {}
223
            }
224
        }
225
    };
226
    return $query;
227
}
228
165
1;
229
1;
166
230
167
__END__
231
__END__
(-)a/admin/searchengine/elasticsearch/field_config.yaml (+4 lines)
Lines 47-52 search: Link Here
47
        type: text
47
        type: text
48
        analyzer: analyzer_phrase
48
        analyzer: analyzer_phrase
49
        search_analyzer: analyzer_phrase
49
        search_analyzer: analyzer_phrase
50
      autocomplete:
51
        type: text
52
        analyzer: autocomplete
53
        search_analyzer: standard
50
      raw:
54
      raw:
51
        type: keyword
55
        type: keyword
52
        normalizer: nfkc_cf_normalizer
56
        normalizer: nfkc_cf_normalizer
(-)a/admin/searchengine/elasticsearch/index_config.yaml (+14 lines)
Lines 2-7 Link Here
2
# Index configuration that defines how different analyzers work.
2
# Index configuration that defines how different analyzers work.
3
index:
3
index:
4
  analysis:
4
  analysis:
5
    tokenizer:
6
      autocomplete_tokenizer:
7
        type: edge_ngram
8
        min_gram: 1
9
        max_gram: 10
10
        token_chars:
11
          - letter
12
          - digit
5
    analyzer:
13
    analyzer:
6
      # Phrase analyzer is used for phrases (exact phrase match)
14
      # Phrase analyzer is used for phrases (exact phrase match)
7
      analyzer_phrase:
15
      analyzer_phrase:
Lines 10-15 index: Link Here
10
          - icu_folding
18
          - icu_folding
11
        char_filter:
19
        char_filter:
12
          - punctuation
20
          - punctuation
21
      autocomplete:
22
        type: custom
23
        filter:
24
          - icu_folding
25
          - lowercase
26
        tokenizer: autocomplete_tokenizer
13
      analyzer_standard:
27
      analyzer_standard:
14
        tokenizer: icu_tokenizer
28
        tokenizer: icu_tokenizer
15
        filter:
29
        filter:
(-)a/api/elasticsearch/autocomplete.pl (+69 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
3
use strict;
4
use warnings;
5
use CGI qw ( -utf8 );
6
use v5.10;
7
use JSON;
8
use Switch;
9
use utf8;
10
use Text::Unaccent;
11
use CGI::Session;
12
use Koha::SearchEngine::Elasticsearch::Browse;
13
14
my $browser = Koha::SearchEngine::Elasticsearch::Browse->new( { index => 'biblios' } );
15
my $cgi = CGI->new;
16
my $session = CGI::Session->new();
17
18
$session->param(-name=>'analyzer', -value=>"autocomplete");
19
$session->param(-name=>'prefix', -value=>$cgi->param("prefix"));
20
$session->param(-name=>'q', -value=>$cgi->param("q"));
21
$session->param(-name=>'key', -value=>$cgi->param("key"));
22
$session->param(-name=>'token_counter', -value=>$cgi->param("token_counter"));
23
$session->expire('+1h');
24
25
#Chose GET in key parametre
26
given ($session->param("key")) {
27
  when ("autocomplete") {
28
    my @prefix = split /,/, $session->param("prefix");
29
    #fields for autocomplete
30
    my $length = scalar @prefix;
31
    #search by many prefix fields
32
    if ($length > 1){
33
      my $res = $browser->autocomplete_idx(unac_string("UTF-8", $session->param("q")), \@prefix, $session->param("analyzer"), $session->param("token_counter"));
34
      print $cgi->header("application/json");
35
      print to_json($res);
36
    }
37
    #search by one prefix field
38
    elsif ($length == 1) {
39
      my $res = $browser->autocomplete_one_idx(unac_string("UTF-8", $session->param("q")), $prefix[0], $session->param("analyzer"), $session->param("token_counter"));
40
      print $cgi->header("application/json");
41
      print to_json($res);
42
    }
43
    #no prefix 404
44
    else {
45
      response404JSON();
46
    }
47
  }
48
  #no key 404
49
  default {
50
    response404JSON();
51
  }
52
}
53
54
sub response404JSON {
55
  my $json = JSON->new->utf8;
56
  my $header_type = "application/json";
57
  my $header_status = "404";
58
  my $output = $json->encode({
59
    "error" => "No data",
60
    "description" => "Bad request",
61
  });
62
  print $cgi->header(
63
    -type => $header_type,
64
    -charset => "utf-8",
65
    -status => $header_status
66
  );
67
  print $output;
68
  print "\n";
69
}
(-)a/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl (+8 lines)
Line 0 Link Here
1
$DBversion = 'XXX'; # will be replaced by the RM
2
if( CheckVersion( $DBversion ) ) {
3
    # you can use $dbh here like:
4
    $dbh->do(q{INSERT IGNORE INTO systempreferences (variable,value,options,explanation,type) VALUES ('AutocompleteElasticSearch', '0', NULL, NULL, 'YesNo')});
5
6
    # Always end with this (adjust the bug info)
7
    NewVersion( $DBversion, 27113, "Autocomplete with elasticsearch");
8
}
(-)a/installer/data/mysql/mandatory/sysprefs.sql (-1 / +2 lines)
Lines 733-737 INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` Link Here
733
('XSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on intranet','Free'),
733
('XSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on intranet','Free'),
734
('XSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on intranet','Free'),
734
('XSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on intranet','Free'),
735
('z3950AuthorAuthFields','701,702,700',NULL,'Define the MARC biblio fields for Personal Name Authorities to fill biblio.author','free'),
735
('z3950AuthorAuthFields','701,702,700',NULL,'Define the MARC biblio fields for Personal Name Authorities to fill biblio.author','free'),
736
('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo')
736
('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo'),
737
('AutocompleteElasticSearch','0',NULL,NULL,'YesNo')
737
;
738
;
(-)a/koha-tmpl/intranet-tmpl/js/elasticsearch/autocomplete.js (+270 lines)
Line 0 Link Here
1
/* OPAC JS file AutocompleteElasticSearch */
2
/* prefix for search ES */
3
var es_prefix = {
4
    'au': 'author',
5
    'pb': 'publisher',
6
    'se': 'title-series',
7
    'su': 'subject',
8
    'ti': 'title-cover',
9
    /* for all */
10
    '': ['title-cover', 'author', 'subject', 'title-series', 'publisher'],
11
    'kw': ['title-cover', 'author', 'subject', 'title-series', 'publisher']
12
};
13
14
/* stop class for elements name=["q"] */
15
var stop_class_input = ["form-field-value"];
16
17
var url_request = '/cgi-bin/koha/api/elasticsearch/autocomplete.pl?q=';
18
/* query for elasticsearch encode*/
19
var query_url_encode = {
20
    '\'':'%5C%27', /* \\' for decode */
21
    '+': ''
22
};
23
/* query for elasticsearch decode*/
24
var query_url_decode = {
25
    '\'':"\\'",
26
    '+': ''
27
};
28
/* count of lines for autocomplete */
29
var nb_autocomplete = 10;
30
/* key API */
31
var key = 'autocomplete';
32
33
function AutocompleteInitIntranet(){
34
    /* vars for class position absolute autocomplete */
35
    var left = "0px";
36
    var right = "0px";
37
    var top = "";
38
    /* get all input name q for search */
39
    var input_q = document.getElementsByName("q");
40
    for (var nb = 0; nb < input_q.length; nb++){
41
        /* addEventListener for every 'input' */
42
        if (!stop_class_input.includes(input_q[nb].className)){
43
            autocomplete(input_q[nb], nb, left, right, top);
44
        }
45
    };
46
};
47
48
function autocomplete(inp, nb, left, right) {
49
    var select_idx = document.getElementsByName("idx");
50
    /* autocomplete off for input */
51
    inp.setAttribute("autocomplete", "off");
52
    /* get parent of input */
53
    var parent_inp = $(inp).parent();
54
    /* get element after input */
55
    var next_elem_inp = inp.nextElementSibling;
56
    /* create new div with position relative for class .autocomplete with absolute */
57
    var div_relative = document.createElement('div');
58
    $(div_relative).addClass( "autocomplete" );
59
    div_relative.append(inp);
60
    /* input doesn't have an elem after, add it to parent */
61
    if (next_elem_inp === null){
62
        parent_inp.append( div_relative );
63
    }  else {  // input has an elem after, add elem after it
64
        next_elem_inp.before(div_relative);
65
    };
66
    var currentFocus;
67
    /*execute a function when someone writes in the text field:*/
68
    var token_counter = 0;
69
    inp.addEventListener("input", function(e) {
70
        var a, val = this.value;
71
        /* var for async compare */
72
        var tmp_input = this.value.replace(/[+']/g, function(matched){
73
            return query_url_decode[matched];
74
        });
75
        token_counter++;
76
        currentFocus = -1;
77
        if (document.getElementsByClassName("autocomplete-items").length !== 0){
78
            a = document.getElementsByClassName("autocomplete-items")[0];
79
        } else {
80
            /*create a DIV element that will contain the items (values):*/
81
            a = document.createElement("DIV");
82
            a.setAttribute("id", this.id + "autocomplete-list");
83
            a.setAttribute("class", "autocomplete-items");
84
            /*append the DIV element as a child of the autocomplete container:*/
85
            this.parentNode.appendChild(a);
86
            /*append position absolute left/right:*/
87
            $(".autocomplete-items").css("left",left);
88
            $(".autocomplete-items").css("right",right);
89
        };
90
        /* get es_prefix key for builder */
91
        var chose_prefix = (select_idx == null || select_idx.length == 0) ? '' : GetValueIdx(select_idx, nb);
92
        chose_prefix = chose_prefix.replace(/([^,])[,-]([^,].*)?$/, '$1');
93
        if (chose_prefix !== null){
94
            var prefix = es_prefix[chose_prefix].toString();
95
            val = val.replace(/[+']/g, function(matched){
96
                return query_url_encode[matched];
97
            });
98
            if (tmp_input == '' || tmp_input == null){
99
                closeAllLists();
100
                token_counter = 0;
101
            } else {
102
                $.ajax({
103
                    type: 'GET',
104
                    url: url_request + val + '&key=' + key + '&prefix=' + prefix + '&token_counter=' + token_counter,
105
                    success: function (data) {
106
                        //console.log(data);
107
                        if (data.length !== 0){
108
                            var myset; //Set for Autocomplete unique
109
                            /* autocomplete for all prefix */
110
                            if (chose_prefix === 'kw' || chose_prefix === ''){
111
                                myset = GetSetAutocompleteAllIdx(data, prefix, key);
112
                            } else { // autocomplete for one prefix
113
                                myset = GetSetAutocompleteOneIdx(data, prefix, key);
114
                            };
115
                            /* append set to autocomplete */
116
                            if ( tmp_input + prefix == data['val'] + data['prefix'] && token_counter === parseInt(data['token_counter'], 10)){
117
                                a.innerHTML = "";
118
                                for (let item of myset){
119
                                    a.appendChild(CreateDivItemAutocomplete(item, val));
120
                                };
121
                            };
122
                        } else {
123
                            closeAllLists(this);
124
                        };
125
                    },
126
                    error: function (data) {
127
                        console.log(data);
128
                    },
129
                });
130
            }
131
132
        };
133
    });
134
    /* get value for tag with name idx */
135
    function GetValueIdx(elem, nb){
136
        switch (elem[0].tagName){
137
            case 'INPUT':
138
                return elem[0].value;
139
            case 'SELECT':
140
                return select_idx[nb].options[select_idx[nb].selectedIndex].value;
141
            default:
142
                return null;
143
        };
144
    };
145
    /* get autocomplete for only one prefix title/author/etc... */
146
    function GetSetAutocompleteOneIdx(data, prefix, key){
147
        let myset = new Set();
148
        let tmp_data = data['hits']['hits'];
149
        for (let i = 0; i < tmp_data.length; i++) {
150
            for (let j = 0; j < tmp_data[i]['highlight'][prefix + '.' + key].length; j++){
151
                /* div with data for autocomplete */
152
                let tmp = tmp_data[i]['highlight'][prefix + '.' + key][j];
153
                tmp = tmp.replace(/^\[/g, '');
154
                tmp = tmp.replace(/\]+$/g, '');
155
                myset.add(tmp.replace(/^[ &\/\\#,+)$~%.'":*?>{}!;]+|[ &\/\\#,+($~%.'":*?<{}!;]+$/g, ''));
156
                if (myset.size >= nb_autocomplete) break;
157
            };
158
            if (myset.size >= nb_autocomplete) break;
159
        };
160
        return myset;
161
    };
162
    /* get autocomplete for all prefix */
163
    function GetSetAutocompleteAllIdx(data, prefix, key){
164
        let myset = new Set();
165
        var pref = prefix.split(",");
166
        for (k = 0; k < Object.keys(data).length; k++){ //Object.keys(data).length
167
            if (data[k] != '' && data[k] != null){
168
                let tmp_data = data[k]['hits']['hits'];
169
                for (i = 0; i < tmp_data.length; i++) {
170
                    for (j = 0; j < tmp_data[i]['highlight'][pref[k] + '.' + key].length; j++){
171
                        /* div with data for autocomplete */
172
                        let tmp = tmp_data[i]['highlight'][pref[k] + '.' + key][j]
173
                        myset.add(tmp.replace(/[ &#,+()$~%.'":*?<{}!/;]+$/g, ''));
174
                        if (myset.size >= nb_autocomplete) break;
175
                    };
176
                    if (myset.size >= nb_autocomplete) break;
177
                };
178
                if (myset.size >= nb_autocomplete) break;
179
            }
180
        }
181
        return myset;
182
    };
183
184
    /*execute a function presses a key on the keyboard:*/
185
    inp.addEventListener("keydown", function(e) {
186
        var x = document.getElementById(this.id + "autocomplete-list");
187
        if (x) x = x.getElementsByTagName("div");
188
        if (e.keyCode == 40) { //DOWN
189
            /*If the arrow DOWN key is pressed,
190
            increase the currentFocus variable:*/
191
            currentFocus++;
192
            /*and and make the current item more visible:*/
193
            addActive(x);
194
        } else if (e.keyCode == 38) { //up
195
            /*If the arrow UP key is pressed,
196
            decrease the currentFocus variable:*/
197
            currentFocus--;
198
            /*and and make the current item more visible:*/
199
            addActive(x);
200
            e.preventDefault();
201
        } else if (e.keyCode == 13) {
202
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
203
            //e.preventDefault();
204
            if (currentFocus > -1) {
205
                /*and simulate a click on the "active" item:*/
206
                if (x) x[currentFocus].click();
207
            }
208
        }
209
        /* press Esc clear all autocomplete */
210
        else if (e.keyCode == 27) {
211
            closeAllLists();
212
        }
213
        /* press Esc clear all autocomplete */
214
        else if (e.keyCode == 8) {
215
            closeAllLists();
216
        };
217
    });
218
    function addActive(x) {
219
        /*a function to classify an item as "active":*/
220
        if (!x) return false;
221
        /*start by removing the "active" class on all items:*/
222
        removeActive(x);
223
        if (currentFocus >= x.length) currentFocus = 0;
224
        if (currentFocus < 0) currentFocus = (x.length - 1);
225
        /*add class "autocomplete-active":*/
226
        x[currentFocus].classList.add("autocomplete-active");
227
        inp.value = (x[currentFocus].textContent.replace(/<\/?[^>]+(>|$)/g, "")).trim();
228
    };
229
    function removeActive(x) {
230
        /*a function to remove the "active" class from all autocomplete items:*/
231
        for (var i = 0; i < x.length; i++) {
232
            x[i].classList.remove("autocomplete-active");
233
        };
234
    };
235
236
    function closeAllLists(elem) {
237
        /*close all autocomplete lists in the document with class autocomplete-items */
238
        var x = document.getElementsByClassName("autocomplete-items");
239
        for (var i = 0; i < x.length; i++) {
240
            x[i].parentNode.removeChild(x[i])
241
        };
242
    };
243
244
    /* div for one item autocomplete */
245
    function CreateDivItemAutocomplete (elem){
246
        var b = document.createElement("DIV");
247
        // add element ";
248
        b.innerHTML += elem;
249
        /*insert a input field that will hold the current array item's value:*/
250
        b.innerHTML += "<input type='hidden' value=''>";
251
        /*execute a function when someone clicks on the item value (DIV element):*/
252
        b.addEventListener("click", function(e) {
253
            /* insert the value for the autocomplete text field: */
254
            inp.value = this.getElementsByTagName("input")[0].value;
255
            /* normalyzer hightlight without tags */
256
            //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim();
257
            inp.value = this.innerText;
258
            /* Submit form click mouse in div */
259
            this.closest("form").submit();
260
        });
261
        return b;
262
    };
263
264
    /*execute a function when someone clicks in the document:*/
265
    document.addEventListener("click", function (e) {
266
        closeAllLists(e.target);
267
    });
268
};
269
270
AutocompleteInitIntranet();
(-)a/koha-tmpl/intranet-tmpl/prog/css/elasticsearch/autocomplete.css (+28 lines)
Line 0 Link Here
1
.autocomplete {
2
  /*the container must be positioned relative:*/
3
  position: relative;
4
  display: inline-block;
5
}
6
.autocomplete-items {
7
    position: absolute;
8
    border: 1px solid #d4d4d4;
9
    border-bottom: none;
10
    border-top: none;
11
    z-index: 99;
12
    /*position the autocomplete items to be the same width as the container:*/
13
  }
14
  .autocomplete-items div {
15
    padding: 10px;
16
    cursor: pointer;
17
    background-color: #fff;
18
    border-bottom: 1px solid #d4d4d4;
19
  }
20
  .autocomplete-items div:hover {
21
    /*when hovering an item:*/
22
    background-color: #e9e9e9;
23
  }
24
  .autocomplete-active {
25
    /*when navigating through the items using the arrow keys:*/
26
    background-color: #cedfb1 !important;
27
    color: #ffffff;
28
  }
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/doc-head-close.inc (+5 lines)
Lines 21-26 Link Here
21
[% Asset.css("css/print.css", { media = "print" }) | $raw %]
21
[% Asset.css("css/print.css", { media = "print" }) | $raw %]
22
[% INCLUDE intranetstylesheet.inc %]
22
[% INCLUDE intranetstylesheet.inc %]
23
[% IF ( bidi ) %][% Asset.css("css/right-to-left.css") | $raw %][% END %]
23
[% IF ( bidi ) %][% Asset.css("css/right-to-left.css") | $raw %][% END %]
24
<!-- Intranet inc CSS AutocompleteElasticSearch -->
25
[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
26
    [% SET Optylesheet = 'elasticsearch/autocomplete.css' %]
27
    <link rel="stylesheet" type="text/css" href="[% interface | url %]/[% theme | url %]/css/[% Optylesheet | url %]" />
28
[% END %]
24
29
25
<script>
30
<script>
26
var Koha = {};
31
var Koha = {};
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc (+4 lines)
Lines 144-147 Link Here
144
    });
144
    });
145
    </script>
145
    </script>
146
[% END %]
146
[% END %]
147
<!-- Intranet inc JS AutocompleteElasticSearch -->
148
[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
149
	[% Asset.js("js/elasticsearch/autocomplete.js") %]
150
[% END %]
147
<!-- / js_includes.inc -->
151
<!-- / js_includes.inc -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref (+9 lines)
Lines 276-278 Searching: Link Here
276
            - LIBRIS base URL
276
            - LIBRIS base URL
277
            - pref: LibrisURL
277
            - pref: LibrisURL
278
            - "Please only change this if you are sure it needs changing."
278
            - "Please only change this if you are sure it needs changing."
279
        -
280
            - pref: AutocompleteElasticSearch
281
              type: boolean
282
              default: no
283
              choices:
284
                  yes: Show
285
                  no: "Don't show"
286
            - looking terms based on a provided text by using an ElasticSearch.
287
        -
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt (-5 / +5 lines)
Lines 106-116 Link Here
106
    <fieldset id="searchterms">
106
    <fieldset id="searchterms">
107
    <legend>Search for </legend>
107
    <legend>Search for </legend>
108
    [% FOREACH search_box IN search_boxes_loop %]
108
    [% FOREACH search_box IN search_boxes_loop %]
109
        [% IF ( search_boxes_label ) %]
109
        [% IF ( search_boxes_label ) %]<div style="text-indent: 4.5em;">[% ELSE %]<div>[% END %]
110
        <div class="search-term-row" style="text-indent: 4.5em;">
111
        [% ELSE %]
112
        <div class="search-term-row">
113
        [% END %]
114
			[% IF ( expanded_options ) %]
110
			[% IF ( expanded_options ) %]
115
            [% IF ( search_box.boolean ) %]
111
            [% IF ( search_box.boolean ) %]
116
                <select name="op">
112
                <select name="op">
Lines 337-342 Link Here
337
            var dad  = line.parentNode;
333
            var dad  = line.parentNode;
338
            dad.appendChild(line.cloneNode(true));
334
            dad.appendChild(line.cloneNode(true));
339
            line.removeChild(ButtonPlus);
335
            line.removeChild(ButtonPlus);
336
            /* Intranet JS AutocompleteElasticSearch */
337
            [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
338
            AutocompleteInitIntranet();
339
    [% END %]
340
        }
340
        }
341
        var Sticky;
341
        var Sticky;
342
        $(document).ready(function() {
342
        $(document).ready(function() {
(-)a/koha-tmpl/intranet-tmpl/prog/js/staff-global.js (+6 lines)
Lines 82-87 $.fn.selectTabByID = function (tabID) { Link Here
82
    $(".keep_text").on("click",function(){
82
    $(".keep_text").on("click",function(){
83
        var field_index = $(this).parent().index();
83
        var field_index = $(this).parent().index();
84
        keep_text( field_index );
84
        keep_text( field_index );
85
        /* AutocompleteElasticSearch Tab */
86
        var tab = this.hash.substr(1, this.hash.length-1);
87
        /*  Koha.Preference('AutocompleteElasticSearch') == Show */
88
        if (typeof AutocompleteInitIntranet !== "undefined" && tab === 'catalog_search' ){
89
            AutocompleteInitIntranet();
90
        }
85
    });
91
    });
86
92
87
    $(".toggle_element").on("click",function(e){
93
    $(".toggle_element").on("click",function(e){
(-)a/koha-tmpl/opac-tmpl/bootstrap/css/opac-elasticsearch/opac-autocomplete.css (+29 lines)
Line 0 Link Here
1
/* CSS file AutocompleteElasticSearch */
2
.autocomplete {
3
  /*the container must be positioned relative:*/
4
  position: relative;
5
  display: inline-block;
6
  width: 100%;
7
}
8
.autocomplete-items {
9
    position: absolute;
10
    border: 1px solid #d4d4d4;
11
    border-bottom: none;
12
    border-top: none;
13
    z-index: 99;
14
  }
15
  .autocomplete-items div {
16
    padding: 10px;
17
    cursor: pointer;
18
    background-color: #fff;
19
    border-bottom: 1px solid #d4d4d4;
20
  }
21
  .autocomplete-items div:hover {
22
    /*when hovering an item:*/
23
    background-color: #e9e9e9;
24
  }
25
  .autocomplete-active {
26
    /*when navigating through the items using the arrow keys:*/
27
    background-color: #cedfb1 !important;
28
    color: #ffffff;
29
  }
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/includes/doc-head-close.inc (+5 lines)
Lines 23-28 Link Here
23
        [% SET opaclayoutstylesheet = 'opac.css' %]
23
        [% SET opaclayoutstylesheet = 'opac.css' %]
24
    [% END %]
24
    [% END %]
25
[% END %]
25
[% END %]
26
<!-- OPAC inc CSS AutocompleteElasticSearch -->
27
[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
28
    [% SET Optylesheet = 'opac-elasticsearch/opac-autocomplete.css' %]
29
    <link rel="stylesheet" type="text/css" href="[% interface | url %]/[% theme | url %]/css/[% Optylesheet | url %]" />
30
[% END %]
26
[% IF (opaclayoutstylesheet.match('^https?:|^\/')) %]
31
[% IF (opaclayoutstylesheet.match('^https?:|^\/')) %]
27
    <link rel="stylesheet" type="text/css" href="[% opaclayoutstylesheet | url %]" />
32
    <link rel="stylesheet" type="text/css" href="[% opaclayoutstylesheet | url %]" />
28
[% ELSE %]
33
[% ELSE %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/includes/opac-bottom.inc (+4 lines)
Lines 318-323 $(document).ready(function() { Link Here
318
});
318
});
319
</script>
319
</script>
320
[% PROCESS jsinclude %]
320
[% PROCESS jsinclude %]
321
<!-- OPAC *.inc JS AutocompleteElasticSearch -->
322
[% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
323
    [% Asset.js("js/opac-elasticsearch/opac-autocomplete.js") %]
324
[% END %]
321
[% IF ( Koha.Preference('OPACUserJS') ) %]
325
[% IF ( Koha.Preference('OPACUserJS') ) %]
322
    <script>
326
    <script>
323
        [% Koha.Preference('OPACUserJS') | $raw %]
327
        [% Koha.Preference('OPACUserJS') | $raw %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt (+8 lines)
Lines 527-532 $(document).ready(function() { Link Here
527
        var newLine = thisLine.clone();
527
        var newLine = thisLine.clone();
528
        newLine.find('input').val('');
528
        newLine.find('input').val('');
529
        thisLine.after(newLine);
529
        thisLine.after(newLine);
530
        /* OPAC JS AutocompleteElasticSearch */
531
        [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
532
            AutocompleteInitOpac();
533
        [% END %]
530
    });
534
    });
531
535
532
    $(document).on("click", '.ButtonLess', function(e) {
536
    $(document).on("click", '.ButtonLess', function(e) {
Lines 535-540 $(document).ready(function() { Link Here
535
           $('.ButtonLess').hide();
539
           $('.ButtonLess').hide();
536
        }
540
        }
537
        $(this).parent().parent().remove();
541
        $(this).parent().parent().remove();
542
        /* OPAC JS AutocompleteElasticSearch */
543
        [% IF ( Koha.Preference('AutocompleteElasticSearch') ) %]
544
            AutocompleteInitOpac();
545
        [% END %]
538
    });
546
    });
539
547
540
</script>
548
</script>
(-)a/koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js (+265 lines)
Line 0 Link Here
1
/* OPAC JS file AutocompleteElasticSearch */
2
/* prefix for search ES */
3
var es_prefix = {
4
    'au': 'author',
5
    'pb': 'publisher',
6
    'se': 'title-series',
7
    'su': 'subject',
8
    'ti': 'title-cover',
9
    /* for all */
10
    '': ['title-cover', 'author', 'subject', 'title-series', 'publisher'],
11
    'kw': ['title-cover', 'author', 'subject', 'title-series', 'publisher']
12
};
13
14
var url_request = '/cgi-bin/koha/svc/elasticsearch/opac-autocomplete.pl?q=';
15
/* query for elasticsearch encode*/
16
var query_url_encode = {
17
    '\'':'%5C%27', /* \\' for decode */
18
    '+': ''
19
};
20
/* query for elasticsearch decode*/
21
var query_url_decode = {
22
    '\'':"\\'",
23
    '+': ''
24
};
25
/* count of lines for autocomplete */
26
var nb_autocomplete = 10;
27
/* key API */
28
var key = 'autocomplete';
29
30
function AutocompleteInitOpac(){
31
    /* vars for class position absolute autocomplete */
32
    var left = "0px";
33
    var right = "0px";
34
    var top = "";
35
    /* get all input name q for search */
36
    var input_q = document.getElementsByName("q");
37
    for (var nb = 0; nb < input_q.length; nb++){
38
        /* addEventListener for every 'input' */
39
        autocomplete(input_q[nb], nb, left, right, top);
40
    };
41
};
42
43
function autocomplete(inp, nb, left, right) {
44
    var select_idx = document.getElementsByName("idx");
45
    /* autocomplete off for input */
46
    inp.setAttribute("autocomplete", "off");
47
    /* get parent of input */
48
    var parent_inp = $(inp).parent();
49
    /* get element after input */
50
    var next_elem_inp = inp.nextElementSibling;
51
    /* create new div with position relative for class .autocomplete with absolute */
52
    var div_relative = document.createElement('div');
53
    $(div_relative).addClass( "autocomplete" );
54
    div_relative.append(inp);
55
    /* input doesn't have an elem after, add it to parent */
56
    if (next_elem_inp === null){
57
        parent_inp.append( div_relative );
58
    }  else {  // input has an elem after, add elem after it
59
        next_elem_inp.before(div_relative);
60
    };
61
    var currentFocus;
62
    /*execute a function when someone writes in the text field:*/
63
    var token_counter = 0;
64
    inp.addEventListener("input", function(e) {
65
        var a, val = this.value;
66
        /* var for async compare */
67
        var tmp_input = this.value.replace(/[+']/g, function(matched){
68
            return query_url_decode[matched];
69
        });
70
        token_counter++;
71
        currentFocus = -1;
72
        if (document.getElementsByClassName("autocomplete-items").length !== 0){
73
            a = document.getElementsByClassName("autocomplete-items")[0];
74
        } else {
75
            /*create a DIV element that will contain the items (values):*/
76
            a = document.createElement("DIV");
77
            a.setAttribute("id", this.id + "autocomplete-list");
78
            a.setAttribute("class", "autocomplete-items");
79
            /*append the DIV element as a child of the autocomplete container:*/
80
            this.parentNode.appendChild(a);
81
            /*append position absolute left/right:*/
82
            $(".autocomplete-items").css("left",left);
83
            $(".autocomplete-items").css("right",right);
84
        };
85
        /* get es_prefix key for builder */
86
        var chose_prefix = (select_idx == null || select_idx.length == 0) ? '' : GetValueIdx(select_idx, nb);
87
        chose_prefix = chose_prefix.replace(/([^,])[,-]([^,].*)?$/, '$1');
88
        if (chose_prefix !== null){
89
            var prefix = es_prefix[chose_prefix].toString();
90
            val = val.replace(/[+']/g, function(matched){
91
                return query_url_encode[matched];
92
            });
93
            if (tmp_input == '' || tmp_input == null){
94
                closeAllLists();
95
                token_counter = 0;
96
            } else {
97
                $.ajax({
98
                    type: 'GET',
99
                    url: url_request + val + '&key=' + key + '&prefix=' + prefix + '&token_counter=' + token_counter,
100
                    success: function (data) {
101
                        //console.log(data);
102
                        if (data.length !== 0){
103
                            var myset; //Set for Autocomplete unique
104
                            /* autocomplete for all prefix */
105
                            if (chose_prefix === 'kw' || chose_prefix === ''){
106
                                myset = GetSetAutocompleteAllIdx(data, prefix, key);
107
                            } else { // autocomplete for one prefix
108
                                myset = GetSetAutocompleteOneIdx(data, prefix, key);
109
                            };
110
                            /* append set to autocomplete */
111
                            if ( tmp_input + prefix == data['val'] + data['prefix'] && token_counter === parseInt(data['token_counter'], 10)){
112
                                a.innerHTML = "";
113
                                for (let item of myset){
114
                                    a.appendChild(CreateDivItemAutocomplete(item, val));
115
                                };
116
                            };
117
                        } else {
118
                            closeAllLists(this);
119
                        };
120
                    },
121
                    error: function (data) {
122
                        console.log(data);
123
                    },
124
                });
125
            }
126
127
        };
128
    });
129
    /* get value for tag with name idx */
130
    function GetValueIdx(elem, nb){
131
        switch (elem[0].tagName){
132
            case 'INPUT':
133
                return elem[0].value;
134
            case 'SELECT':
135
                return select_idx[nb].options[select_idx[nb].selectedIndex].value;
136
            default:
137
                return null;
138
        };
139
    };
140
    /* get autocomplete for only one prefix title/author/etc... */
141
    function GetSetAutocompleteOneIdx(data, prefix, key){
142
        let myset = new Set();
143
        let tmp_data = data['hits']['hits'];
144
        for (let i = 0; i < tmp_data.length; i++) {
145
            for (let j = 0; j < tmp_data[i]['highlight'][prefix + '.' + key].length; j++){
146
                /* div with data for autocomplete */
147
                let tmp = tmp_data[i]['highlight'][prefix + '.' + key][j];
148
                tmp = tmp.replace(/^\[/g, '');
149
                tmp = tmp.replace(/\]+$/g, '');
150
                myset.add(tmp.replace(/^[ &\/\\#,+)$~%.'":*?>{}!;]+|[ &\/\\#,+($~%.'":*?<{}!;]+$/g, ''));
151
                if (myset.size >= nb_autocomplete) break;
152
            };
153
            if (myset.size >= nb_autocomplete) break;
154
        };
155
        return myset;
156
    };
157
    /* get autocomplete for all prefix */
158
    function GetSetAutocompleteAllIdx(data, prefix, key){
159
        let myset = new Set();
160
        var pref = prefix.split(",");
161
        for (k = 0; k < Object.keys(data).length; k++){ //Object.keys(data).length
162
            if (data[k] != '' && data[k] != null){
163
                let tmp_data = data[k]['hits']['hits'];
164
                for (i = 0; i < tmp_data.length; i++) {
165
                    for (j = 0; j < tmp_data[i]['highlight'][pref[k] + '.' + key].length; j++){
166
                        /* div with data for autocomplete */
167
                        let tmp = tmp_data[i]['highlight'][pref[k] + '.' + key][j]
168
                        myset.add(tmp.replace(/[ &#,+()$~%.'":*?<{}!/;]+$/g, ''));
169
                        if (myset.size >= nb_autocomplete) break;
170
                    };
171
                    if (myset.size >= nb_autocomplete) break;
172
                };
173
                if (myset.size >= nb_autocomplete) break;
174
            }
175
        }
176
        return myset;
177
    };
178
179
    /*execute a function presses a key on the keyboard:*/
180
    inp.addEventListener("keydown", function(e) {
181
        var x = document.getElementById(this.id + "autocomplete-list");
182
        if (x) x = x.getElementsByTagName("div");
183
        if (e.keyCode == 40) { //DOWN
184
            /*If the arrow DOWN key is pressed,
185
            increase the currentFocus variable:*/
186
            currentFocus++;
187
            /*and and make the current item more visible:*/
188
            addActive(x);
189
        } else if (e.keyCode == 38) { //up
190
            /*If the arrow UP key is pressed,
191
            decrease the currentFocus variable:*/
192
            currentFocus--;
193
            /*and and make the current item more visible:*/
194
            addActive(x);
195
            e.preventDefault();
196
        } else if (e.keyCode == 13) {
197
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
198
            //e.preventDefault();
199
            if (currentFocus > -1) {
200
                /*and simulate a click on the "active" item:*/
201
                if (x) x[currentFocus].click();
202
            }
203
        }
204
        /* press Esc clear all autocomplete */
205
        else if (e.keyCode == 27) {
206
            closeAllLists();
207
        }
208
        /* press Esc clear all autocomplete */
209
        else if (e.keyCode == 8) {
210
            closeAllLists();
211
        };
212
    });
213
    function addActive(x) {
214
        /*a function to classify an item as "active":*/
215
        if (!x) return false;
216
        /*start by removing the "active" class on all items:*/
217
        removeActive(x);
218
        if (currentFocus >= x.length) currentFocus = 0;
219
        if (currentFocus < 0) currentFocus = (x.length - 1);
220
        /*add class "autocomplete-active":*/
221
        x[currentFocus].classList.add("autocomplete-active");
222
        inp.value = (x[currentFocus].textContent.replace(/<\/?[^>]+(>|$)/g, "")).trim();
223
    };
224
    function removeActive(x) {
225
        /*a function to remove the "active" class from all autocomplete items:*/
226
        for (var i = 0; i < x.length; i++) {
227
            x[i].classList.remove("autocomplete-active");
228
        };
229
    };
230
231
    function closeAllLists(elem) {
232
        /*close all autocomplete lists in the document with class autocomplete-items */
233
        var x = document.getElementsByClassName("autocomplete-items");
234
        for (var i = 0; i < x.length; i++) {
235
            x[i].parentNode.removeChild(x[i])
236
        };
237
    };
238
239
    /* div for one item autocomplete */
240
    function CreateDivItemAutocomplete (elem){
241
        var b = document.createElement("DIV");
242
        // add element ";
243
        b.innerHTML += elem;
244
        /*insert a input field that will hold the current array item's value:*/
245
        b.innerHTML += "<input type='hidden' value=''>";
246
        /*execute a function when someone clicks on the item value (DIV element):*/
247
        b.addEventListener("click", function(e) {
248
            /* insert the value for the autocomplete text field: */
249
            inp.value = this.getElementsByTagName("input")[0].value;
250
            /* normalyzer hightlight without tags */
251
            //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim();
252
            inp.value = this.innerText;
253
            /* Submit form click mouse in div */
254
            this.closest("form").submit();
255
        });
256
        return b;
257
    };
258
259
    /*execute a function when someone clicks in the document:*/
260
    document.addEventListener("click", function (e) {
261
        closeAllLists(e.target);
262
    });
263
};
264
265
AutocompleteInitOpac();
(-)a/opac/svc/elasticsearch/opac-autocomplete.pl (-1 / +71 lines)
Line 0 Link Here
0
- 
1
#!/usr/bin/perl
2
3
use strict;
4
use warnings;
5
use CGI qw ( -utf8 );
6
use v5.10;
7
use JSON;
8
use utf8;
9
use Unicode::Normalize;
10
use CGI::Session;
11
use Koha::SearchEngine::Elasticsearch::Browse;
12
13
my $browser = Koha::SearchEngine::Elasticsearch::Browse->new( { index => 'biblios' } );
14
my $cgi = CGI->new;
15
my $session = CGI::Session->new();
16
17
$session->param(-name=>'analyzer', -value=>"autocomplete");
18
$session->param(-name=>'prefix', -value=>$cgi->param("prefix"));
19
$session->param(-name=>'q', -value=>$cgi->param("q"));
20
$session->param(-name=>'key', -value=>$cgi->param("key"));
21
$session->param(-name=>'token_counter', -value=>$cgi->param("token_counter"));
22
$session->expire('+1h');
23
24
#Chose GET in key parametre
25
given ($session->param("key")) {
26
  when ("autocomplete") {
27
    my @prefix = split /,/, $session->param("prefix");
28
    #fields for autocomplete
29
    my $length = scalar @prefix;
30
    my $ses = NFKD( $session->param("q") );    
31
    $ses =~ s/\p{NonspacingMark}//g;
32
33
    #search by many prefix fields
34
    if ($length > 1){
35
      my $res = $browser->autocomplete_idx($ses, \@prefix, $session->param("analyzer"), $session->param("token_counter"));
36
      print $cgi->header("application/json");
37
      print to_json($res);
38
    }
39
    #search by one prefix field
40
    elsif ($length == 1) {
41
      my $res = $browser->autocomplete_one_idx($ses, $prefix[0], $session->param("analyzer"), $session->param("token_counter"));
42
      print $cgi->header("application/json");
43
      print to_json($res);
44
    }
45
    #no prefix 404
46
    else {
47
      response404JSON();
48
    }
49
  }
50
  #no key 404
51
  default {
52
    response404JSON();
53
  }
54
}
55
56
sub response404JSON {
57
  my $json = JSON->new->utf8;
58
  my $header_type = "application/json";
59
  my $header_status = "404";
60
  my $output = $json->encode({
61
    "error" => "No data",
62
    "description" => "Bad request",
63
  });
64
  print $cgi->header(
65
    -type => $header_type,
66
    -charset => "utf-8",
67
    -status => $header_status
68
  );
69
  print $output;
70
  print "\n";
71
}

Return to bug 27113