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

(-)a/Koha/SearchEngine/Elasticsearch/Browse.pm (-1 / +126 lines)
Lines 40-46 Koha::SearchEngine::ElasticSearch::Browse - browse functions for Elasticsearch Link Here
40
40
41
This provides an easy interface to the "browse" functionality. Essentially,
41
This provides an easy interface to the "browse" functionality. Essentially,
42
it does a fast prefix search on defined fields. The fields have to be marked
42
it does a fast prefix search on defined fields. The fields have to be marked
43
as "suggestible" in the database when indexing takes place.
43
as "suggestible" in the database when indexing takes place(no action required for autocomplete).
44
44
45
=head1 METHODS
45
=head1 METHODS
46
46
Lines 162-167 sub _build_query { Link Here
162
    return $query;
162
    return $query;
163
}
163
}
164
164
165
=head2 autocomplete_one_idx
166
167
    my $query = $self->autocomplete_one_idx($cgi_q, $prefix, $analyzer, $token_counter);
168
169
Does a prefix search for C<$prefix> (only one prefix), looking for C<$cgi_q> , using analyzer C<$analyzer> ,
170
C<$token_counter> is used for identify which word to use in autocomplete
171
172
=cut
173
174
=head3 Returns
175
176
This returns an arrayref of hashrefs with highlights. Each hashref contains a "text" element that contains the field as returned.
177
178
=cut
179
180
sub autocomplete_one_idx {
181
    my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_;
182
    my @source;
183
    my $elasticsearch = $self->get_elasticsearch();
184
    my $query = $self->_build_query_autocomplete($cgi_q, $prefix, $analyzer);
185
    my $res = $elasticsearch->search(
186
        index => $self->index_name,
187
        body => $query
188
    );
189
    $res->{'val'} = $cgi_q;
190
    $res->{'prefix'} = $prefix;
191
    $res->{'token_counter'} = $token_counter;
192
193
  return $res;
194
}
195
196
=head2 autocomplete_idx
197
198
    my $query = $self->autocomplete_idx($cgi_q, $prefix, $analyzer, $token_counter);
199
200
Does a prefix search for C<$prefix> (many prefix), looking for C<$cgi_q>, using analyzer C<$analyzer>,
201
C<$token_counter> is used for identify which word to use in autocomplete
202
203
=cut
204
205
=head3 Returns
206
207
This returns an arrayref for all prefix of hashrefs with highlights. Each hashref contains a "text" element
208
that contains the field as returned.
209
210
=cut
211
212
sub autocomplete_idx {
213
  my ($self, $cgi_q, $prefix, $analyzer, $token_counter) = @_;
214
  my %results;
215
  my $idx = 0;
216
  foreach my $pref ( @$prefix ) {
217
      $results{$idx} = $self->autocomplete_one_idx($cgi_q, $pref, $analyzer, $token_counter);
218
      $idx++;
219
  }
220
  $results{'val'} = $cgi_q;
221
  $results{'prefix'} = join( ',', @$prefix );
222
  $results{'token_counter'} = $token_counter;
223
  return \%results;
224
}
225
226
=head2 _build_query_autocomplete
227
228
    my $query = $self->_build_query_autocomplete($cgi_q, $prefix, $analyzer);
