OSDN Git Service

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