1 // PukiWiki - Yet another WikiWikiWeb clone.
4 // 2017 PukiWiki Development Team
5 // License: GPL v2 or (at your option) any later version
7 // PukiWiki search2 pluign - JavaScript client script
8 window.addEventListener && window.addEventListener('DOMContentLoaded', function() {
9 function enableSearch2() {
11 var maxResultLines = 20;
12 var minBlockLines = 5;
13 var minSearchWaitMilliseconds = 100;
15 function escapeHTML (s) {
16 if(typeof s !== 'string') {
19 return s.replace(/[&"<>]/g, function(m) {
28 function doSearch(searchText, session, startIndex) {
29 var url = './?cmd=search2&action=query';
30 var props = getSiteProps();
31 url += '&encode_hint=\u3077';
33 url += '&q=' + encodeURIComponent(searchText);
36 url += '&base=' + encodeURIComponent(session.base);
38 url += '&start=' + startIndex;
40 ).then(function(response){
42 return response.json();
44 throw new Error(response.status + ': ' +
45 + response.statusText + ' on ' + url);
47 }).then(function(obj) {
48 showResult(obj, session, searchText);
49 })['catch'](function(err){
51 console.log('Error! Please check JavaScript console' + '\n' + JSON.stringify(err) + '|' + err);
54 function getMessageTemplate(idText, defaultText) {
55 var messageHolder = document.querySelector('#' + idText);
56 var messageTemplate = (messageHolder && messageHolder.value) || defaultText;
57 return messageTemplate;
59 function getAuthorInfo(text) {
62 function getPassage(now, dateText) {
66 var units = [{u: 'm', max: 60}, {u: 'h', max: 24}, {u: 'd', max: 1}];
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;
76 return '(' + Math.floor(t) + unit + ')';
78 function removeSearchOperators(searchText) {
79 var sp = searchText.split(/\s+/);
84 for (var i = sp.length - 1; i >= 0; i--) {
92 function showResult(obj, session, searchText) {
93 var searchRegex = textToRegex(removeSearchOperators(searchText));
94 var ul = document.querySelector('#result-list');
96 if (obj.start_index === 0) {
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;
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;
118 msg = messageTemplate.replace(/\$1|\$2|\$3/g, function(m){
120 '$1': searchTextDecorated,
121 '$2': session.hit_page_count,
122 '$3': session.read_page_count
125 document.querySelector('#_plugin_search2_message').innerHTML = msg;
127 if (obj.search_done) {
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);
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);
147 var author = getAuthorHeader(val.body);
150 updatedAt = getUpdateTimeFromAuthorInfo(author);
152 updatedAt = val.updated_at;
154 var liHtml = '<a href="' + escapeHTML(href) + '">' + decoratedName + '</a> ' +
155 getPassage(now, updatedAt);
156 li.innerHTML = liHtml;
157 var a = li.querySelector('a');
159 if (a.hash !== hash) {
160 // Some browser execute encodeHTML(hash) automatically. Support them.
161 a.href = val.url + '#encq=' + encodeSearchTextForHash(searchText);
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);
177 fragment.appendChild(div);
178 ul.appendChild(fragment);
180 if (!obj.search_done && obj.next_start_index) {
181 var waitE = document.querySelector('#_search2_search_wait_milliseconds');
182 var interval = minSearchWaitMilliseconds;
184 interval = parseInt(waitE.value);
186 interval = minSearchWaitMilliseconds;
188 if (interval < minSearchWaitMilliseconds) {
189 interval = minSearchWaitMilliseconds;
191 setTimeout(function(){
192 doSearch(searchText, session, obj.next_start_index);
196 function prepareKanaMap() {
197 if (kanaMap !== null) return;
198 if (!String.prototype.normalize) {
202 var dakuten = '\uFF9E';
205 for (var c = 0xFF61; c <=0xFF9F; c++) {
206 var han = String.fromCharCode(c);
207 var zen = han.normalize('NFKC');
209 var hanDaku = han + dakuten;
210 var zenDaku = hanDaku.normalize('NFKC');
211 if (zenDaku.length === 1) { // +Handaku-ten OK
212 map[zenDaku] = hanDaku;
214 var hanMaru = han + maru;
215 var zenMaru = hanMaru.normalize('NFKC');
216 if (zenMaru.length === 1) { // +Maru OK
217 map[zenMaru] = hanMaru;
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+/);
231 for (var i = 0; i < sp.length; i++) {
237 s = s.normalize('NFKC');
239 var s2 = s.replace(regRep, function(m, m1, m2, m3){
241 // Symbol - escape with prior backslach
245 var r = '(?:' + String.fromCharCode(m2.charCodeAt(0) - 0x60) +
248 r += '|' + kanaMap[m2];
254 var katakana = String.fromCharCode(m3.charCodeAt(0) + 0x60);
255 var r = '(?:' + m3 + '|' + katakana;
256 if (kanaMap[katakana]) {
257 r += '|' + kanaMap[katakana];
264 rText += '(' + s2 + ')';
266 return new RegExp(rText, 'ig');
268 function getAuthorHeader(body) {
271 while ((pos = body.indexOf('\n', start)) >= 0) {
272 var line = body.substring(start, pos);
273 if (line.match(/^#author\(/, line)) {
275 } else if (line.match(/^#freeze(\W|$)/, line)) {
276 // Found #freeze still in header
278 // other line, #author not found
285 function getUpdateTimeFromAuthorInfo(authorInfo) {
286 var m = authorInfo.match(/^#author\("([^;"]+)(;[^;"]+)?/);
292 function getTargetLines(body, searchRegex) {
293 var lines = body.split('\n');
296 var isInAuthorHeader = true;
297 var lastFoundLineIndex = -1 - aroundLines;
298 var lastAddedLineIndex = lastFoundLineIndex;
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
308 } else if (line.match(/^#freeze(\W|$)/)) {
312 isInAuthorHeader = false;
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]));
320 lastAddedLineIndex = index;
323 var startIndex = Math.max(Math.max(lastAddedLineIndex + 1, index - aroundLines), 0);
324 if (lastAddedLineIndex + 1 < startIndex) {
327 startIndex: startIndex,
328 foundLineIndex: index,
331 foundLines = block.lines;
334 if (lineCount >= maxResultLines) {
335 foundLines.push('...');
338 for (var i = startIndex; i < index; i++) {
339 foundLines.push('' + (i + 1) + ':\t' + escapeHTML(lines[i]));
342 foundLines.push('' + (index + 1) + ':\t' + decorated);
344 lastFoundLineIndex = lastAddedLineIndex = index;
349 function getSummary(bodyText, searchRegex) {
350 return getTargetLines(bodyText, searchRegex);
352 function hookSearch2(e) {
353 var form = document.querySelector('form');
354 if (form && form.q) {
356 if (q.value === '') {
361 function getBodySummary(body) {
362 var lines = body.split('\n');
363 var isInAuthorHeader = true;
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
373 } else if (line.match(/^#freeze(\W|$)/)) {
378 isInAuthorHeader = false;
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
389 if (summary.length >= 10) {
393 return summary.join(' ').substring(0, 150);
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');
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);
417 forEach(form.querySelectorAll('input[name="base"]'), function(radio){
418 if (radio.checked) base = radio.value;
420 doSearch(searchText.value, {base: base}, 0);
423 function setSearchStatus(statusText) {
424 var statusObj = document.querySelector('#_plugin_search2_search_status');
426 statusObj.textContent = statusText;
429 function forEach(nodeList, func) {
430 if (nodeList.forEach) {
431 nodeList.forEach(func);
433 for (var i = 0, n = nodeList.length; i < n; i++) {
434 func(nodeList[i], i);
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;
444 forEach(f.querySelectorAll('input[name="base"]'), function(radio){
445 if (radio.checked) base = radio.value;
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;
453 var url = baseUri + '?' +
454 (props.is_utf8 ? '' : 'encode_hint=\u3077' + '&') +
456 '&q=' + encodeSearchText(q) +
457 (base ? '&base=' + encodeURIComponent(base) : '');
459 setTimeout(function() {
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);
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;
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;
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]);
496 function encodeSearchTextForHash(q) {
497 var sp = q.split(/\s+/);
500 function findAndDecorateText(text, searchRegex) {
501 var isReplaced = false;
505 searchRegex.lastIndex = 0;
506 while ((m = searchRegex.exec(text)) !== null) {
508 var pre = text.substring(lastIndex, m.index);
509 decorated += escapeHTML(pre);
510 for (var i = 1; i < m.length; i++) {
512 decorated += '<strong class="word' + (i - 1) + '">' + escapeHTML(m[i]) + '</strong>'
515 lastIndex = searchRegex.lastIndex;
518 decorated += escapeHTML(text.substr(lastIndex));
523 function getSearchTextInLocationHash() {
524 // TODO Cross browser
525 var hash = location.hash;
526 if (!hash) return '';
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, ' '));
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);
546 var span = document.createElement('span');
547 span.innerHTML = decorated;
548 element.parentNode.replaceChild(span, element);
551 function walkElement(element) {
552 var e = element.firstChild;
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);
560 if (e.nodeType == 1 && ignoreTags.indexOf(e.tagName) == -1) {
567 var target = document.getElementById('body');
570 function showNoSupportMessage() {
571 var pList = document.getElementsByClassName('_plugin_search2_nosupport_message');
572 for (var i = 0; i < pList.length; i++) {
574 p.style.display = 'block';
577 function isEnabledFetchFunctions() {
578 if (window.fetch && document.querySelector && window.JSON) {
583 function isEnableServerFunctions() {
584 var props = getSiteProps();
585 if (props.json_enabled) return true;
588 function getSiteProps() {
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;
597 colorSearchTextInBody();
598 if (! isEnabledFetchFunctions()) {
599 showNoSupportMessage();
602 if (! isEnableServerFunctions()) return;
603 replaceSearchWithSearch2();