229
230
Arguments:
231
232
=over 4
233
234
=item cgi_q
235
236
GET request
237
238
=item prefix
239
240
Field(s) for autocomplete (title, author, etc...)
241
242
=item analyzer
243
244
Name of analyzer wich we use for autocomplete
245
246
=back
247
248
=cut
249
250
=head3 Returns
251
252
This returns an arrayref for all prefix of hashrefs with highlights. Each hashref contains a "text" element
253
that contains the field as returned.
254
255
=cut
256
257
sub _build_query_autocomplete {
258
    my ($self, $cgi_q, $prefix, $analyzer) = @_;
259
    my (@source);
260
    #prefix + analyzer
261
    my $prefix_analyzer = $prefix . '.' . $analyzer;
262
    # we can change these variables
263
    my ($nb_fragments, $size_fragment, $pre_tags, $post_tags) = (1, 100, ["<strong>"], ["</strong>"]);
264
    push(@source, $prefix);
265
    my $query = {
266
        _source    => \@source,
267
        query => {
268
            match => {
269
                $prefix_analyzer    => {
270
                    query => $cgi_q,
271
                    operator => 'and'
272
                }
273
            }
274
        },
275
        highlight => {
276
            number_of_fragments => $nb_fragments,
277
            fragment_size => $size_fragment,
278
            pre_tags => $pre_tags,
279
            post_tags => $post_tags,
280
            fields => {
281
                $prefix_analyzer => {}
282
            }
283
        }
284
    };
285
    return $query;
286
}
287
165
1;
288
1;
166
289
167
__END__
290
__END__
Lines 172-177 __END__ Link Here
172
295
173
=item Robin Sheat << <robin@catalyst.net.nz> >>
296
=item Robin Sheat << <robin@catalyst.net.nz> >>
174
297
298
=item Ivan Dziuba << <ivan.dziuba@inlibro.com> >>
299
175
=back
300
=back
176
301
177
=cut
302
=cut
(-)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: 16
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/elasticsearch.pl (+59 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
3
use strict;
4
use warnings;
5
use CGI qw ( -utf8 );
6
use JSON;
7
use utf8;
8
use Unicode::Normalize;
9
use CGI::Session;
10
use Koha::SearchEngine::Elasticsearch::Browse;
11
12
my $browser = Koha::SearchEngine::Elasticsearch::Browse->new( { index => 'biblios' } );
13
my $cgi = CGI->new;
14
my $session = CGI::Session->load() or die CGI::Session->errstr();
15
16
$session->param(-name=>'analyzer', -value=>"autocomplete");
17
$session->param(-name=>'prefix', -value=>$cgi->multi_param("prefix"));
18
$session->param(-name=>'q', -value=>$cgi->multi_param("q"));
19
$session->param(-name=>'key', -value=>$cgi->multi_param("key"));
20
$session->param(-name=>'token_counter', -value=>$cgi->multi_param("token_counter"));
21
$session->expire('+1h');
22
23
if ($session->param("key") eq "autocomplete") {
24
  my @prefix = split /,/, $session->param("prefix");
25
  #fields for autocomplete
26
  my $length = scalar @prefix;
27
  my $ses = NFKD( $session->param("q") );
28
  $ses =~ s/\p{NonspacingMark}//g;
29
30
  if ($length >= 1) {
31
    my $res = $browser->autocomplete_idx($ses, \@prefix, $session->param("analyzer"), $session->param("token_counter"));
32
    print $cgi->header("application/json;charset=UTF-8");
33
    print to_json($res, {utf8 => 1});
34
  }
35
  #no prefix 404
36
  else {
37
    response404JSON();
38
  }
39
} else {
40
  response404JSON();
41
}
42
43
sub response404JSON {
44
  my $res = CGI->new;
45
  my $json = JSON->new->utf8;
46
  my $header_type = "application/json;charset=UTF-8";
47
  my $header_status = "404";
48
  my $output = $json->encode({
49
    "error" => "No data",
50
    "description" => "Bad request",
51
  });
52
  print $res->header(
53
    -type => $header_type,
54
    -charset => "utf-8",
55
    -status => $header_status
56
  );
57
  print $output;
58
  print "\n";
59
}
(-)a/installer/data/mysql/atomicupdate/bug_27113-elasticsearch_autocomplete_input_search.perl (+10 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 ('OPACAutocompleteElasticSearch', '0', NULL, NULL, 'YesNo')});
5
6
    $dbh->do(q{INSERT IGNORE INTO systempreferences (variable,value,options,explanation,type) VALUES ('IntranetAutocompleteElasticSearch', '0', NULL, NULL, 'YesNo')});
7
8
    # Always end with this (adjust the bug info)
9
    NewVersion( $DBversion, 27113, "Autocomplete with elasticsearch");
