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=' + encodeURIComponent('\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');
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;
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;
116 msg = messageTemplate.replace(/\$1|\$2|\$3/g, function(m){
118 '$1': searchTextDecorated,
119 '$2': session.hit_page_count,
120 '$3': session.read_page_count
123 document.querySelector('#_plugin_search2_message').innerHTML = msg;
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);
137 var author = getAuthorHeader(val.body);
140 updatedAt = getUpdateTimeFromAuthorInfo(author);
142 updatedAt = val.updated_at;
144 var liHtml = '<a href="' + escapeHTML(href) + '">' + decoratedName + '</a> ' +
145 getPassage(now, updatedAt);
146 li.innerHTML = liHtml;
147 var a = li.querySelector('a');
149 if (a.hash !== hash) {
150 // Some browser execute encodeHTML(hash) automatically. Support them.
151 a.href = val.url + '#encq=' + encodeSearchTextForHash(searchText);
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);
167 fragment.appendChild(div);
168 ul.appendChild(fragment);
171 function prepareKanaMap() {
172 if (kanaMap !== null) return;
173 if (!String.prototype.normalize) {
177 var dakuten = '\uFF9E';
180 for (var c = 0xFF61; c <=0xFF9F; c++) {
181 var han = String.fromCharCode(c);
182 var zen = han.normalize('NFKC');
184 var hanDaku = han + dakuten;
185 var zenDaku = hanDaku.normalize('NFKC');
186 if (zenDaku.length === 1) { // +Handaku-ten OK
187 map[zenDaku] = hanDaku;
189 var hanMaru = han + maru;
190 var zenMaru = hanMaru.normalize('NFKC');
191 if (zenMaru.length === 1) { // +Maru OK
192 map[zenMaru] = hanMaru;
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+/);
206 for (var i = 0; i < sp.length; i++) {
212 s = s.normalize('NFKC');
214 var s2 = s.replace(regRep, function(m, m1, m2, m3){
216 // Symbol - escape with prior backslach
220 var r = '(?:' + String.fromCharCode(m2.charCodeAt(0) - 0x60) +
223 r += '|' + kanaMap[m2];
229 var katakana = String.fromCharCode(m3.charCodeAt(0) + 0x60);
230 var r = '(?:' + m3 + '|' + katakana;
231 if (kanaMap[katakana]) {
232 r += '|' + kanaMap[katakana];
239 rText += '(' + s2 + ')';
241 return new RegExp(rText, 'ig');
243 function getAuthorHeader(body) {
246 while ((pos = body.indexOf('\n', start)) >= 0) {
247 var line = body.substring(start, pos);
248 if (line.match(/^#author\(/, line)) {
250 } else if (line.match(/^#freeze(\W|$)/, line)) {
251 // Found #freeze still in header
253 // other line, #author not found
260 function getUpdateTimeFromAuthorInfo(authorInfo) {
261 var m = authorInfo.match(/^#author\("([^;"]+)(;[^;"]+)?/);
267 function getTargetLines(body, searchRegex) {
268 var lines = body.split('\n');
271 var isInAuthorHeader = true;
272 var lastFoundLineIndex = -1 - aroundLines;
273 var lastAddedLineIndex = lastFoundLineIndex;
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
283 } else if (line.match(/^#freeze(\W|$)/)) {
287 isInAuthorHeader = false;
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]));
295 lastAddedLineIndex = index;
298 var startIndex = Math.max(Math.max(lastAddedLineIndex + 1, index - aroundLines), 0);
299 if (lastAddedLineIndex + 1 < startIndex) {
302 startIndex: startIndex,
303 foundLineIndex: index,
306 foundLines = block.lines;
309 if (lineCount >= maxResultLines) {
310 foundLines.push('...');
313 for (var i = startIndex; i < index; i++) {
314 foundLines.push('' + (i + 1) + ':\t' + escapeHTML(lines[i]));
317 foundLines.push('' + (index + 1) + ':\t' + decorated);
319 lastFoundLineIndex = lastAddedLineIndex = index;
324 function getSummary(bodyText, searchRegex) {
325 return getTargetLines(bodyText, searchRegex);
327 function hookSearch2(e) {
328 var form = document.querySelector('form');
329 if (form && form.q) {
331 if (q.value === '') {
336 function getBodySummary(body) {
337 var lines = body.split('\n');
338 var isInAuthorHeader = true;
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
348 } else if (line.match(/^#freeze(\W|$)/)) {
353 isInAuthorHeader = false;
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
364 if (summary.length >= 10) {
368 return summary.join(' ').substring(0, 150);
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');
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);
392 forEach(form.querySelectorAll('input[name="base"]'), function(radio){
393 if (radio.checked) base = radio.value;
395 doSearch(searchText.value, {base: base}, 0);
398 function setSearchStatus(statusText) {
399 var statusObj = document.querySelector('#_plugin_search2_search_status');
401 statusObj.textContent = statusText;
404 function forEach(nodeList, func) {
405 if (nodeList.forEach) {
406 nodeList.forEach(func);
408 for (var i = 0, n = nodeList.length; i < n; i++) {
409 func(nodeList[i], i);
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;
419 forEach(f.querySelectorAll('input[name="base"]'), function(radio){
420 if (radio.checked) base = radio.value;
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;
428 var url = baseUri + '?' +
429 (props.is_utf8 ? '' : 'encode_hint=' +
430 encodeURIComponent('\u3077') + '&') +
432 '&q=' + encodeSearchText(q) +
433 (base ? '&base=' + encodeURIComponent(base) : '');
435 setTimeout(function() {
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);
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;
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;
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]);
472 function encodeSearchTextForHash(q) {
473 var sp = q.split(/\s+/);
476 function findAndDecorateText(text, searchRegex) {
477 var isReplaced = false;
481 searchRegex.lastIndex = 0;
482 while ((m = searchRegex.exec(text)) !== null) {
484 var pre = text.substring(lastIndex, m.index);
485 decorated += escapeHTML(pre);
486 for (var i = 1; i < m.length; i++) {
488 decorated += '<strong class="word' + (i - 1) + '">' + escapeHTML(m[i]) + '</strong>'
491 lastIndex = searchRegex.lastIndex;
494 decorated += escapeHTML(text.substr(lastIndex));
499 function getSearchTextInLocationHash() {
500 // TODO Cross browser
501 var hash = location.hash;
502 if (!hash) return '';
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, ' '));
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);
522 var span = document.createElement('span');
523 span.innerHTML = decorated;
524 element.parentNode.replaceChild(span, element);
527 function walkElement(element) {
528 var e = element.firstChild;
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);
536 if (e.nodeType == 1 && ignoreTags.indexOf(e.tagName) == -1) {
543 var target = document.getElementById('body');
546 function showNoSupportMessage() {
547 var pList = document.getElementsByClassName('_plugin_search2_nosupport_message');
548 for (var i = 0; i < pList.length; i++) {
550 p.style.display = 'block';
553 function isEnabledFetchFunctions() {
554 if (window.fetch && document.querySelector && window.JSON) {
559 function isEnableServerFunctions() {
560 var props = getSiteProps();
561 if (props.json_enabled) return true;
564 function getSiteProps() {
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;
573 colorSearchTextInBody();
574 if (! isEnabledFetchFunctions()) {
575 showNoSupportMessage();
578 if (! isEnableServerFunctions()) return;
579 replaceSearchWithSearch2();