OSDN Git Service

BugTrack/692 Show page contents in search result - search2 plugin
[pukiwiki/pukiwiki.git] / skin / search2.js
1 // PukiWiki - Yet another WikiWikiWeb clone.
2 // search2.js
3 // Copyright
4 //   2017 PukiWiki Development Team
5 // License: GPL v2 or (at your option) any later version
6 //
7 // PukiWiki search2 pluign - JavaScript client script
8 window.addEventListener && window.addEventListener('DOMContentLoaded', function() {
9   function enableSearch2() {
10     var aroundLines = 2;
11     var maxResultLines = 20;
12     var minBlockLines = 5;
13     var minSearchWaitMilliseconds = 100;
14     var kanaMap = null;
15     function escapeHTML (s) {
16       if(typeof s !== 'string') {
17         s = '' + s;
18       }
19       return s.replace(/[&"<>]/g, function(m) {
20         return {
21           '&': '&amp;',
22           '"': '&quot;',
23           '<': '&lt;',
24           '>': '&gt;'
25         }[m];
26       });
27     }
28     function doSearch(searchText, session, startIndex) {
29       var url = './?cmd=search2&action=query';
30       var props = getSiteProps();
31       url += '&encode_hint=' + encodeURIComponent('\u3077');
32       if (searchText) {
33         url += '&q=' + encodeURIComponent(searchText);
34       }
35       if (session.base) {
36         url += '&base=' + encodeURIComponent(session.base);
37       }
38       url += '&start=' + startIndex;
39       fetch (url
40       ).then(function(response){
41         if (response.ok) {
42           return response.json();
43         } else {
44           throw new Error(response.status + ': ' +
45             + response.statusText + ' on ' + url);
46         }
47       }).then(function(obj) {
48         showResult(obj, session, searchText);
49       })['catch'](function(err){
50         console.log(err);
51         console.log('Error! Please check JavaScript console' + '\n' + JSON.stringify(err) + '|' + err);
52       });
53     }
54     function getMessageTemplate(idText, defaultText) {
55       var messageHolder = document.querySelector('#' + idText);
56       var messageTemplate = (messageHolder && messageHolder.value) || defaultText;
57       return messageTemplate;
58     }
59     function getAuthorInfo(text) {
60
61     }
62     function getPassage(now, dateText) {
63       if (! dateText) {
64         return '';
65       }
66       var units = [{u: 'm', max: 60}, {u: 'h', max: 24}, {u: 'd', max: 1}];
67       var d = new Date();
68       d.setTime(Date.parse(dateText));
69       var t = (now.getTime() - d.getTime()) / (1000 * 60); // minutes
70       var unit = units[0].u, card = units[0].max;
71       for (var i = 0; i < units.length; i++) {
72         unit = units[i].u, card = units[i].max;
73         if (t < card) break;
74         t = t / card;
75       }
76       return '(' + Math.floor(t) + unit + ')';
77     }
78     function removeSearchOperators(searchText) {
79       var sp = searchText.split(/\s+/);
80       if (sp.length <= 1) {
81         return searchText;
82       }
83       var hasOr = false;
84       for (var i = sp.length - 1; i >= 0; i--) {
85         if (sp[i] === 'OR') {
86           hasOr = true;
87           sp.splice(i, 1);
88         }
89       }
90       return sp.join(' ');
91     }
92     function showResult(obj, session, searchText) {
93       var searchRegex = textToRegex(removeSearchOperators(searchText));
94       var ul = document.querySelector('#result-list');
95       if (!ul) return;
96       ul.innerHTML = '';
97       if (! session.scan_page_count) session.scan_page_count = 0;
98       if (! session.read_page_count) session.read_page_count = 0;
99       if (! session.hit_page_count) session.hit_page_count = 0;
100       session.scan_page_count += obj.scan_page_count;
101       session.read_page_count += obj.read_page_count;
102       session.hit_page_count += obj.results.length;
103       session.page_count = obj.page_count;
104
105       var msg = obj.message;
106       var notFoundMessageTemplate = getMessageTemplate('_plugin_search2_msg_result_notfound',
107         'No page which contains $1 has been found.');
108       var foundMessageTemplate = getMessageTemplate('_plugin_search2_msg_result_found',
109         'In the page <strong>$2</strong>, <strong>$3</strong> pages that contain all the terms $1 were found.');
110       var searchTextDecorated = findAndDecorateText(searchText, searchRegex);
111       if (searchTextDecorated === null) searchTextDecorated = escapeHTML(searchText);
112       var messageTemplate = foundMessageTemplate;
113       if (session.hit_page_count === 0) {
114         messageTemplate = notFoundMessageTemplate;
115       }
116       msg = messageTemplate.replace(/\$1|\$2|\$3/g, function(m){
117         return {
118           '$1': searchTextDecorated,
119           '$2': session.hit_page_count,
120           '$3': session.read_page_count
121         }[m];
122       });
123       document.querySelector('#_plugin_search2_message').innerHTML = msg;
124
125       setSearchStatus('');
126       var results = obj.results;
127       var now = new Date();
128       results.forEach(function(val, index) {
129         var fragment = document.createDocumentFragment();
130         var li = document.createElement('li');
131         var hash = '#q=' + encodeSearchTextForHash(searchText);
132         var href = val.url + hash;
133         var decoratedName = findAndDecorateText(val.name, searchRegex);
134         if (! decoratedName) {
135           decoratedName = escapeHTML(val.name);
136         }
137         var author = getAuthorHeader(val.body);
138         var updatedAt = '';
139         if (author) {
140           updatedAt = getUpdateTimeFromAuthorInfo(author);
141         } else {
142           updatedAt = val.updated_at;
143         }
144         var liHtml = '<a href="' + escapeHTML(href) + '">' + decoratedName + '</a> ' +
145           getPassage(now, updatedAt);
146         li.innerHTML = liHtml;
147         var a = li.querySelector('a');
148         if (a && a.hash) {
149           if (a.hash !== hash) {
150             // Some browser execute encodeHTML(hash) automatically. Support them.
151             a.href = val.url + '#encq=' + encodeSearchTextForHash(searchText);
152           }
153         }
154         fragment.appendChild(li);
155         var div = document.createElement('div');
156         div.classList.add('search-result-detail');
157         var head = document.createElement('div');
158         head.classList.add('search-result-page-summary');
159         head.innerHTML = escapeHTML(getBodySummary(val.body));
160         div.appendChild(head);
161         var summary = getSummary(val.body, searchRegex);
162         for (var i = 0; i < summary.length; i++) {
163           var pre = document.createElement('pre');
164           pre.innerHTML = summary[i].lines.join('\n');
165           div.appendChild(pre);
166         }
167         fragment.appendChild(div);
168         ul.appendChild(fragment);
169       });
170     }
171     function prepareKanaMap() {
172       if (kanaMap !== null) return;
173       if (!String.prototype.normalize) {
174         kanaMap = {};
175         return;
176       }
177       var dakuten = '\uFF9E';
178       var maru = '\uFF9F';
179       var map = {};
180       for (var c = 0xFF61; c <=0xFF9F; c++) {
181         var han = String.fromCharCode(c);
182         var zen = han.normalize('NFKC');
183         map[zen] = han;
184         var hanDaku = han + dakuten;
185         var zenDaku = hanDaku.normalize('NFKC');
186         if (zenDaku.length === 1) { // +Handaku-ten OK
187             map[zenDaku] = hanDaku;
188         }
189         var hanMaru = han + maru;
190         var zenMaru = hanMaru.normalize('NFKC');
191         if (zenMaru.length === 1) { // +Maru OK
192             map[zenMaru] = hanMaru;
193         }
194       }
195       kanaMap = map;
196     }
197     function textToRegex(searchText) {
198       if (!searchText) return null;
199       var regEscape = /[\\^$.*+?()[\]{}|]/g;
200       //             1:Symbol             2:Katakana        3:Hiragana
201       var regRep = /([\\^$.*+?()[\]{}|])|([\u30a1-\u30f6])|([\u3041-\u3096])/g;
202       var s1 = searchText.replace(/^\s+|\s+$/g, '');
203       var sp = s1.split(/\s+/);
204       var rText = '';
205       prepareKanaMap();
206       for (var i = 0; i < sp.length; i++) {
207         if (rText !== '') {
208           rText += '|'
209         }
210         var s = sp[i];
211         if (s.normalize) {
212           s = s.normalize('NFKC');
213         }
214         var s2 = s.replace(regRep, function(m, m1, m2, m3){
215           if (m1) {
216             // Symbol - escape with prior backslach
217             return '\\' + m1;
218           } else if (m2) {
219             // Katakana
220             var r = '(?:' + String.fromCharCode(m2.charCodeAt(0) - 0x60) +
221               '|' + m2;
222             if (kanaMap[m2]) {
223               r += '|' + kanaMap[m2];
224             }
225             r += ')';
226             return r;
227           } else if (m3) {
228             // Hiragana
229             var katakana = String.fromCharCode(m3.charCodeAt(0) + 0x60);
230             var r = '(?:' + m3 + '|' + katakana;
231             if (kanaMap[katakana]) {
232               r += '|' + kanaMap[katakana];
233             }
234             r += ')';
235             return r;
236           }
237           return m;
238         });
239         rText += '(' + s2 + ')';
240       }
241       return new RegExp(rText, 'ig');
242     }
243     function getAuthorHeader(body) {
244       var start = 0;
245       var pos;
246       while ((pos = body.indexOf('\n', start)) >= 0) {
247         var line = body.substring(start, pos);
248         if (line.match(/^#author\(/, line)) {
249           return line;
250         } else if (line.match(/^#freeze(\W|$)/, line)) {
251           // Found #freeze still in header
252         } else {
253           // other line, #author not found
254           return null;
255         }
256         start = pos + 1;
257       }
258       return null;
259     }
260     function getUpdateTimeFromAuthorInfo(authorInfo) {
261       var m = authorInfo.match(/^#author\("([^;"]+)(;[^;"]+)?/);
262       if (m) {
263         return m[1];
264       }
265       return '';
266     }
267     function getTargetLines(body, searchRegex) {
268       var lines = body.split('\n');
269       var found = [];
270       var foundLines = [];
271       var isInAuthorHeader = true;
272       var lastFoundLineIndex = -1 - aroundLines;
273       var lastAddedLineIndex = lastFoundLineIndex;
274       var blocks = [];
275       var lineCount = 0;
276       for (var index = 0, length = lines.length; index < length; index++) {
277         var line = lines[index];
278         if (isInAuthorHeader) {
279           // '#author line is not search target'
280           if (line.match(/^#author\(/)) {
281             // Remove this line from search target
282             continue;
283           } else if (line.match(/^#freeze(\W|$)/)) {
284             // Still in header
285           } else {
286             // Already in body
287             isInAuthorHeader = false;
288           }
289         }
290         var decorated = findAndDecorateText(line, searchRegex);
291         if (decorated === null) {
292           if (index < lastFoundLineIndex + aroundLines + 1) {
293             foundLines.push('' + (index + 1) + ':\t' + escapeHTML(lines[index]));
294             lineCount++;
295             lastAddedLineIndex = index;
296           }
297         } else {
298           var startIndex = Math.max(Math.max(lastAddedLineIndex + 1, index - aroundLines), 0);
299           if (lastAddedLineIndex + 1 < startIndex) {
300             // Newly found!
301             var block = {
302               startIndex: startIndex,
303               foundLineIndex: index,
304               lines: []
305             };
306             foundLines = block.lines;
307             blocks.push(block);
308           }
309           if (lineCount >= maxResultLines) {
310             foundLines.push('...');
311             return blocks;
312           }
313           for (var i = startIndex; i < index; i++) {
314             foundLines.push('' + (i + 1) + ':\t' + escapeHTML(lines[i]));
315             lineCount++;
316           }
317           foundLines.push('' + (index + 1) + ':\t' + decorated);
318           lineCount++;
319           lastFoundLineIndex = lastAddedLineIndex = index;
320         }
321       }
322       return blocks;
323     }
324     function getSummary(bodyText, searchRegex) {
325       return getTargetLines(bodyText, searchRegex);
326     }
327     function hookSearch2(e) {
328       var form = document.querySelector('form');
329       if (form && form.q) {
330         var q = form.q;
331         if (q.value === '') {
332           q.focus();
333         }
334       }
335     }
336     function getBodySummary(body) {
337       var lines = body.split('\n');
338       var isInAuthorHeader = true;
339       var summary = [];
340       var lineCount = 0;
341       for (var index = 0, length = lines.length; index < length; index++) {
342         var line = lines[index];
343         if (isInAuthorHeader) {
344           // '#author line is not search target'
345           if (line.match(/^#author\(/)) {
346             // Remove this line from search target
347             continue;
348           } else if (line.match(/^#freeze(\W|$)/)) {
349             continue;
350             // Still in header
351           } else {
352             // Already in body
353             isInAuthorHeader = false;
354           }
355         }
356         line = line.replace(/^\s+|\s+$/g, '');
357         if (line.length === 0) continue; // Empty line
358         if (line.match(/^#\w+/)) continue; // Block-type plugin
359         if (line.match(/^\/\//)) continue; // Comment
360         if (line.substr(0, 1) === '*') {
361           line = line.replace(/\s*\[\#\w+\]$/, ''); // Remove anchor
362         }
363         summary.push(line);
364         if (summary.length >= 10) {
365           continue;
366         }
367       }
368       return summary.join(' ').substring(0, 150);
369     }
370     function removeEncodeHint() {
371       // Remove 'encode_hint' if site charset is UTF-8
372       var props = getSiteProps();
373       if (!props.is_utf8) return;
374       var forms = document.querySelectorAll('form');
375       forEach(forms, function(form){
376         if (form.cmd && form.cmd.value === 'search2') {
377           if (form.encode_hint && (typeof form.encode_hint.removeAttribute === 'function')) {
378             form.encode_hint.removeAttribute('name');
379           }
380         }
381       });
382     }
383     function kickFirstSearch() {
384       var form = document.querySelector('._plugin_search2_form');
385       var searchText = form && form.q;
386       if (!searchText) return;
387       if (searchText && searchText.value) {
388         var e = document.querySelector('#_plugin_search2_msg_searching');
389         var msg = e && e.value || 'Searching...';
390         setSearchStatus(msg);
391         var base = '';
392         forEach(form.querySelectorAll('input[name="base"]'), function(radio){
393           if (radio.checked) base = radio.value;
394         });
395         doSearch(searchText.value, {base: base}, 0);
396       }
397     }
398     function setSearchStatus(statusText) {
399       var statusObj = document.querySelector('#_plugin_search2_search_status');
400       if (statusObj) {
401         statusObj.textContent = statusText;
402       }
403     }
404     function forEach(nodeList, func) {
405       if (nodeList.forEach) {
406         nodeList.forEach(func);
407       } else {
408         for (var i = 0, n = nodeList.length; i < n; i++) {
409           func(nodeList[i], i);
410         }
411       }
412     }
413     function replaceSearchWithSearch2() {
414       forEach(document.querySelectorAll('form'), function(f){
415         if (f.action.match(/cmd=search$/)) {
416           f.addEventListener('submit', function(e) {
417             var q = e.target.word.value;
418             var base = '';
419             forEach(f.querySelectorAll('input[name="base"]'), function(radio){
420               if (radio.checked) base = radio.value;
421             });
422             var props = getSiteProps();
423             var loc = document.location;
424             var baseUri = loc.protocol + '//' + loc.host + loc.pathname;
425             if (props.base_uri_pathname) {
426               baseUri = props.base_uri_pathname;
427             }
428             var url = baseUri + '?' +
429               (props.is_utf8 ? '' : 'encode_hint=' +
430                 encodeURIComponent('\u3077') + '&') +
431               'cmd=search2' +
432               '&q=' + encodeSearchText(q) +
433               (base ? '&base=' + encodeURIComponent(base) : '');
434             e.preventDefault();
435             setTimeout(function() {
436               location.href = url;
437             }, 1);
438             return false;
439           });
440           var radios = f.querySelectorAll('input[type="radio"][name="type"]');
441           forEach(radios, function(radio){
442             if (radio.value === 'AND') {
443               radio.addEventListener('click', onAndRadioClick);
444             } else if (radio.value === 'OR') {
445               radio.addEventListener('click', onOrRadioClick);
446             }
447           });
448           function onAndRadioClick(e) {
449             var sp = removeSearchOperators(f.word.value).split(/\s+/);
450             var newText = sp.join(' ');
451             if (f.word.value !== newText) {
452               f.word.value = newText;
453             }
454           }
455           function onOrRadioClick(e) {
456             var sp = removeSearchOperators(f.word.value).split(/\s+/);
457             var newText = sp.join(' OR ');
458             if (f.word.value !== newText) {
459               f.word.value = newText;
460             }
461           }
462         }
463       });
464     }
465     function encodeSearchText(q) {
466       var sp = q.split(/\s+/);
467       for (var i = 0; i < sp.length; i++) {
468         sp[i] = encodeURIComponent(sp[i]);
469       }
470       return sp.join('+');
471     }
472     function encodeSearchTextForHash(q) {
473       var sp = q.split(/\s+/);
474       return sp.join('+');
475     }
476     function findAndDecorateText(text, searchRegex) {
477       var isReplaced = false;
478       var lastIndex = 0;
479       var m;
480       var decorated = '';
481       searchRegex.lastIndex = 0;
482       while ((m = searchRegex.exec(text)) !== null) {
483         isReplaced = true;
484         var pre = text.substring(lastIndex, m.index);
485         decorated += escapeHTML(pre);
486         for (var i = 1; i < m.length; i++) {
487           if (m[i]) {
488             decorated += '<strong class="word' + (i - 1) + '">' + escapeHTML(m[i]) + '</strong>'
489           }
490         }
491         lastIndex = searchRegex.lastIndex;
492       }
493       if (isReplaced) {
494         decorated += escapeHTML(text.substr(lastIndex));
495         return decorated;
496       }
497       return null;
498     }
499     function getSearchTextInLocationHash() {
500       // TODO Cross browser
501       var hash = location.hash;
502       if (!hash) return '';
503       var q = '';
504       if (hash.substr(0, 3) === '#q=') {
505         q = hash.substr(3).replace(/\+/g, ' ');
506       } else if (hash.substr(0, 6) === '#encq=') {
507         q = decodeURIComponent(hash.substr(6).replace(/\+/g, ' '));
508       }
509       return q;
510     }
511     function colorSearchTextInBody() {
512       var searchText = getSearchTextInLocationHash();
513       if (!searchText) return;
514       var searchRegex = textToRegex(removeSearchOperators(searchText));
515       var headReText = '([\\s\\b]|^)';
516       var tailReText = '\\b';
517       var ignoreTags = ['INPUT', 'TEXTAREA', 'BUTTON',
518         'SCRIPT', 'FRAME', 'IFRAME'];
519       function colorSearchText(element, searchRegex) {
520         var decorated = findAndDecorateText(element.nodeValue, searchRegex);
521         if (decorated) {
522           var span = document.createElement('span');
523           span.innerHTML = decorated;
524           element.parentNode.replaceChild(span, element);
525         }
526       }
527       function walkElement(element) {
528         var e = element.firstChild;
529         while (e) {
530           if (e.nodeType == 3 && e.nodeValue &&
531               e.nodeValue.length >= 2 && /\S/.test(e.nodeValue)) {
532             var next = e.nextSibling;
533             colorSearchText(e, searchRegex);
534             e = next;
535           } else {
536             if (e.nodeType == 1 && ignoreTags.indexOf(e.tagName) == -1) {
537               walkElement(e);
538             }
539             e = e.nextSibling;
540           }
541         }
542       }
543       var target = document.getElementById('body');
544       walkElement(target);
545     }
546     function showNoSupportMessage() {
547       var pList = document.getElementsByClassName('_plugin_search2_nosupport_message');
548       for (var i = 0; i < pList.length; i++) {
549         var p = pList[i];
550         p.style.display = 'block';
551       }
552     }
553     function isEnabledFetchFunctions() {
554       if (window.fetch && document.querySelector && window.JSON) {
555         return true;
556       }
557       return false;
558     }
559     function isEnableServerFunctions() {
560       var props = getSiteProps();
561       if (props.json_enabled) return true;
562       return false;
563     }
564     function getSiteProps() {
565       var empty = {};
566       var propsDiv = document.getElementById('pukiwiki-site-properties');
567       if (!propsDiv) return empty;
568       var jsonE = propsDiv.querySelector('div[data-key="site-props"]');
569       if (!jsonE) return emptry;
570       var props = JSON.parse(jsonE.getAttribute('data-value'));
571       return props || empty;
572     }
573     colorSearchTextInBody();
574     if (! isEnabledFetchFunctions()) {
575       showNoSupportMessage();
576       return;
577     }
578     if (! isEnableServerFunctions()) return;
579     replaceSearchWithSearch2();
580     hookSearch2();
581     removeEncodeHint();
582     kickFirstSearch();
583   }
584   enableSearch2();
585 });