10
}
(-)a/installer/data/mysql/mandatory/sysprefs.sql (-1 / +3 lines)
Lines 756-760 INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` Link Here
756
('XSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on intranet','Free'),
756
('XSLTListsDisplay','default','','Enable XSLT stylesheet control over lists pages display on intranet','Free'),
757
('XSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on intranet','Free'),
757
('XSLTResultsDisplay','default','','Enable XSL stylesheet control over results page display on intranet','Free'),
758
('z3950AuthorAuthFields','701,702,700',NULL,'Define the MARC biblio fields for Personal Name Authorities to fill biblio.author','free'),
758
('z3950AuthorAuthFields','701,702,700',NULL,'Define the MARC biblio fields for Personal Name Authorities to fill biblio.author','free'),
759
('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo')
759
('z3950NormalizeAuthor','0','','If ON, Personal Name Authorities will replace authors in biblio.author','YesNo'),
760
('OPACAutocompleteElasticSearch','0',NULL,NULL,'YesNo'),
761
('IntranetAutocompleteElasticSearch','0',NULL,NULL,'YesNo')
760
;
762
;
(-)a/koha-tmpl/intranet-tmpl/js/elasticsearch/autocomplete.js (+270 lines)
Line 0 Link Here
1
/* OPAC JS file OPACAutocompleteElasticSearch */
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/elasticsearch.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
68
    var timer;
69
    var doneTimerInterval = 500;
70
71
    /*execute a function when someone writes in the text field:*/
72
    var token_counter = 0;
73
    inp.addEventListener("input", function(e) {
74
        clearTimeout(timer);
75
        timer = setTimeout( function() {
76
            var a, val = inp.value;
77
            /* var for async compare */
78
            var tmp_input = inp.value.replace(/[+']/g, function(matched){
79
                return query_url_decode[matched];
80
            });
81
            token_counter++;
82
            currentFocus = -1;
83
            if (document.getElementsByClassName("autocomplete-items").length !== 0){
84
                a = document.getElementsByClassName("autocomplete-items")[0];
85
            } else {
86
                /*create a DIV element that will contain the items (values):*/
87
                a = document.createElement("DIV");
88
                a.setAttribute("id", inp.id + "autocomplete-list");
89
                a.setAttribute("class", "autocomplete-items");
90
                /*append the DIV element as a child of the autocomplete container:*/
91
                inp.parentNode.appendChild(a);
92
                /*append position absolute left/right:*/
93
                $(".autocomplete-items").css("left",left);
94
                $(".autocomplete-items").css("right",right);
95
            };
96
            /* get es_prefix key for builder */
97
            var chose_prefix = (select_idx == null || select_idx.length == 0) ? '' : GetValueIdx(select_idx, nb);
98
            chose_prefix = chose_prefix.replace(/([^,])[,-]([^,].*)?$/, '$1');
99
            if (chose_prefix !== null){
100
                var prefix = es_prefix[chose_prefix].toString();
101
                val = val.replace(/[+']/g, function(matched){
102
                    return query_url_encode[matched];
103
                });
104
                if (tmp_input == '' || tmp_input == null){
105
                    closeAllLists();
106
                    token_counter = 0;
107
                } else {
108
                    $.ajax({
109
                        type: 'GET',
110
                        url: url_request + val + '&key=' + key + '&prefix=' + prefix + '&token_counter=' + token_counter,
111
                        contentType: "application/json;charset=UTF-8",
112
                        success: function (data) {
113
                            //console.log(data);
114
                            if (data.length !== 0){
115
                                var myset; //Set for Autocomplete unique
116
117
                                myset = GetSetAutocomplete(data, prefix, key);
118
                                /* append set to autocomplete */
119
                                if ( tmp_input + prefix == data['val'] + data['prefix'] && token_counter === parseInt(data['token_counter'], 10)){
120
                                    a.innerHTML = "";
121
                                    for (let item of myset){
122
                                        a.appendChild(CreateDivItemAutocomplete(item, val));
123
                                    };
124
                                };
125
                            } else {
126
                                closeAllLists(inp);
127
                            };
128
                        },
129
                        error: function (data) {
130
                            console.log(data);
131
                        },
132
                    });
133
                }
134
135
            };
136
        }, doneTimerInterval);
137
    });
138
    /* get value for tag with name idx */
139
    function GetValueIdx(elem, nb){
140
        switch (elem[0].tagName){
141
            case 'INPUT':
142
                return elem[0].value;
143
            case 'SELECT':
144
                return select_idx[nb].options[select_idx[nb].selectedIndex].value;
145
            default:
146
                return null;
147
        };
148
    };
149
    /* get autocomplete for all prefix */
150
    function GetSetAutocomplete(data, prefix, key){
151
        let myset = new Set();
152
        var pref = prefix.split(",");
153
        for (k = 0; k < Object.keys(data).length; k++){ //Object.keys(data).length
154
            if (data[k] != '' && data[k] != null){
155
                let tmp_data = data[k]['hits']['hits'];
156
                for (i = 0; i < tmp_data.length; i++) {
157
                    for (j = 0; j < tmp_data[i]['highlight'][pref[k] + '.' + key].length; j++){
158
                        /* div with data for autocomplete */
159
                        let tmp = tmp_data[i]['highlight'][pref[k] + '.' + key][j]
160
                        myset.add(tmp.replace(/[ &#,+()$~%.'":*?<{}!/;]+$/g, ''));
161
                        if (myset.size >= nb_autocomplete) break;
162
                    };
163
                    if (myset.size >= nb_autocomplete) break;
164
                };
165
                if (myset.size >= nb_autocomplete) break;
166
            }
167
        }
168
        return myset;
169
    };
170
171
    /*execute a function presses a key on the keyboard:*/
172
    inp.addEventListener("keydown", function(e) {
173
        var x = document.getElementById(this.id + "autocomplete-list");
174
        if (x) x = x.getElementsByTagName("div");
175
        if (e.keyCode == 40) { //DOWN
176
            /*If the arrow DOWN key is pressed,
177
            increase the currentFocus variable:*/
178
            currentFocus++;
179
            /*and and make the current item more visible:*/
180
            addActive(x);
181
        } else if (e.keyCode == 38) { //up
182
            /*If the arrow UP key is pressed,
183
            decrease the currentFocus variable:*/
184
            currentFocus--;
185
            /*and and make the current item more visible:*/
186
            addActive(x);
187
            e.preventDefault();
188
        } else if (e.keyCode == 13) {
189
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
190
            //e.preventDefault();
191
            if (currentFocus > -1) {
192
                /*and simulate a click on the "active" item:*/
193
                if (x) x[currentFocus].click();
194
            }
195
        }
196
        /* press Esc clear all autocomplete */
197
        else if (e.keyCode == 27) {
198
            closeAllLists();
199
        }
200
        /* press Esc clear all autocomplete */
201
        else if (e.keyCode == 8) {
202
            closeAllLists();
203
        }
204
        /* press Tab clear all autocomplete */
205
        else if (e.keyCode == 9) {
206
            closeAllLists();
207
        };
208
    });
209
    function addActive(x) {
210
        /*a function to classify an item as "active":*/
211
        if (!x) return false;
212
        /*start by removing the "active" class on all items:*/
213
        removeActive(x);
214
        if (currentFocus >= x.length) currentFocus = 0;
215
        if (currentFocus < 0) currentFocus = (x.length - 1);
216
        /*add class "autocomplete-active":*/
217
        x[currentFocus].classList.add("autocomplete-active");
218
        inp.value = (x[currentFocus].textContent.replace(/<\/?[^>]+(>|$)/g, "")).trim();
219
    };
220
    function removeActive(x) {
221
        /*a function to remove the "active" class from all autocomplete items:*/
222
        for (var i = 0; i < x.length; i++) {
223
            x[i].classList.remove("autocomplete-active");
224
        };
225
    };
226
227
    function closeAllLists(elem) {
228
        /*close all autocomplete lists in the document with class autocomplete-items */
229
        var x = document.getElementsByClassName("autocomplete-items");
230
        for (var i = 0; i < x.length; i++) {
231
            x[i].parentNode.removeChild(x[i])
232
        };
233
    };
234
235
    /* div for one item autocomplete */
236
    function CreateDivItemAutocomplete (elem){
237
        var b = document.createElement("DIV");
238
        // add element ";
239
        b.innerHTML += elem;
240
        /*insert a input field that will hold the current array item's value:*/
241
        b.innerHTML += "<input type='hidden' value=''>";
242
        /*execute a function when someone clicks on the item value (DIV element):*/
243
        b.addEventListener("click", function(e) {
244
            /* insert the value for the autocomplete text field: */
245
            inp.value = this.getElementsByTagName("input")[0].value;
246
            /* normalyzer hightlight without tags */
247
            //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim();
248
            inp.value = this.innerText;
249
250
            var autocommit = 1;
251
            const inputs = document.querySelectorAll("#advanced-search input[type='text']");
252
            for (var i = 0; i < inputs.length && autocommit; i++) {
253
                var input = inputs[i];
254
                if (input === inp) {
255
                    autocommit = 0;
256
                }
257
            }
258
            //Submit form click mouse in div if not in advanced search
259
            if (autocommit) 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 26-31 Link Here
26
[% Asset.css("css/print.css", { media = "print" }) | $raw %]
26
[% Asset.css("css/print.css", { media = "print" }) | $raw %]
27
[% INCLUDE intranetstylesheet.inc %]
27
[% INCLUDE intranetstylesheet.inc %]
28
[% IF ( bidi ) %][% Asset.css("css/right-to-left.css") | $raw %][% END %]
28
[% IF ( bidi ) %][% Asset.css("css/right-to-left.css") | $raw %][% END %]
29
<!-- Intranet inc CSS IntranetAutocompleteElasticSearch -->
30
[% IF ( Koha.Preference('IntranetAutocompleteElasticSearch') ) %]
31
    [% SET Optylesheet = 'elasticsearch/autocomplete.css' %]
32
    <link rel="stylesheet" type="text/css" href="[% interface | url %]/[% theme | url %]/css/[% Optylesheet | url %]" />
33
[% END %]
29
34
30
<script>
35
<script>
31
var Koha = {};
36
var Koha = {};
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/js_includes.inc (+4 lines)
Lines 175-178 Link Here
175
    });
175
    });
176
    </script>
176
    </script>
177
[% END %]
177
[% END %]
178
<!-- Intranet inc JS IntranetAutocompleteElasticSearch -->
179
[% IF ( Koha.Preference('IntranetAutocompleteElasticSearch') ) %]
180
[% Asset.js("js/elasticsearch/autocomplete.js") | $raw %]
181
[% END %]
178
<!-- / js_includes.inc -->
182
<!-- / js_includes.inc -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/searching.pref (+17 lines)
Lines 298-300 Searching: Link Here
298
            - LIBRIS base URL
298
            - LIBRIS base URL
299
            - pref: LibrisURL
299
            - pref: LibrisURL
300
            - "Please only change this if you are sure it needs changing."
300
            - "Please only change this if you are sure it needs changing."
301
        -
302
            - pref: OPACAutocompleteElasticSearch
303
              type: boolean
304
              default: 0
305
              choices:
306
                  1: Show
307
                  0: "Don't show"
308
            - looking terms based on a provided text by using an ElasticSearch for OPAC.
309
        -
310
            - pref: IntranetAutocompleteElasticSearch
311
              type: boolean
312
              default: 0
313
              choices:
314
                  1: Show
315
                  0: "Don't show"
316
            - looking terms based on a provided text by using an ElasticSearch for Intranet.
317
        -
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/advsearch.tt (+4 lines)
Lines 382-387 Link Here
382
            var dad  = line.parentNode;
382
            var dad  = line.parentNode;
383
            dad.appendChild(line.cloneNode(true));
383
            dad.appendChild(line.cloneNode(true));
384
            line.removeChild(ButtonPlus);
384
            line.removeChild(ButtonPlus);
385
            /* Intranet JS IntranetAutocompleteElasticSearch */
386
            [% IF ( Koha.Preference('IntranetAutocompleteElasticSearch') ) %]
387
                AutocompleteInitIntranet();
388
            [% END %]
385
        }
389
        }
386
390
387
        var Sticky;
391
        var Sticky;
(-)a/koha-tmpl/intranet-tmpl/prog/js/staff-global.js (+7 lines)
Lines 89-94 $(document).ready(function() { Link Here
89
    $(".keep_text").on("click",function(){
89
    $(".keep_text").on("click",function(){
90
        var field_index = $(this).parent().index();
90
        var field_index = $(this).parent().index();
91
        keep_text( field_index );
91
        keep_text( field_index );
92
        /* IntranetAutocompleteElasticSearch Tab */
93
        var tab = this.hash.substr(1, this.hash.length-1);
94
        /*  Koha.Preference('IntranetAutocompleteElasticSearch') == Show */
95
        if (typeof AutocompleteInitIntranet !== "undefined" && tab === 'catalog_search' ){
96
            AutocompleteInitIntranet();
97
        }
98
        $("#search-form").focus();
92
    });
99
    });
93
100
94
    $(".toggle_element").on("click",function(e){
101
    $(".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 OPACAutocompleteElasticSearch */
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 OPACAutocompleteElasticSearch -->
27
[% IF ( Koha.Preference('OPACAutocompleteElasticSearch') ) %]
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 289-294 $(document).ready(function() { Link Here
289
});
289
});
290
</script>
290
</script>
291
[% PROCESS jsinclude %]
291
[% PROCESS jsinclude %]
292
<!-- OPAC *.inc JS OPACAutocompleteElasticSearch -->
293
[% IF ( Koha.Preference('OPACAutocompleteElasticSearch') ) %]
294
    [% Asset.js("js/opac-elasticsearch/opac-autocomplete.js") | $raw %]
295
[% END %]
292
[% IF ( Koha.Preference('OPACUserJS') ) %]
296
[% IF ( Koha.Preference('OPACUserJS') ) %]
293
    <script>
297
    <script>
294
        [% Koha.Preference('OPACUserJS') | $raw %]
298
        [% Koha.Preference('OPACUserJS') | $raw %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-advsearch.tt (+8 lines)
Lines 538-543 $(document).ready(function() { Link Here
538
        $(newLine).find('.search-term-input select[name="op"]').first().prop("disabled",false).show();
538
        $(newLine).find('.search-term-input select[name="op"]').first().prop("disabled",false).show();
539
        newLine.find('input').val('');
539
        newLine.find('input').val('');
540
        thisLine.after(newLine);
540
        thisLine.after(newLine);
541
        /* OPAC JS OPACAutocompleteElasticSearch */
542
        [% IF ( Koha.Preference('OPACAutocompleteElasticSearch') ) %]
543
            AutocompleteInitOpac();
544
        [% END %]
541
    });
545
    });
542
546
543
    $(document).on("click", '.ButtonLess', function(e) {
547
    $(document).on("click", '.ButtonLess', function(e) {
Lines 547-552 $(document).ready(function() { Link Here
547
        }
551
        }
548
        $(this).parent().parent().remove();
552
        $(this).parent().parent().remove();
549
        $('.search-term-row .search-term-input select[name="op"]').first().prop("disabled",true).hide();
553
        $('.search-term-row .search-term-input select[name="op"]').first().prop("disabled",true).hide();
554
        /* OPAC JS OPACAutocompleteElasticSearch */
555
        [% IF ( Koha.Preference('OPACAutocompleteElasticSearch') ) %]
556
            AutocompleteInitOpac();
557
        [% END %]
550
    });
558
    });
551
559
552
</script>
560
</script>
(-)a/koha-tmpl/opac-tmpl/bootstrap/js/opac-elasticsearch/opac-autocomplete.js (+266 lines)
Line 0 Link Here
1
/* OPAC JS file OPACAutocompleteElasticSearch */
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-elasticsearch.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).css("display", "inline-block");
55
    div_relative.append(inp);
56
    /* input doesn't have an elem after, add it to parent */
57
    if (next_elem_inp === null){
58
        parent_inp.append( div_relative );
59
    }  else {  // input has an elem after, add elem after it
60
        next_elem_inp.before(div_relative);
61
    };
62
    var currentFocus;
63
64
    var timer;
65
    var doneTimerInterval = 500;
66
67
    /*execute a function when someone writes in the text field:*/
68
    var token_counter = 0;
69
    inp.addEventListener("input", function(e) {
70
        clearTimeout(timer);
71
        timer = setTimeout( function() {
72
            var a, val = inp.value;
73
            /* var for async compare */
74
            var tmp_input = inp.value.replace(/[+']/g, function(matched){
75
                return query_url_decode[matched];
76
            });
77
            token_counter++;
78
            currentFocus = -1;
79
            if (document.getElementsByClassName("autocomplete-items").length !== 0){
80
                a = document.getElementsByClassName("autocomplete-items")[0];
81
            } else {
82
                /*create a DIV element that will contain the items (values):*/
83
                a = document.createElement("DIV");
84
                a.setAttribute("id", inp.id + "autocomplete-list");
85
                a.setAttribute("class", "autocomplete-items");
86
                /*append the DIV element as a child of the autocomplete container:*/
87
                inp.parentNode.appendChild(a);
88
                /*append position absolute left/right:*/
89
                $(".autocomplete-items").css("left",left);
90
                $(".autocomplete-items").css("right",right);
91
            };
92
            /* get es_prefix key for builder */
93
            var chose_prefix = (select_idx == null || select_idx.length == 0) ? '' : GetValueIdx(select_idx, nb);
94
            chose_prefix = chose_prefix.replace(/([^,])[,-]([^,].*)?$/, '$1');
95
            if (chose_prefix !== null){
96
                var prefix = es_prefix[chose_prefix].toString();
97
                val = val.replace(/[+']/g, function(matched){
98
                    return query_url_encode[matched];
99
                });
100
                if (tmp_input == '' || tmp_input == null){
101
                    closeAllLists();
102
                    token_counter = 0;
103
                } else {
104
                    $.ajax({
105
                        type: 'GET',
106
                        url: url_request + val + '&key=' + key + '&prefix=' + prefix + '&token_counter=' + token_counter,
107
                        contentType: "application/json;charset=UTF-8",
108
                        success: function (data) {
109
                            //console.log(data);
110
                            if (data.length !== 0){
111
                                var myset; //Set for Autocomplete unique
112
113
                                myset = GetSetAutocomplete(data, prefix, key);
114
                                /* append set to autocomplete */
115
                                if ( tmp_input + prefix == data['val'] + data['prefix'] && token_counter === parseInt(data['token_counter'], 10)){
116
                                    a.innerHTML = "";
117
                                    for (let item of myset){
118
                                        a.appendChild(CreateDivItemAutocomplete(item, val));
119
                                    };
120
                                };
121
                            } else {
122
                                closeAllLists(inp);
123
                            };
124
                        },
125
                        error: function (data) {
126
                            console.log(data);
127
                        },
128
                    });
129
                }
130
131
            };
132
        }, doneTimerInterval);
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 all prefix */
146
    function GetSetAutocomplete(data, prefix, key){
147
        let myset = new Set();
148
        var pref = prefix.split(",");
149
        for (k = 0; k < Object.keys(data).length; k++){ //Object.keys(data).length
150
            if (data[k] != '' && data[k] != null){
151
                let tmp_data = data[k]['hits']['hits'];
152
                for (i = 0; i < tmp_data.length; i++) {
153
                    for (j = 0; j < tmp_data[i]['highlight'][pref[k] + '.' + key].length; j++){
154
                        /* div with data for autocomplete */
155
                        let tmp = tmp_data[i]['highlight'][pref[k] + '.' + key][j]
156
                        myset.add(tmp.replace(/[ &#,+()$~%.'":*?<{}!/;]+$/g, ''));
157
                        if (myset.size >= nb_autocomplete) break;
158
                    };
159
                    if (myset.size >= nb_autocomplete) break;
160
                };
161
                if (myset.size >= nb_autocomplete) break;
162
            }
163
        }
164
        return myset;
165
    };
166
167
    /*execute a function presses a key on the keyboard:*/
168
    inp.addEventListener("keydown", function(e) {
169
        var x = document.getElementById(this.id + "autocomplete-list");
170
        if (x) x = x.getElementsByTagName("div");
171
        if (e.keyCode == 40) { //DOWN
172
            /*If the arrow DOWN key is pressed,
173
            increase the currentFocus variable:*/
174
            currentFocus++;
175
            /*and and make the current item more visible:*/
176
            addActive(x);
177
        } else if (e.keyCode == 38) { //up
178
            /*If the arrow UP key is pressed,
179
            decrease the currentFocus variable:*/
180
            currentFocus--;
181
            /*and and make the current item more visible:*/
182
            addActive(x);
183
            e.preventDefault();
184
        } else if (e.keyCode == 13) {
185
            /*If the ENTER key is pressed, prevent the form from being submitted,*/
186
            //e.preventDefault();
187
            if (currentFocus > -1) {
188
                /*and simulate a click on the "active" item:*/
189
                if (x) x[currentFocus].click();
190
            }
191
        }
192
        /* press Esc clear all autocomplete */
193
        else if (e.keyCode == 27) {
194
            closeAllLists();
195
        }
196
        /* press Esc clear all autocomplete */
197
        else if (e.keyCode == 8) {
198
            closeAllLists();
199
        }
200
        /* press Tab clear all autocomplete */
201
        else if (e.keyCode == 9) {
202
            closeAllLists();
203
        };
204
    });
205
    function addActive(x) {
206
        /*a function to classify an item as "active":*/
207
        if (!x) return false;
208
        /*start by removing the "active" class on all items:*/
209
        removeActive(x);
210
        if (currentFocus >= x.length) currentFocus = 0;
211
        if (currentFocus < 0) currentFocus = (x.length - 1);
212
        /*add class "autocomplete-active":*/
213
        x[currentFocus].classList.add("autocomplete-active");
214
        inp.value = (x[currentFocus].textContent.replace(/<\/?[^>]+(>|$)/g, "")).trim();
215
    };
216
    function removeActive(x) {
217
        /*a function to remove the "active" class from all autocomplete items:*/
218
        for (var i = 0; i < x.length; i++) {
219
            x[i].classList.remove("autocomplete-active");
220
        };
221
    };
222
223
    function closeAllLists(elem) {
224
        /*close all autocomplete lists in the document with class autocomplete-items */
225
        var x = document.getElementsByClassName("autocomplete-items");
226
        for (var i = 0; i < x.length; i++) {
227
            x[i].parentNode.removeChild(x[i])
228
        };
229
    };
230
231
    /* div for one item autocomplete */
232
    function CreateDivItemAutocomplete (elem){
233
        var b = document.createElement("DIV");
234
        // add element ";
235
        b.innerHTML += elem;
236
        /*insert a input field that will hold the current array item's value:*/
237
        b.innerHTML += "<input type='hidden' value=''>";
238
        /*execute a function when someone clicks on the item value (DIV element):*/
239
        b.addEventListener("click", function(e) {
240
            /* insert the value for the autocomplete text field: */
241
            inp.value = this.getElementsByTagName("input")[0].value;
242
            /* normalyzer hightlight without tags */
243
            //inp.value = (inp.value.replace(/<\/?[^>]+(>|$)/g, "")).trim();
244
            inp.value = this.innerText;
245
246
            var autocommit = 1;
247
            const inputs = document.querySelectorAll("#booleansearch input[type='text']");
248
            for (var i = 0; i < inputs.length && autocommit; i++) {
249
                var input = inputs[i];
250
                if (input === inp) {
251
                    autocommit = 0;
252
                }
253
            }
254
            //Submit form click mouse in div if not in advanced search
255
            if (autocommit) this.closest("form").submit();
256
        });
257
        return b;
258
    };
259
260
    /*execute a function when someone clicks in the document:*/
261
    document.addEventListener("click", function (e) {
262
        closeAllLists(e.target);
263
    });
264
};
265
266
AutocompleteInitOpac();
(-)a/opac/svc/elasticsearch/opac-elasticsearch.pl (+146 lines)
Line 0 Link Here
1
#!/usr/bin/perl
2
3
use strict;
4
use warnings;
5
use CGI qw ( -utf8 );
6
use JSON;
7
use utf8;
8
use Unicode::Normalize;
9
use CGI::Session;
10
use Koha::SearchEngine::Elasticsearch::Browse;
11
12
use Koha::Items;
13
use C4::Context;
14
use C4::Biblio qw ( GetMarcBiblio );
15
16
my $browser = Koha::SearchEngine::Elasticsearch::Browse->new( { index => 'biblios' } );
17
my $cgi = CGI->new;
18
my $session = CGI::Session->load() or die CGI::Session->errstr();
19
20
$session->param(-name=>'analyzer', -value=>"autocomplete");
21
$session->param(-name=>'prefix', -value=>$cgi->multi_param("prefix"));
22
$session->param(-name=>'q', -value=>$cgi->multi_param("q"));
23
$session->param(-name=>'key', -value=>$cgi->multi_param("key"));
24
$session->param(-name=>'token_counter', -value=>$cgi->multi_param("token_counter"));
25
$session->expire('+1h');
26
27
if ($session->param("key") eq "autocomplete") {
28
  my @prefix = split /,/, $session->param("prefix");
29
  #fields for autocomplete
30
  my $length = scalar @prefix;
31
  my $ses = NFKD( $session->param("q") );
32
  $ses =~ s/\p{NonspacingMark}//g;
33
34
  if ($length >= 1) {
35
    my $res = $browser->autocomplete_idx($ses, \@prefix, $session->param("analyzer"), $session->param("token_counter"));
36
37
    filterAutocomplete($res);
38
39
    print $cgi->header("application/json;charset=UTF-8");
40
    print to_json($res, {utf8 => 1});
41
  }
42
  #no prefix 404
43
  else {
44
    response404JSON();
45
  }
46
} else {
47
  response404JSON();
48
}
49
50
sub filterAutocomplete {
51
  if (C4::Context->preference('OpacSuppression') || C4::Context->yaml_preference('OpacHiddenItems')) {
52
    my $res = shift;
53
    my @prefix = $res->{ "prefix" };
54
    @prefix = split(',', $prefix[0]);
55
56
    for (my $i = 0; $i < scalar @prefix; $i++) {
57
      my $hits = $res->{ $i }->{ 'hits' };
58
      my $hitlist = $hits->{ "hits" };
59
      if (@{$hitlist}) {
60
        # Remove item inside hits in elasticsearch response if the item has
61
        # marc field 942$n set to true and OpacSuppression preference on
62
        if (C4::Context->preference('OpacSuppression')) {
63
          for ( my $i = 0; $i < scalar @{$hitlist}; $i++ ) {
64
            my $record = GetMarcBiblio({
65
              biblionumber => $hitlist->[$i]->{ "_id" },
66
              opac         => 1
67
            });
68
            my $opacsuppressionfield = '942';
69
            my $opacsuppressionfieldvalue = $record->field($opacsuppressionfield);
70
            if ( $opacsuppressionfieldvalue &&
71
                $opacsuppressionfieldvalue->subfield("n") &&
72
                $opacsuppressionfieldvalue->subfield("n") == 1) {
73
              # if OPAC suppression by IP address
74
              if (C4::Context->preference('OpacSuppressionByIPRange')) {
75
                my $IPAddress = $ENV{'REMOTE_ADDR'};
76
                my $IPRange = C4::Context->preference('OpacSuppressionByIPRange');
77
                if ($IPAddress !~ /^$IPRange/)  {
78
                    splice(@{$hitlist}, $i, 1);
79
                    $i--;
80
                    $hits->{ "total" }--;
81
                }
82
              } else {
83
                splice(@{$hitlist}, $i, 1);
84
                $i--;
85
                $hits->{ "total" }--;
86
              }
87
            }
88
          }
89
        }
90
        # Remove item inside hits in elasticsearch response if the item is
91
        # declared hidden in OPACHiddenItems preference
92
        if (C4::Context->yaml_preference('OpacHiddenItems')) {
93
          my @biblionumbers;
94
          foreach (@{$hitlist}) {
95
            push(@biblionumbers, $_->{ "_id" });
96
          }
97
          my $autocomplete_items = Koha::Items->search({
98
            biblionumber => { -in => \@biblionumbers }
99
          });
100
          my $filtered_items = $autocomplete_items->filter_by_visible_in_opac({
101
            patron => undef
102
          });
103
          for ( my $i = 0; $i < scalar @{$hitlist}; $i++ ) {
104
            my $item = $filtered_items->find({
105
              biblionumber => $hitlist->[$i]->{ "_id" }
106
            });
107
            if (!$item) {
108
              splice(@{$hitlist}, $i, 1);
109
              $i--;
110
              $hits->{ "total" }--;
111
            }
112
          }
113
        }
114
        # Adjust the max_score inside hits in elasticsearch response
115
        my $maxscore = 0;
116
        foreach ( @{$hitlist} ) {
117
          my $score = $_->{"_score"};
118
          $maxscore = $score if ($maxscore < $score);
119
        }
120
        if ($maxscore == 0) {
121
          $hits->{ "max_score" } = undef;
122
        } else {
123
          $hits->{ "max_score" } = $maxscore;
124
        }
125
      }
126
    }
127
  }
128
}
129
130
sub response404JSON {
131
  my $res = CGI->new;
132
  my $json = JSON->new->utf8;
133
  my $header_type = "application/json;charset=UTF-8";
134
  my $header_status = "404";
135
  my $output = $json->encode({
136
    "error" => "No data",
137
    "description" => "Bad request",
138
  });
139
  print $res->header(
140
    -type => $header_type,
141
    -charset => "utf-8",
142
    -status => $header_status
143
  );
144
  print $output;
145
  print "\n";
146
}
(-)a/t/Koha_SearchEngine_Elasticsearch_Browse.t (-1 / +30 lines)
Lines 65-68 subtest "_build_query tests" => sub { Link Here
65
    }, 'Fuzziness and size specified');
65
    }, 'Fuzziness and size specified');
66
};
66
};
67
67
68
subtest "_build_query_autocomplete tests" => sub {
69
    plan tests => 1;
70
71
    my $browse = Koha::SearchEngine::Elasticsearch::Browse->new({index=>'dummy'});
72
73
    my $q = $browse->_build_query_autocomplete('a', 'title', 'autocomplete');
74
75
    is_deeply($q, {
76
        _source    => ["title"],
77
        query => {
78
            match => {
79
                "title.autocomplete"  => {
80
                    query => 'a',
81
                    operator => 'and'
82
                }
83
            }
84
        },
85
        highlight => {
86
            number_of_fragments => 1,
87
            fragment_size => 100,
88
            pre_tags => ["<strong>"],
89
            post_tags => ["</strong>"],
90
            fields => {
91
                "title.autocomplete" => {}
92
            }
93
        }
94
    }, 'Autocomplete for title is specified');
95
96
};
97
68
done_testing();
98
done_testing();
69
- 

Return to bug 27113