OSDN Git Service

BugTrack/2420 AutoTicketLink - Improve regex and JSON encode
[pukiwiki/pukiwiki.git] / skin / main.js
1 // PukiWiki - Yet another WikiWikiWeb clone.
2 // main.js
3 // Copyright 2017-2019 PukiWiki Development Team
4 // License: GPL v2 or (at your option) any later version
5 //
6 // PukiWiki JavaScript client script
7 window.addEventListener && window.addEventListener('DOMContentLoaded', function() { // eslint-disable-line no-unused-expressions
8   'use strict';
9   /**
10    * @param {NodeList} nodeList
11    * @param {function(Node, number): void} func
12    */
13   function forEach(nodeList, func) {
14     if (nodeList.forEach) {
15       nodeList.forEach(func);
16     } else {
17       for (var i = 0, n = nodeList.length; i < n; i++) {
18         func(nodeList[i], i);
19       }
20     }
21   }
22   // Name for comment
23   function setYourName() {
24     var NAME_KEY_ID = 'pukiwiki_comment_plugin_name';
25     var actionPathname = null;
26     function getPathname(formAction) {
27       if (actionPathname) return actionPathname;
28       try {
29         var u = new URL(formAction, document.location);
30         var u2 = new URL('./', u);
31         actionPathname = u2.pathname;
32         return u2.pathname;
33       } catch (e) {
34         // Note: Internet Explorer doesn't support URL class
35         var m = formAction.match(/^https?:\/\/([^/]+)(\/([^?&]+\/)?)/);
36         if (m) {
37           actionPathname = m[2]; // pathname
38         } else {
39           actionPathname = '/';
40         }
41         return actionPathname;
42       }
43     }
44     function getNameKey(form) {
45       var pathname = getPathname(form.action);
46       var key = 'path.' + pathname + '.' + NAME_KEY_ID;
47       return key;
48     }
49     function getForm(element) {
50       if (element.form && element.form.tagName === 'FORM') {
51         return element.form;
52       }
53       var e = element.parentElement;
54       for (var i = 0; i < 5; i++) {
55         if (e.tagName === 'FORM') {
56           return e;
57         }
58         e = e.parentElement;
59       }
60       return null;
61     }
62     function handleCommentPlugin(form) {
63       var namePrevious = '';
64       var nameKey = getNameKey(form);
65       if (typeof localStorage !== 'undefined') {
66         namePrevious = localStorage[nameKey];
67       }
68       var onFocusForm = function () {
69         if (form.name && !form.name.value && namePrevious) {
70           form.name.value = namePrevious;
71         }
72       };
73       var addOnForcusForm = function(eNullable) {
74         if (!eNullable) return;
75         if (eNullable.addEventListener) {
76           eNullable.addEventListener('focus', onFocusForm);
77         }
78       };
79       if (namePrevious) {
80         var textList = form.querySelectorAll('input[type=text],textarea');
81         textList.forEach(function (v) {
82           addOnForcusForm(v);
83         });
84       }
85       form.addEventListener('submit', function() {
86         if (typeof localStorage !== 'undefined') {
87           localStorage[nameKey] = form.name.value;
88         }
89       }, false);
90     }
91     function setNameForComment() {
92       if (!document.querySelectorAll) return;
93       var elements = document.querySelectorAll(
94         'input[type=hidden][name=plugin][value=comment],' +
95         'input[type=hidden][name=plugin][value=pcomment],' +
96         'input[type=hidden][name=plugin][value=article],' +
97         'input[type=hidden][name=plugin][value=bugtrack]');
98       for (var i = 0; i < elements.length; i++) {
99         var form = getForm(elements[i]);
100         if (form) {
101           handleCommentPlugin(form);
102         }
103       }
104     }
105     setNameForComment();
106   }
107   // AutoTicketLink
108   function autoTicketLink() {
109     var headReText = '([\\s\\b:\\[]|^)';
110     var tailReText = '\\b';
111     var ignoreTags = ['A', 'INPUT', 'TEXTAREA', 'BUTTON',
112       'SCRIPT', 'FRAME', 'IFRAME'];
113     var ticketSiteList = [];
114     var jiraProjects = null;
115     var jiraDefaultInfo = null;
116     function regexEscape(key) {
117       return key.replace(/[-.]/g, function (m) {
118         return '\\' + m;
119       });
120     }
121     function setupSites(siteList) {
122       for (var i = 0, length = siteList.length; i < length; i++) {
123         var site = siteList[i];
124         var reText = '';
125         switch (site.type) {
126           case 'jira':
127             reText = '(' + regexEscape(site.key) + '):([A-Z][A-Z0-9]{1,20}-\\d{1,10})';
128             break;
129           case 'redmine':
130             reText = '(' + regexEscape(site.key) + '):(\\d{1,10})';
131             break;
132           case 'git':
133             reText = '(' + regexEscape(site.key) + '):([0-9a-f]{7,40})';
134             break;
135           default:
136             continue;
137         }
138         site.reText = reText;
139         site.re = new RegExp(headReText + reText + tailReText);
140       }
141     }
142     function getJiraSite() {
143       var reText = '()([A-Z][A-Z0-9]{1,20}-\\d{1,10})';
144       var site = {
145         title: 'Builtin JIRA',
146         type: '_jira_',
147         key: '_jira_',
148         reText: reText,
149         re: new RegExp(headReText + reText + tailReText)
150       };
151       return site;
152     }
153     function getSiteListFromBody() {
154       var defRoot = document.querySelector('#pukiwiki-site-properties .ticketlink-def');
155       if (defRoot && defRoot.value) {
156         var list = JSON.parse(defRoot.value);
157         setupSites(list);
158         return list;
159       }
160       return [];
161     }
162     function getJiraProjectsFromBody() {
163       var defRoot = document.querySelector('#pukiwiki-site-properties .ticketlink-jira-def');
164       if (defRoot && defRoot.value) {
165         try {
166           return JSON.parse(defRoot.value); // List
167         } catch (e) {
168           return null;
169         }
170       }
171       return null;
172     }
173     function getJiraDefaultInfoFromBody() {
174       var defRoot = document.querySelector('#pukiwiki-site-properties .ticketlink-jira-default-def');
175       if (defRoot && defRoot.value) {
176         try {
177           return JSON.parse(defRoot.value); // object
178         } catch (e) {
179           return null;
180         }
181       }
182       return null;
183     }
184     function getSiteList() {
185       return ticketSiteList;
186     }
187     function getJiraProjectList() {
188       return jiraProjects;
189     }
190     function getDefaultJira() {
191       return jiraDefaultInfo;
192     }
193     function ticketToLink(keyText) {
194       var siteList = getSiteList();
195       for (var i = 0; i < siteList.length; i++) {
196         var site = siteList[i];
197         var m = keyText.match(site.re);
198         if (m) {
199           var ticketKey = m[3];
200           var title = ticketKey;
201           var ticketUrl;
202           if (site.type === '_jira_') {
203             // JIRA issue
204             var projects = getJiraProjectList();
205             var hyphen = keyText.indexOf('-');
206             if (hyphen > 0) {
207               var projectKey = keyText.substr(0, hyphen);
208               if (projects) {
209                 for (var j = 0; j < projects.length; j++) {
210                   var p = projects[j];
211                   if (p.key === projectKey) {
212                     if (p.title) {
213                       title = p.title.replace(/\$1/g, ticketKey);
214                     }
215                     ticketUrl = p.base_url + ticketKey;
216                     break;
217                   }
218                 }
219               }
220               if (!ticketUrl) {
221                 var defaultJira = getDefaultJira();
222                 if (defaultJira) {
223                   if (defaultJira.title) {
224                     title = defaultJira.title.replace(/\$1/g, ticketKey);
225                   }
226                   ticketUrl = defaultJira.base_url + ticketKey;
227                 }
228               }
229             }
230             if (!ticketUrl) {
231               return null;
232             }
233           } else {
234             // Explicit TicketLink
235             if (site.title) {
236               title = site.title.replace(/\$1/g, ticketKey);
237             }
238             ticketUrl = site.base_url + ticketKey;
239           }
240           return {
241             url: ticketUrl,
242             title: title
243           };
244         }
245       }
246       return null;
247     }
248     function getRegex(list) {
249       var reText = '';
250       for (var i = 0, length = list.length; i < length; i++) {
251         if (reText.length > 0) {
252           reText += '|';
253         }
254         reText += list[i].reText;
255       }
256       return new RegExp(headReText + '(' + reText + ')' + tailReText);
257     }
258     function makeTicketLink(element) {
259       var siteList = getSiteList();
260       if (!siteList || siteList.length === 0) {
261         return;
262       }
263       var re = getRegex(siteList);
264       var f;
265       var m;
266       var text = element.nodeValue;
267       while (m = text.match(re)) { // eslint-disable-line no-cond-assign
268         // m[1]: head, m[2]: keyText
269         if (!f) {
270           f = document.createDocumentFragment();
271         }
272         if (m.index > 0 || m[1].length > 0) {
273           f.appendChild(document.createTextNode(text.substr(0, m.index) + m[1]));
274         }
275         var linkKey = m[2];
276         var linkInfo = ticketToLink(linkKey);
277         if (linkInfo) {
278           var a = document.createElement('a');
279           a.textContent = linkKey;
280           a.href = linkInfo.url;
281           a.title = linkInfo.title;
282           f.appendChild(a);
283         } else {
284           f.appendChild(document.createTextNode(m[2]));
285         }
286         text = text.substr(m.index + m[0].length);
287       }
288       if (f) {
289         if (text.length > 0) {
290           f.appendChild(document.createTextNode(text));
291         }
292         element.parentNode.replaceChild(f, element);
293       }
294     }
295     function walkElement(element) {
296       var e = element.firstChild;
297       while (e) {
298         if (e.nodeType === 3 && e.nodeValue &&
299             e.nodeValue.length > 5 && /\S/.test(e.nodeValue)) {
300           var next = e.nextSibling;
301           makeTicketLink(e);
302           e = next;
303         } else {
304           if (e.nodeType === 1 && ignoreTags.indexOf(e.tagName) === -1) {
305             walkElement(e);
306           }
307           e = e.nextSibling;
308         }
309       }
310     }
311     if (!Array.prototype.indexOf || !document.createDocumentFragment) {
312       return;
313     }
314     ticketSiteList = getSiteListFromBody();
315     jiraProjects = getJiraProjectsFromBody();
316     jiraDefaultInfo = getJiraDefaultInfoFromBody();
317     if (jiraDefaultInfo || (jiraProjects && jiraProjects.length > 0)) {
318       ticketSiteList.push(getJiraSite());
319     }
320     var target = document.getElementById('body');
321     walkElement(target);
322   }
323   function confirmEditFormLeaving() {
324     function trim(s) {
325       if (typeof s !== 'string') {
326         return s;
327       }
328       return s.replace(/^\s+|\s+$/g, '');
329     }
330     if (!document.querySelector) return;
331     var canceled = false;
332     var pluginNameE = document.querySelector('#pukiwiki-site-properties .plugin-name');
333     if (!pluginNameE) return;
334     var originalText = null;
335     if (pluginNameE.value !== 'edit') return;
336     var editForm = document.querySelector('.edit_form form._plugin_edit_edit_form');
337     if (!editForm) return;
338     var cancelMsgE = editForm.querySelector('#_msg_edit_cancel_confirm');
339     var unloadBeforeMsgE = editForm.querySelector('#_msg_edit_unloadbefore_message');
340     var textArea = editForm.querySelector('textarea[name="msg"]');
341     if (!textArea) return;
342     originalText = textArea.value;
343     var isPreview = false;
344     var inEditE = document.querySelector('#pukiwiki-site-properties .page-in-edit');
345     if (inEditE && inEditE.value) {
346       isPreview = (inEditE.value === 'true');
347     }
348     var cancelForm = document.querySelector('.edit_form form._plugin_edit_cancel');
349     var submited = false;
350     editForm.addEventListener('submit', function() {
351       canceled = false;
352       submited = true;
353     });
354     cancelForm.addEventListener('submit', function(e) {
355       submited = false;
356       canceled = false;
357       if (trim(textArea.value) === trim(originalText)) {
358         canceled = true;
359         return false;
360       }
361       var message = 'The text you have entered will be discarded. Is it OK?';
362       if (cancelMsgE && cancelMsgE.value) {
363         message = cancelMsgE.value;
364       }
365       if (window.confirm(message)) { // eslint-disable-line no-alert
366         // Execute "Cancel"
367         canceled = true;
368         return true;
369       }
370       e.preventDefault();
371       return false;
372     });
373     window.addEventListener('beforeunload', function(e) {
374       if (canceled) return;
375       if (submited) return;
376       if (!isPreview) {
377         if (trim(textArea.value) === trim(originalText)) return;
378       }
379       var message = 'Data you have entered will not be saved.';
380       if (unloadBeforeMsgE && unloadBeforeMsgE.value) {
381         message = unloadBeforeMsgE.value;
382       }
383       e.returnValue = message;
384     }, false);
385   }
386   function showPagePassage() {
387     /**
388      * @param {Date} now
389      * @param {string} dateText
390      */
391     function getSimplePassage(dateText, now) {
392       if (!dateText) {
393         return '';
394       }
395       var units = [{u: 'm', max: 60}, {u: 'h', max: 24}, {u: 'd', max: 1}];
396       var d = new Date();
397       d.setTime(Date.parse(dateText));
398       var t = (now.getTime() - d.getTime()) / (1000 * 60); // minutes
399       var unit = units[0].u; var card = units[0].max;
400       for (var i = 0; i < units.length; i++) {
401         unit = units[i].u; card = units[i].max;
402         if (t < card) break;
403         t = t / card;
404       }
405       return '' + Math.floor(t) + unit;
406     }
407     /**
408      * @param {Date} now
409      * @param {string} dateText
410      */
411     function getPassage(dateText, now) {
412       return '(' + getSimplePassage(dateText, now) + ')';
413     }
414     var now = new Date();
415     var elements = document.getElementsByClassName('page_passage');
416     forEach(elements, function(e) {
417       var dt = e.getAttribute('data-mtime');
418       if (dt) {
419         var d = new Date(dt);
420         e.textContent = ' ' + getPassage(d, now);
421       }
422     });
423     var links = document.getElementsByClassName('link_page_passage');
424     forEach(links, function(e) {
425       var dt = e.getAttribute('data-mtime');
426       if (dt) {
427         var d = new Date(dt);
428         if (e.title) {
429           e.title = e.title + ' ' + getPassage(d, now);
430         } else {
431           e.title = e.textContent + ' ' + getPassage(d, now);
432         }
433       }
434     });
435     var simplePassages = document.getElementsByClassName('simple_passage');
436     forEach(simplePassages, function(e) {
437       var dt = e.getAttribute('data-mtime');
438       if (dt) {
439         var d = new Date(dt);
440         e.textContent = getSimplePassage(d, now);
441       }
442     });
443     // new plugin
444     var newItems = document.getElementsByClassName('__plugin_new');
445     forEach(newItems, function(e) {
446       var dt = e.getAttribute('data-mtime');
447       if (dt) {
448         var d = new Date(dt);
449         var diff = now.getTime() - d.getTime();
450         var daySpan = diff / 1000 / 60 / 60 / 24;
451         if (daySpan < 1) {
452           e.textContent = ' New!';
453           e.title = getPassage(d, now);
454           if (e.classList && e.classList.add) {
455             e.classList.add('new1');
456           }
457         } else if (daySpan < 5) {
458           e.textContent = ' New';
459           e.title = getPassage(d, now);
460           if (e.classList && e.classList.add) {
461             e.classList.add('new5');
462           }
463         }
464       }
465     });
466   }
467   function convertExternalLinkToCushionPageLink() {
468     function domainQuote(domain) {
469       return domain.replace(/\./g, '\\.');
470     }
471     function domainsToRegex(domains) {
472       var regexList = [];
473       domains.forEach(function(domain) {
474         if (domain.substr(0, 2) === '*.') {
475           // Wildcard domain
476           var apex = domain.substr(2);
477           var r = new RegExp('((^.*\\.)|^)' + domainQuote(apex) + '$', 'i');
478           regexList.push(r);
479         } else {
480           // Normal domain
481           regexList.push(new RegExp('^' + domainQuote(domain) + '$', 'i'));
482         }
483       });
484       return regexList;
485     }
486     function domainMatch(domain, regexList) {
487       for (var i = 0, n = regexList.length; i < n; i++) {
488         if (regexList[i].test(domain)) {
489           return true;
490         }
491       }
492       return false;
493     }
494     function removeCushionPageLinks() {
495       var links = document.querySelectorAll('a.external-link');
496       forEach(links, function(link) {
497         var originalUrl = link.getAttribute('data-original-url');
498         if (originalUrl) {
499           link.setAttribute('href', originalUrl);
500         }
501       });
502     }
503     if (!document.querySelector || !JSON) return;
504     if (!Array || !Array.prototype || !Array.prototype.indexOf) return;
505     var extLinkDef = document.querySelector('#pukiwiki-site-properties .external-link-cushion');
506     if (!extLinkDef || !extLinkDef.value) return;
507     var extLinkInfo = JSON.parse(extLinkDef.value);
508     if (!extLinkInfo) return;
509     var refInternalDomains = extLinkInfo.internal_domains;
510     var silentExternalDomains = extLinkInfo.silent_external_domains;
511     if (!Array.isArray(refInternalDomains)) {
512       refInternalDomains = [];
513     }
514     var internalDomains = refInternalDomains.slice();
515     var location = document.location;
516     if (location.protocol === 'file:') {
517       removeCushionPageLinks();
518       return;
519     }
520     if (location.protocol !== 'http:' && location.protocol !== 'https:') return;
521     if (internalDomains.indexOf(location.hostname) < 0) {
522       internalDomains.push(location.hostname);
523     }
524     if (!Array.isArray(silentExternalDomains)) {
525       silentExternalDomains = [];
526     }
527     var propsE = document.querySelector('#pukiwiki-site-properties .site-props');
528     if (!propsE || !propsE.value) return;
529     var siteProps = JSON.parse(propsE.value);
530     var sitePathname = siteProps && siteProps.base_uri_pathname;
531     if (!sitePathname) return;
532     var internalDomainsR = domainsToRegex(internalDomains);
533     var silentExternalDomainsR = domainsToRegex(silentExternalDomains);
534     var links = document.querySelectorAll('a:not(.external-link):not(.internal-link)');
535     var classListEnabled = null;
536     forEach(links, function(link) {
537       if (classListEnabled === null) {
538         classListEnabled = link.classList && link.classList.add && true;
539       }
540       if (!classListEnabled) return;
541       var href = link.getAttribute('href');
542       if (!href) return; // anchor without href attribute (a name)
543       var m = href.match(/^https?:\/\/([0-9a-zA-Z.-]+)(:\d+)?/);
544       if (m) {
545         var host = m[1];
546         if (domainMatch(host, internalDomainsR)) {
547           link.classList.add('internal-link');
548         } else {
549           if (domainMatch(host, silentExternalDomainsR) ||
550             link.textContent.replace(/\s+/g, '') === '') {
551             // Don't show extenal link icons on these domains
552             link.classList.add('external-link-silent');
553           }
554           link.classList.add('external-link');
555           link.setAttribute('title', href);
556           link.setAttribute('data-original-url', href);
557           link.setAttribute('href', sitePathname + '?cmd=external_link&url=' + encodeURIComponent(href));
558         }
559       } else {
560         link.classList.add('internal-link');
561       }
562     });
563   }
564   function makeTopicpathTitle() {
565     if (!document.createDocumentFragment || !window.JSON) return;
566     var sitePropE = document.querySelector('#pukiwiki-site-properties');
567     if (!sitePropE) return;
568     var pageNameE = sitePropE.querySelector('.page-name');
569     if (!pageNameE || !pageNameE.value) return;
570     var pageName = pageNameE.value;
571     var topicpathE = sitePropE.querySelector('.topicpath-links');
572     if (!topicpathE || !topicpathE.value) return;
573     var topicpathLinks = JSON.parse(topicpathE.value);
574     if (!topicpathLinks) return;
575     var titleH1 = document.querySelector('h1.title');
576     if (!titleH1) return;
577     var aList = titleH1.querySelectorAll('a');
578     if (!aList || aList.length > 1) return;
579     var a = titleH1.querySelector('a');
580     if (!a) return;
581     if (a.textContent !== pageName) return;
582     var fragment = document.createDocumentFragment();
583     for (var i = 0, n = topicpathLinks.length; i < n; i++) {
584       var path = topicpathLinks[i];
585       var a1 = document.createElement('a');
586       a1.setAttribute('href', path.uri);
587       a1.setAttribute('title', path.page);
588       a1.textContent = path.leaf;
589       fragment.appendChild(a1);
590       var span = document.createElement('span');
591       span.className = 'topicpath-slash';
592       span.textContent = '/';
593       fragment.appendChild(span);
594     }
595     var a2 = document.createElement('a');
596     a2.setAttribute('href', a.getAttribute('href'));
597     a2.setAttribute('title', 'Backlinks');
598     a2.textContent = a.textContent.replace(/^.+\//, '');
599     fragment.appendChild(a2);
600     a.parentNode.replaceChild(fragment, a);
601   }
602   setYourName();
603   autoTicketLink();
604   confirmEditFormLeaving();
605   showPagePassage();
606   convertExternalLinkToCushionPageLink();
607   makeTopicpathTitle();
608 });