•  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
r2070
r2070
1// ==UserScript==
2// @name NamuFix
3// @namespace http://litehell.info/
4// @description 나무위키 등 더시드 사용 위키의 편집 인터페이스 등을 개선합니다.
5// @include https://namu.wiki/*
6// @include https://alphawiki.org/*
7// @include https://www.alphawiki.org/*
8// @include https://theseed.io/*
9// @include https://board.namu.wiki/*
10// @version 180630.0
11// @author LiteHell
12// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
13// @require https://cdn.rawgit.com/LiteHell/NamuFix/5326c9aada134f65bba171d12f5ca5d042fd4fca/korCountryNames.js
14// @require https://cdn.rawgit.com/LiteHell/NamuFix/0ea78119c377402a10bbdfc33365c5195ce7fccc/FlexiColorPicker.js
15// @require https://cdn.rawgit.com/Caligatio/jsSHA/v2.3.1/src/sha.js
16// @require https://cdn.rawgit.com/zenozeng/color-hash/v1.0.3/dist/color-hash.js
17// @require https://cdn.rawgit.com/ben-liang/pnglib/91a91b7f840fdf19ef34a32df8051f2178957293/pnglib.js
18// @require https://cdn.rawgit.com/stewartlord/identicon.js/7c4b4efdb7e2aba458eba14b24ba14e8e2bcdb2a/identicon.js
19// @require https://cdn.jsdelivr.net/npm/[email protected]
20// @require https://cdn.rawgit.com/LiteHell/TooSimplePopupLib/7f2a8a81f11f980c1dfa6b5b2213cd38b8bbde3c/TooSimplePopupLib.js
21// @require https://cdn.rawgit.com/wkpark/jsdifflib/dc19d085db5ae71cdff990aac8351607fee4fd01/difflib.js
22// @require https://cdn.rawgit.com/wkpark/jsdifflib/dc19d085db5ae71cdff990aac8351607fee4fd01/diffview.js
23// @require https://cdn.rawgit.com/LiteHell/NamuFix/d6bcc377c563c2745af0b8078ce5dc97f2c19910/namuapi.js
24// @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.19.3/moment-with-locales.min.js
25// @require https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.14/moment-timezone-with-data.min.js
26// @require https://cdnjs.cloudflare.com/ajax/libs/async/2.6.0/async.min.js
27// @connect cdn.rawgit.com
28// @connect cdnjs.cloudflare.com
29// @connect jsdelivr.net
30// @connect api.github.com
31// @connect ipinfo.io
32// @connect wtfismyip.com
33// @connect www.googleapis.com
34// @connect web.archive.org
35// @connect archive.is
36// @connect www.vpngate.net
37// @connect namufix.wikimasonry.org
38// @connect phpgongbu.ga
39// @connect twitter.com
40// @connect facebook.com
41// @connect www.facebook.com
42// @grant GM_addStyle
43// @grant GM_openInTab
44// @grant GM_xmlhttpRequest
45// @grant GM_getValue
46// @grant GM_setValue
47// @grant GM_deleteValue
48// @grant GM_listValues
49// @grant GM_info
50// @grant GM_getResourceURL
51// @grant GM.openInTab
52// @grant GM.xmlHttpRequest
53// @grant GM.getValue
54// @grant GM.setValue
55// @grant GM.deleteValue
56// @grant GM.listValues
57// @grant GM.info
58// @grant GM.getResourceUrl
59// @run-at document-end
60// @noframes
61// ==/UserScript==
62/* jshint ignore:start */
63/*
64Copyright (c) 2015 LiteHell
r504
65
r2070
66Permission is hereby granted, free of charge, to any person
67obtaining a copy of this software and associated documentation
68files (the "Software"), to deal in the Software without
69restriction, including without limitation the rights to use,
70copy, modify, merge, publish, distribute, sublicense, and/or sell
71copies of the Software, and to permit persons to whom the
72Software is furnished to do so, subject to the following
73conditions:
r1891
74
r2070
75The above copyright notice and this permission notice shall be
76included in all copies or substantial portions of the Software.
r2
77
r2070
78THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
79EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
80OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
81NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
82HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
83WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
84FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
85OTHER DEALINGS IN THE SOFTWARE.
86*/
r1598
87
r2070
88try {
89 if (typeof GM_addStyle !== 'undefined') {
90 var GM_addStyle = function (text) {
91 var style = document.createElement("style");
92 style.innerHTML = text;
93 document.head.appendChild(style);
94 }
95 }
r1598
96
r2070
97 function NFStorage() {
98 function jsonParsable(str) {
99 try {
100 JSON.parse(str);
101 return true;
102 } catch (err) {
103 return false;
104 }
105 }
106 var discards = ['save', 'load', 'delete', 'export', 'import'];
107 this.save = async function () {
108 for (var i in this) {
109 if (discards.indexOf(i) != -1) continue;
110 await GM.setValue('SET_' + i, JSON.stringify(this[i]));
111 }
112 };
113 this.load = async function () {
114 var sets = await GM.listValues();
115 for (var i = 0; i < sets.length; i++) {
116 var now = sets[i];
117 if (now.indexOf('SET_') != 0) continue;
118 if (discards.indexOf(now.substring(4)) != -1) continue;
119 let sval = await GM.getValue(now);
120 this[now.substring(4)] = jsonParsable(sval) ? JSON.parse(sval) : sval;
121 }
122 };
123 this.delete = async function (key) {
124 if (discards.indexOf(key) != -1) return;
125 await GM.deleteValue('SET_' + key);
126 delete this[key];
127 };
128 this.export = async function (excludes) {
129 let sets = await GM.listValues();
130 let result = {};
131 for (let i of sets) {
132 if (i.indexOf('SET_') != 0) continue;
133 let setname = i.substring(4)
134 if (discards.includes(setname) || excludes.includes(setname)) continue;
135 result[setname] = await GM.getValue(i);
136 }
137 return result;
138 };
139 this.import = async function (data) {
140 for (let i in data) {
141 await GM.setValue('SET_' + i, data[i]);
142 }
143 }
144 }
145 if (location.host === 'board.namu.wiki') {
146 async function runBoardFix() {
147 let SET = new NFStorage();
148 await SET.load();
149 // 시간대 자동 변경
150 if (SET.noLocaltimeOnNamuBoard !== false) {
151 let times = document.querySelectorAll('.read_header > .meta > .time, .fbMeta .time');
152 let origTimezone = "UTC" // America/Asuncion 아님.
153 for (let i = 0; i < times.length; i++) {
154 let t = moment.tz(times[i].textContent.trim(), "YYYY.MM.DD HH:mm", origTimezone);
155 times[i].textContent = t.tz(moment.tz.guess()).format("YYYY.MM.DD HH:mm z")
156 }
157 if (times.length !== 0)
158 console.log(`[NamuFix] 게시판 시간대 변경 완료. ${origTimezone} > ${moment.tz.guess()}`);
159 }
160 // 아카이브 버튼
161 if (document.querySelector('.viewDocument .read_footer .btnArea a[onclick]') !== null) {
162 let extraMenuLink = document.querySelector('.viewDocument .read_footer .btnArea a[onclick]');
163 let documentId = /document_([0-9]+)/.exec(extraMenuLink.className)[1];
164 let archiveLink = document.createElement("a");
165 archiveLink.href = "#";
166 archiveLink.target = "_blank";
167 archiveLink.textContent = "아카이브";
168 let clickHandler = (evt) => {
169 evt.preventDefault();
170 archiveLink.textContent = "(진행중)";
171 archiveLink.removeAttribute("href");
172 archiveLink.removeEventListener("click", clickHandler);
173 GM.xmlHttpRequest({
174 method: 'POST',
175 url: 'https://phpgongbu.ga/archive/',
176 headers: {
177 "Content-Type": "application/x-www-form-urlencoded",
178 "User-Agent": `Mozilla/5.0 (compatible; NamuFix/${GM.info.script.version}`
179 },
180 data: `board_num=${documentId}`,
181 onload: (res) => {
182 GM.openInTab(res.finalUrl);
183 archiveLink.textContent = "아카이브됨";
184 archiveLink.href = res.finalUrl;
185 }
186 });
187 };
188 archiveLink.addEventListener("click", clickHandler);
189 extraMenuLink.parentNode.insertBefore(archiveLink, extraMenuLink.nextSibling);
190 }
191 // 댓글 상용구
192 if (document.querySelector('.write_comment') && SET.commentMacros) {
193 console.log('[NamuFix] 댓글창 감지됨.')
194 let writeAuthorDiv = document.querySelector('.write_author');
195 for(let i of SET.commentMacros.split(',')) {
196 let macroName = i.split(':')[0];
197 let macroContentParts = i.split(':');
198 macroContentParts.shift();
199 let macroContent = macroContentParts.join(':');
200 let macroBtn = document.createElement("button");
201 console.log(`[NamuFix] 매크로 버튼 추가중 (이름: ${macroName}, 내용: ${macroContent})`)
202 macroBtn.setAttribute('type', 'button');
203 macroBtn.innerHTML = '상용구 (' + macroName + ')';
204 macroBtn.addEventListener('click', (evt) => {
205 evt.preventDefault();
206 let xeEditor = document.querySelector('.xpress-editor'),
207 hiddenContentInput = document.querySelector('.write_comment > input[name="content"]'),
208 writeForm = document.querySelector('.write_comment > .write_form');
209 // XE에디터 제거
210 xeEditor.parentNode.removeChild(xeEditor);
211 // 기존 content input 제거
212 hiddenContentInput.parentNode.removeChild(hiddenContentInput);
213 // content input 추가
214 let newContentInput = document.createElement('input');
215 newContentInput.setAttribute('type', 'hidden');
216 newContentInput.name = "content";
217 newContentInput.value = macroContent;
218 writeForm.appendChild(newContentInput);
219 // 댓글 작성
220 document.querySelector('.write_author button[type="submit"]').click();
221 })
222 writeAuthorDiv.appendChild(macroBtn,writeAuthorDiv.lastChild);
223 }
224 }
225 }
226 runBoardFix();
227 } else
228 (async function () {
229 console.log(`[NamuFix] 현재 버전 : ${GM.info.script.version}`);
230 if (location.hostname == 'no-ssl.namu.wiki')
231 location.hostname = 'namu.wiki';
r1637
232
r2070
233 function insertCSS(url) {
234 // 나무위키 CSP 빡세서 getResourceUrl 쓰면 보안 오류남.
235 GM.xmlHttpRequest({
236 method: 'GET',
237 url: url,
238 onload: (res) => {
239 let styleTag = document.createElement("style");
240 styleTag.innerHTML = res.responseText;
241 document.head.appendChild(styleTag);
242 }
243 });
244 }
r273
245
r2070
246 insertCSS("https://cdn.rawgit.com/LiteHell/NamuFix/284db44ac1d89ff0cbd1155c3372db38be3bc140/NamuFix.css");
247 insertCSS("https://cdn.rawgit.com/LiteHell/TooSimplePopupLib/edad912e28eeacdc3fd8b6e6b7ac5cafc46d95b6/TooSimplePopupLib.css");
248 insertCSS("https://cdn.rawgit.com/wkpark/jsdifflib/dc19d085db5ae71cdff990aac8351607fee4fd01/diffview.css");
249 console.log('[NamuFix] CSS 삽입됨.');
r9
250
r2070
251 // 업데이트 확인
252 GM.xmlHttpRequest({
253 method: "GET",
254 url: "https://api.github.com/repos/LiteHell/NamuFix/releases/latest",
255 onload: function (res) {
256 var obj = JSON.parse(res.responseText);
257 if (typeof obj.message !== 'undefined' && obj.message.indexOf('API rate limit') != -1) {
258 console.log('[NamuFix] NamuFix 업데이트 연기! (GitHub API 제한에 따른 오류)');
259 return; // GitHub API 오류
260 }
261 var currentVersion = GM.info.script.version;
262 if(!obj.tag_name) {
263 console.log('[NamuFix] NamuFix 업데이트 연기! (최신 버전을 읽을 수 없음)');
264 return;
265 }
266 var latestVersion = obj.tag_name;
267 }
268 });
r450
269
r2070
270 function nOu(a) {
271 return typeof a === 'undefined' || a == null;
272 }
r690
273
r2070
274 // HTML 이스케이프 함수
275 function encodeHTMLComponent(text) {
276 var result = text;
277 // http://www.w3schools.com/php/func_string_htmlspecialchars.asp 참고함.
278 result = result.replace(/&/gmi, "&amp;");
279 result = result.replace(/</gmi, "&lt;");
280 result = result.replace(/>/gmi, "&gt;");
281 result = result.replace(/'/gmi, "&#039;");
282 result = result.replace(/"/gmi, "&quot;");
283 return result;
284 }
r1637
285
r2070
286 function validateIP(ip) {
287 return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(ip) || /^([0-9a-f]){1,4}(:([0-9a-f]){1,4}){7}$/i.test(ip);
288 }
r762
289
r2070
290 function formatDateTime(t) {
291 var d = new Date(t);
292 return `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일 ${(['일', '월', '화', '수', '목', '금', '토'])[d.getDay()]}요일 ${ d.getHours() > 12 ? '오후' : '오전'} ${d.getHours() - (d.getHours() > 12 ? 12 : 0)}시 ${d.getMinutes()}분 ${d.getSeconds()}초`;
293 }
r762
294
r2070
295 function formatTimespan(timespan) {
296 var units = [{
297 name: "주",
298 unit: 60 * 60 * 24 * 7,
299 value: 0
300 },
301 {
302 name: "일",
303 unit: 60 * 60 * 24,
304 value: 0
305 },
306 {
307 name: "시간",
308 unit: 60 * 60,
309 value: 0
310 },
311 {
312 name: "분",
313 unit: 60,
314 value: 0
315 },
316 {
317 name: "초",
318 unit: 1,
319 value: 0
320 }
321 ];
322 for (var i = 0; i < units.length; i++) {
323 while (timespan >= units[i].unit) {
324 timespan -= units[i].unit;
325 units[i].value++;
326 }
327 }
328 return units.filter(function (x) {
329 return x.value != 0;
330 }).map(function (x) {
331 return x.value + x.name
332 }).join(' ');
333 }
r762
334
r2070
335 var hashDictionary512 = {};
336 var hashDictionary1 = {};
337 var hashDictionary256 = {};
338 var ipDictionary = {};
r762
339
r2070
340 function SHA512(text) {
341 if (typeof hashDictionary512[text] === 'undefined') {
342 var shaObj = new jsSHA("SHA-512", "TEXT");
343 shaObj.update(text);
344 hashDictionary512[text] = shaObj.getHash("HEX");
345 }
346 return hashDictionary512[text];
347 }
348
349 function SHA1(text) {
350 if (typeof hashDictionary1[text] === 'undefined') {
351 var shaObj = new jsSHA("SHA-1", "TEXT");
352 shaObj.update(text);
353 hashDictionary1[text] = shaObj.getHash("HEX");
354 }
355 return hashDictionary1[text];
356 }
357
358 function SHA256(text) {
359 if (typeof hashDictionary256[text] === 'undefined') {
360 var shaObj = new jsSHA("SHA-256", "TEXT");
361 shaObj.update(text);
362 hashDictionary256[text] = shaObj.getHash("HEX");
363 }
364 return hashDictionary256[text];
365 }
366
367 function getIpInfo(ip, cb) {
368 if (ipDictionary[ip])
369 return cb(ipDictionary[ip]);
370 GM.xmlHttpRequest({
371 method: "GET",
372 url: `http://ipinfo.io/${ip}/json`,
373 onload: function (res) {
374 var resObj = JSON.parse(res.responseText);
375 if (res.status === 200 || res.status === 304) {
376 if (/^AS[0-9]+ /.test(resObj.org)) {
377 resObj.org = resObj.org.replace(/^AS[0-9]+ /, '');
378 }
379 if (SET.ipInfoDefaultOrg != 'ipinfo.io') {
380 getIpWhois(ip, function (whoisRes) {
381 if (!whoisRes.success || whoisRes.raw) {
382 ipDictionary[ip] = resObj;
383 cb(resObj);
384 return;
385 }
386 var koreanISP = null,
387 koreanUser = null;
388 if (whoisRes.result.korean && whoisRes.result.korean.ISP && whoisRes.result.korean.ISP.netinfo && whoisRes.result.korean.ISP.netinfo.orgName) {
389 koreanISP = whoisRes.result.korean.ISP.netinfo.orgName;
390 } else if (whoisRes.result.korean && whoisRes.result.korean.user && whoisRes.result.korean.user.netinfo && whoisRes.result.korean.user.netinfo.orgName) {
391 koreanUser = whoisRes.result.korean.user.netinfo.orgName;
392 }
393 if (SET.ipInfoDefaultOrg === 'KISAuser' && koreanUser !== null) {
394 resObj.org = koreanUser;
395 } else if (SET.ipInfoDefaultOrg === 'KISAISP' && koreanISP !== null) {
396 resObj.org = koreanISP;
397 } else if (SET.ipInfoDefaultOrg === 'KISAuserOrISP' && (koreanUser !== null || koreanISP !== null)) {
398 resObj.org = koreanUser !== null ? koreanUser : koreanISP;
399 }
400 ipDictionary[ip] = resObj;
401 cb(resObj);
402 return;
403 });
404 } else {
405 ipDictionary[ip] = resObj;
406 cb(resObj);
407 }
408 } else {
409 cb(null);
410 }
411 }
412 });
413 }
414
415 var whoisDictionary = {};
416
417 function getIpWhois(ip, cb) {
418 if (whoisDictionary[ip])
419 return cb(whoisDictionary[ip]);
420 GM.xmlHttpRequest({
421 method: "GET",
422 url: `http://namufix.wikimasonry.org/whois/ip/${ip}`,
423 onload: function (res) {
424 var resObj = JSON.parse(res.responseText);
425 whoisDictionary[ip] = resObj;
426 cb(resObj);
427 }
428 });
429 }
430
431 function enterTimespanPopup(title, callback) {
432 var win = TooSimplePopup();
433 win.title(title);
434 win.content(function (winContainer) {
435 var units = {
436 second: 1,
437 minute: 60,
438 hour: 60 * 60,
439 day: 60 * 60 * 24,
440 week: 60 * 60 * 24 * 7,
441 month: 60 * 60 * 24 * 7 * 4, // 4 주
442 year: 60 * 60 * 24 * 7 * 48 // 48주
443 }
444 winContainer.innerHTML = '<style>.timespan-container input.timespan-input {width: 60px;}</style><div class="timespan-container">' +
445 ' <input type="number" data-unit="year" class="timespan-input" value="0">년' +
446 ' <input type="number" data-unit="month" class="timespan-input" value="0">개월' +
447 ' <input type="number" data-unit="week" class="timespan-input" value="0">주' +
448 ' <input type="number" data-unit="day" class="timespan-input" value="0">일' +
449 ' <input type="number" data-unit="hour" class="timespan-input" value="0">시간' +
450 ' <input type="number" data-unit="minute" class="timespan-input" value="0">분' +
451 ' <input type="number" data-unit="second" class="timespan-input" value="0">초' +
452 '</div>';
453 win.button('닫기', function () {
454 win.close();
455 callback(null);
456 });
457 win.button('입력', function () {
458 var result = 0;
459 var isNumberic = function (v) {
460 return !isNaN(parseFloat(v)) && isFinite(v);
461 } // https://stackoverflow.com/a/9716488
462 var timespanInputs = winContainer.querySelectorAll('input.timespan-input')
463 for (var i = 0; i < timespanInputs.length; i++) {
464 var timespanInput = timespanInputs[i];
465 if (isNumberic(timespanInput.value)) result += timespanInput.value * units[timespanInput.dataset.unit];
466 }
467 win.close();
468 callback(result);
469 })
470 });
471 }
472
473 function whoisPopup(ip) {
474 if (ip === null || ip === "")
475 return alert('ip주소를 입력해주세요');
476 var win = TooSimplePopup();
477 win.title('IP WHOIS 조회');
478 win.button('다른 IP 조회', function () {
479 var newip = prompt('IP 주소를 입력하세요.');
480 win.close();
481 whoisPopup(newip);
482 })
483 win.button('닫기', win.close);
484 win.content(function (container) {
485 container.innerHTML = '조회중입니다. 잠시만 기다려주세요...';
486 getIpWhois(ip, function (result) {
487 if (result.success && !result.raw) {
488 var whoisObj = result.result;
489 var currentView = 'table';
490 container.innerHTML = '<p>NamuFix 서버를 통해 KISA WHOIS를 조회했습니다. 결과는 다음과 같습니다.(<a href="#" class="switchViewType">JSON 형식으로 보기</a>)</p><div class="whois-content"><table></table></div>';
491 var resultContainer = container.querySelector('.whois-content');
492 var switchViewLink = container.querySelector('a.switchViewType');
493
494 function useTableView() {
495 function writeSubInfoTable(subInfoObj, caption) {
496 var table = document.createElement('table');
497 table.className = "whois";
498 table.innerHTML += '<caption>' + encodeHTMLComponent(caption) + '</caption>';
499 table.innerHTML += '<thead><tr><th>이름</th><th>내용</th></tr></thead>';
500 var tableHtml = '<tbody>';
501 var koreanContactNames = {
502 techContact: '네트워크 담당자 정보',
503 adminContact: 'IP주소 관리자 정보',
504 abuseContact: '네트워크 abuse 담당자 정보'
505 };
506 var koreanNetInfoNames = {
507 // 주소범위(range), 프리픽스(prefix), 네트워크이름(servName), 기관명(orgname), 주소(addr), 우편번호(zipCode), 할당내역 등록일(regDate) 필드 포함
508 addr: '주소',
509 netType: '네트워크 구분',
510 orgName: '기관명',
511 orgID: '기관 ID',
512 prefix: 'IP prefix',
513 range: 'IP 범위',
514 regDate: '등록일',
515 servName: '네트워크 이름',
516 zipCode: '우편번호'
517 };
518 for (var i in subInfoObj) {
519 if (i == 'techContact' || i == 'adminContact' || i == 'abuseContact') {
520 tableHtml += '<tr><td colspan="2" class="whois-subheader">' + koreanContactNames[i] + '</td></tr>';
521 if (subInfoObj[i].name)
522 tableHtml += '<tr><td>이름</td><td>' + encodeHTMLComponent(subInfoObj[i].name) + '</td></tr>';
523 if (subInfoObj[i].phone)
524 tableHtml += '<tr><td>전화번호</td><td>' + encodeHTMLComponent(subInfoObj[i].phone) + '</td></tr>';
525 if (subInfoObj[i].email)
526 tableHtml += '<tr><td>연락처</td><td>' + encodeHTMLComponent(subInfoObj[i].email) + '</td></tr>';
527 } else if (i == 'netinfo') {
528 // KISA 씨발놈들.... API 설명서에 다 적어두지...
529 tableHtml += '<tr><td colspan="2" class="whois-subheader">네트워크 정보</td></tr>';
530 for (var na in subInfoObj[i]) {
531 tableHtml += '<tr><td>' + (koreanNetInfoNames[na] ? koreanNetInfoNames[na] : na) + '</td><td>' + encodeHTMLComponent(subInfoObj[i][na]) + '</td></tr>';
532 }
533 }
534 }
535 tableHtml += '</tbody>';
536 table.innerHTML += tableHtml;
537 return table;
538 }
539 switchViewLink.innerText = '(진행중입니다)';
540 currentView = 'progress';
541 resultContainer.innerHTML = '<p>(진행중입니다.)</p>'
542
543 function writeRest() {
544 if (whoisObj.korean && whoisObj.korean.ISP)
545 resultContainer.appendChild(writeSubInfoTable(whoisObj.korean.ISP, "IP 주소 보유기관 정보 (국문)"));
546 if (whoisObj.korean && whoisObj.korean.user)
547 resultContainer.appendChild(writeSubInfoTable(whoisObj.korean.user, "IP 주소 이용기관 정보 (국문)"));
548 if (whoisObj.english && whoisObj.english.ISP)
549 resultContainer.appendChild(writeSubInfoTable(whoisObj.english.ISP, "IP 주소 보유기관 정보 (영문)"));
550 if (whoisObj.english && whoisObj.english.user)
551 resultContainer.appendChild(writeSubInfoTable(whoisObj.english.user, "IP 주소 이용기관 정보 (영문)"));
552 switchViewLink.innerText = 'JSON 형식으로 보기';
553 currentView = 'table';
554 }
555 if (whoisObj.countryCode == 'none') {
556 resultContainer.innerHTML = '<style>.whois-subheader {background: #9D75D9; color: white; text-align: center;} table.whois tr, table.whois td{border: 1px #9D75D9 solid; border-collapse: collapse;} table.whois td {padding: 5px;} table.whois caption { caption-side: top; }</style>' +
557 '<table class="whois"><caption>쿼리 정보</caption>' +
558 '<thead><th>이름</th><th>내용</th></tr></thead>' +
559 '<tbody>' +
560 '<tr><td>쿼리</td><td>' + whoisObj.query + '</td></tr>' +
561 '<tr><td>쿼리 유형</td><td>' + whoisObj.queryType + '</td></tr>' +
562 '<tr><td>레지스트리</td><td>' + whoisObj.registry + '</td></tr>' +
563 '<tr><td>등록 국가</td><td><span class="icon ion-android-star"></span> 없음 (루프백같이 특수한 경우)</td></tr>' +
564 '</tbody></table>';
565 writeRest();
566 } else {
567 getFlagIcon(whoisObj.countryCode.toLowerCase(), function (flagIconData) {
568 resultContainer.innerHTML = '<style>.whois-subheader {background: #9D75D9; color: white; text-align: center;} table.whois tr, table.whois td{border: 1px #9D75D9 solid; border-collapse: collapse;} table.whois td {padding: 5px;} table.whois caption { caption-side: top; }</style>' +
569 '<table class="whois"><caption>쿼리 정보</caption>' +
570 '<thead><th>이름</th><th>내용</th></tr></thead>' +
571 '<tbody>' +
572 '<tr><td>쿼리</td><td>' + whoisObj.query + '</td></tr>' +
573 '<tr><td>쿼리 유형</td><td>' + whoisObj.queryType + '</td></tr>' +
574 '<tr><td>레지스트리</td><td>' + whoisObj.registry + '</td></tr>' +
575 '<tr><td>등록 국가</td><td><img style="height: 1em;" src="' + flagIconData + '">' + korCountryNames[whoisObj.countryCode.toUpperCase()] + '</td></tr>' +
576 '</tbody></table>';
577 writeRest();
578 });
579 }
580 }
581
582 function useJsonView() {
583 switchViewLink.innerText = '표 형식으로 보기';
584 currentView = 'json';
585 resultContainer.innerHTML = '<textarea readonly style="width: 50vw; height: 600px; max-height: 90vh;"></textarea>';
586 resultContainer.querySelector('textarea').value = JSON.stringify(whoisObj);
587 }
588 switchViewLink.addEventListener('click', function (evt) {
589 evt.preventDefault();
590 if (currentView == 'json') {
591 useTableView();
592 } else if (currentView == 'table') {
593 useJsonView();
594 } else if (currentView == 'progress') {
595 alert('잠시만 기다려주세요.');
596 }
597 })
598 useTableView();
599 } else if (result.success && result.raw) {
600 container.innerHTML = '<p>NamuFix 서버에서 다음과 같은 WHOIS 결과를 얻었습니다.</p><textarea readonly style="width: 50vw; height: 600px; max-height: 80vh;"></textarea>';
601 container.querySelector('textarea').value = result.result;
602 } else if (result.error.namufix) {
603 alert('NamuFix 서버측에서 오류가 발생했습니다.\n\n메세지 : ' + result.error.message + '\n오류 코드 : ' + result.error.code);
604 win.close();
605 } else if (result.error.kisa) {
606 container.innerHTML = '<p>KISA WHOIS 조회결과 다음과 같은 오류가 반환됐습니다.</p><p><em>' + result.error.message + '</em></p><p>오류 코드는 ' + result.error.code + '입니다.</p>';
607 }
608 });
609 });
610 }
611
612 // To bypass CSP
613 var flagIconDictionary = {};
614
615 function getFlagIcon(countryCode, cb) {
616 if (flagIconDictionary[countryCode])
617 return cb(flagIconDictionary[countryCode]);
618 GM.xmlHttpRequest({
619 method: 'GET',
620 url: `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/2.9.0/flags/4x3/${countryCode}.svg`,
621 onload: function (res) {
622 flagIconDictionary[countryCode] = "data:image/svg+xml;base64," + btoa(res.responseText);
623 return cb(flagIconDictionary[countryCode]);
624 }
625 });
626 }
627
628 // 문서/역사/편집 페이지 등에서 버튼 추가 함수
629 function addArticleButton(text, onclick) {
630 var aTag = document.createElement("a");
631 aTag.className = "btn btn-secondary";
632 aTag.setAttribute("role", "button");
633 aTag.innerHTML = text;
634 aTag.href = "#";
635 aTag.addEventListener('click', (evt) => {
636 evt.preventDefault();
637 onclick(evt);
638 });
639 var buttonGroup = document.querySelector('body.Liberty .liberty-content .content-tools .btn-group , body.senkawa .wiki-article-menu > div.btn-group');
640 buttonGroup.insertBefore(aTag, buttonGroup.firstChild);
641 };
642
643 function uniqueID() {
644 var dt = Date.now();
645 var url = location.href;
646 var randomized = Math.floor(Math.random() * 48158964189489678525869410);
647 return SHA512(String(dt).concat(dt, '\n', url, '\n', String(randomized)));
648 }
649
650 function listenPJAX(callback) {
651 // create elements
652 var pjaxButton = document.createElement("button");
653 var scriptElement = document.createElement("script");
654
655 // configure button
656 pjaxButton.style.display = "none";
657 pjaxButton.id = "nfFuckingPJAX"
658 pjaxButton.addEventListener("click", callback);
659
660 // configure script
661 scriptElement.setAttribute("type", "text/javascript");
662 scriptElement.innerHTML = '$(document).bind("pjax:end", function(){document.querySelector("button#nfFuckingPJAX").click();})';
663
664 // add elements
665 document.body.appendChild(pjaxButton);
666 document.head.appendChild(scriptElement);
667 }
668
669 var SET = new NFStorage();
670
671 async function INITSET() { // Storage INIT
672 await SET.load();
673 if (nOu(SET.tempsaves))
674 SET.tempsaves = {};
675 if (nOu(SET.recentlyUsedTemplates))
676 SET.recentlyUsedTemplates = [];
677 if (nOu(SET.imgurDeletionLinks))
678 SET.imgurDeletionLinks = [];
679 if (nOu(SET.discussIdenti))
680 SET.discussIdenti = 'icon'; // icon, headBg, none
681 if (nOu(SET.discussIdentiLightness))
682 SET.discussIdentiLightness = 0.7;
683 if (nOu(SET.discussIdentiSaturation))
684 SET.discussIdentiSaturation = 0.5;
685 if (nOu(SET.favorites))
686 SET.favorites = [];
687 if (nOu(SET.customIdenticons))
688 SET.customIdenticons = {};
689 if (nOu(SET.hideDeletedWhenDiscussing))
690 SET.hideDeletedWhenDiscussing = 0;
691 else if (typeof SET.hideDeletedWhenDiscussing !== "Number")
692 SET.hideDeletedWhenDiscussing = Number(SET.hideDeletedWhenDiscussing);
693 if (nOu(SET.discussAnchorPreviewType))
694 SET.discussAnchorPreviewType = 1; // 0 : None, 1 : mouseover, 2 : quote
695 else
696 SET.discussAnchorPreviewType = Number(SET.discussAnchorPreviewType);
697 if (nOu(SET.removeNFQuotesInAnchorPreview))
698 SET.removeNFQuotesInAnchorPreview = false;
699 if (nOu(SET.lookupIPonDiscuss))
700 SET.lookupIPonDiscuss = true;
701 if (nOu(SET.ignoreNonSenkawaWarning))
702 SET.ignoreNonSenkawaWarning = false;
703 if (nOu(SET.loadUnvisibleReses))
704 SET.loadUnvisibleReses = false;
705 if (nOu(SET.ipInfoDefaultOrg))
706 SET.ipInfoDefaultOrg = "ipinfo.io"; //ipinfo.io, KISAISP, KISAuser, KISAuserOrISP
707 if (nOu(SET.addAdminLinksForLiberty))
708 SET.addAdminLinksForLiberty = false;
709 if (nOu(SET.autoTempsaveSpan))
710 SET.autoTempsaveSpan = 1000 * 60 * 5; // 5분
711 if (nOu(SET.addBatchBlockMenu))
712 SET.addBatchBlockMenu = false;
713 if (nOu(SET.noLocaltimeOnNamuBoard))
714 SET.noLocaltimeOnNamuBoard = true;
715 if (nOu(SET.fileUploadReqLimit))
716 SET.fileUploadReqLimit = 3;
717 if (nOu(SET.adminReqLimit))
718 SET.adminReqLimit = 3;
719 if (nOu(SET.quickBlockReasonTemplate_discuss))
720 SET.quickBlockReasonTemplate_discuss = '긴급조치 https://${host}/thread/${threadNo} #${messageNo}' // ${host}, ${threadNo}, ${messageNo}
721 if (nOu(SET.quickBlockReasonTemplate_history))
722 SET.quickBlockReasonTemplate_history = '긴급조치 [[${docName}]] ${revisionNo}' // ${host}, ${docName}, ${revisionNo}
723 if (nOu(SET.quickBlockDefaultDuration))
724 SET.quickBlockDefaultDuration = 0;
725 if (nOu(SET.addQuickBlockLink))
726 SET.addQuickBlockLink = false;
727 if (nOu(SET.notifyForUnvisibleThreads))
728 SET.notifyForUnvisibleThreads = false;
729 if (nOu(SET.checkWhoisNetTypeOnDiscuss))
730 SET.checkWhoisNetTypeOnDiscuss = false;
731 if (nOu(SET.checkedServerNotices))
732 SET.checkedServerNotices = [];
733 if (nOu(SET.additionalScript))
734 SET.additionalScript = "";
735 if (nOu(SET.umiCookie))
736 SET.umiCookie = "";
737 if (nOu(SET.unprefixedFilename))
738 SET.unprefixedFilename= false;
739 if (nOu(SET.addSnsShareButton))
740 SET.addSnsShareButton = false;
741 if (nOu(SET.commentMacros))
742 SET.commentMacros = '';
743 if (nOu(SET.ipBlockHistoryCheckDelay))
744 SET.ipBlockHistoryCheckDelay = 500;
745 if (nOu(SET.identiconLibrary))
746 SET.identiconLibrary = 'jdenticon'; // jdenticon, identicon, gravatar, gravatar-monster
747 if (nOu(SET.emphasizeResesWhenMouseover))
748 SET.emphasizeResesWhenMouseover = false;
749 await SET.save();
750 }
751
752 var nfMenuDivider = document.createElement("div");
753 if (document.querySelectorAll('.dropdown-divider').length == 0) {
754 (function () {
755 nfMenuDivider.className = "dropdown-divider";
756 document.querySelector('nav.navbar ul.nav li.nav-item.dropdown.user-menu-parent .dropdown-menu').appendChild(nfMenuDivider);
757 document.querySelector('nav.navbar ul.nav li.nav-item.dropdown.user-menu-parent .dropdown-menu').appendChild(document.createElement("div"));
758 })();
759 } else {
760 (function () {
761 nfMenuDivider.className = "dropdown-divider";
762 var secondDivider = document.querySelectorAll('.user-menu-parent .dropdown-divider')[1];
763 secondDivider.parentNode.insertBefore(nfMenuDivider, secondDivider);
764 })();
765 }
766
767 function addItemToMemberMenu(text, onclick) {
768 var menuItem = document.createElement("a");
769 menuItem.className = "dropdown-item";
770 menuItem.href = "#NothingToLink";
771 menuItem.innerHTML = text;
772 menuItem.addEventListener('click', onclick);
773 nfMenuDivider.parentNode.insertBefore(menuItem, nfMenuDivider.nextSibling);
774 }
775
776 let vpngateCache = [],
777 vpngateCrawlledAt = -1;
778
779 function getVPNGateIPList() {
780 return new Promise((resolve, reject) => {
781 GM.xmlHttpRequest({
782 method: "GET",
783 url: "https://namufix.wikimasonry.org/vpngate/list",
784 onload: function (res) {
785 let resObj = JSON.parse(res.responseText);
786 if (resObj.success)
787 resolve(resObj.result);
788 else
789 reject(resObj.message);
790 }
791 });
792 });
793 }
794
795 async function checkVPNGateIP(ip) {
796 return new Promise((resolve, reject) => {
797 GM.xmlHttpRequest({
798 method: "GET",
799 url: "https://namufix.wikimasonry.org/vpngate/check/" + encodeURIComponent(ip),
800 onload: function (res) {
801 let resObj = JSON.parse(res.responseText);
802 if (resObj.success)
803 resolve(resObj.result);
804 else
805 reject(resObj.message);
806 }
807 });
808 });
809 }
810
811 function makeTabs() {
812 var div = document.createElement("div");
813 div.className = "nf-tabs";
814 div.innerHTML = "<ul></ul>";
815 var ul = div.querySelector("ul");
816 return {
817 tab: function (text) {
818 var item = document.createElement("li");
819 item.innerHTML = text;
820 item.addEventListener('click', function () {
821 var selectedTabs = div.querySelectorAll('li.selected');
822 for (var i = 0; i < selectedTabs.length; i++) {
823 selectedTabs[i].className = selectedTabs[i].className.replace(/selected/mg, '');
824 }
825 item.className = "selected";
826 });
827 ul.appendChild(item);
828 return {
829 click: function (callback) {
830 item.addEventListener('click', callback);
831 return this;
832 },
833 selected: function () {
834 if (item.className.indexOf('selected') == -1) item.className += ' selected';
835 return this;
836 }
837 };
838 },
839 get: function () {
840 return div;
841 }
842 };
843 }
844
845 // i : {author : {name, isIP}, defaultReason, defaultDuration}
846 function quickBlockPopup(i) {
847 let win = TooSimplePopup();
848 win.title("빠른 차단");
849 win.content(el => {
850 el.innerHTML = `<p>차단 대상 : ${encodeHTMLComponent(i.author.name)} ${i.author.isIP ? "<i>(IP)</i>" : "<i>(계정)</i>"}</p>차단 사유 : <input type="text" id="reason"></input><br>차단 기간 : <input type="number" id="duration"></input><a href="#" id="enterDuration">(간편하게 입력)</a><br><div id="allowLoginDiv">로그인 허용 : <input type="checkbox" id="allowLogin"></input></div><br><i>(참고 : NamuFix 설정에서 차단기간 기본값을 변경할 수 있습니다.)`
851 if (!i.author.isIP) {
852 el.querySelector('#allowLoginDiv').style.display = 'none';
853 }
854 el.querySelector('a#enterDuration').addEventListener('click', (evt) => {
855 evt.preventDefault();
856 enterTimespanPopup("차단기간 간편입력", (span) => {
857 if (span)
858 el.querySelector('#duration').value = span;
859 })
860 })
861 el.querySelector('#duration').value = i.defaultDuration;
862 el.querySelector('#reason').value = i.defaultReason;
863 el.querySelector('#reason').style.width = '500px';
864 el.querySelector('#reason').style.maxWidth = '30vw';
865 win.button('취소', win.close);
866 win.button('확인', () => {
867 if (i.author.isIP) {
868 namuapi.blockIP({
869 ip: i.author.name,
870 note: el.querySelector('#reason').value,
871 expire: el.querySelector('#duration').value,
872 allowLogin: el.querySelector('#allowLogin').checked
873 }, (err, data) => {
874 if (err)
875 alert('오류 발생 : ' + err);
876 else
877 win.close();
878 })
879 } else {
880 namuapi.blockAccount({
881 id: i.author.name,
882 note: el.querySelector('#reason').value,
883 expire: el.querySelector('#duration').value,
884 allowLogin: el.querySelector('#allowLogin').checked
885 }, (err, data) => {
886 if (err)
887 alert('오류 발생 : ' + err);
888 else
889 win.close();
890 })
891 }
892 });
893 });
894 }
895
896 function createDesigner(buttonBar) {
897 var Designer = {};
898 Designer.button = function (txt) {
899 var btn = document.createElement('button');
900 btn.className = 'NamaEditor NEMenuButton';
901 btn.setAttribute('type', 'button');
902 btn.innerHTML = txt;
903
904 buttonBar.appendChild(btn);
905 var r = {
906 click: function (func) {
907 btn.addEventListener('click', func);
908 return r;
909 },
910 hoverMessage: function (msg) {
911 btn.setAttribute('title', msg);
912 return r;
913 },
914 right: function () {
915 btn.className += ' NEright';
916 return r;
917 },
918 active: function () {
919 btn.setAttribute('active', 'yes');
920 return r;
921 },
922 deactive: function () {
923 btn.removeAttribute('active')
924 return r;
925 },
926 remove: function () {
927 btn.parentNode.removeChild(btn);
928 return r;
929 },
930 use: function () {
931 buttonBar.appendChild(btn);
932 return r;
933 }
934 };
935 return r;
936 };
937 Designer.dropdown = function (txt) {
938 var dropdownButton = document.createElement("div");
939 var dropdown = document.createElement("div");
940 var dropdownList = document.createElement("ul");
941 dropdownButton.innerHTML = '<div class="NEDropdownButtonLabel NamaEditor">' + txt + '</div>';
942 dropdownButton.className = 'NamaEditor NEMenuButton';
943 dropdown.className = 'NamaEditor NEDropDown';
944 dropdown.appendChild(dropdownList);
945 dropdownButton.appendChild(dropdown);
946 buttonBar.appendChild(dropdownButton);
947
948 var dbHover = false,
949 dbBHover = false;
950 dropdown.style.display = 'none';
951 dropdownButton.addEventListener('click', function () {
952 var dropdowns = buttonBar.querySelectorAll(".NamaEditor.NEMenuButton > .NamaEditor.NEDropDown");
953 for (var i = 0; i < dropdowns.length; i++) {
954 if (dropdowns[i] != dropdown) {
955 dropdowns[i].style.display = 'none';
956 dropdowns[i].parentNode.removeAttribute("hover");
957 } else if (dropdown.style.display.trim() == 'none') {
958 dropdown.style.display = '';
959 dropdownButton.setAttribute("hover", "yes");
960 } else {
961 dropdown.style.display = 'none';
962 dropdownButton.removeAttribute("hover");
963 }
964 }
965 });
966
967 var hr = {
968 button: function (iconTxt, txt) {
969 var liTag = document.createElement('li');
970 liTag.innerHTML = '<span class="NEHeadIcon">' + iconTxt + '</span><span class="NEDescText">' + txt + '</span>'
971 liTag.addEventListener('click', function () {
972 dropdown.style.display = '';
973 })
974 dropdownList.appendChild(liTag);
975 var r = {
976 icon: function (iconTxt) {
977 liTag.querySelector('.NEHeadIcon').innerHTML = iconTxt;
978 return r;
979 },
980 text: function (txt) {
981 liTag.querySElector('.NEDescText').innerHTML = txt;
982 return r;
983 },
984 hoverMessage: function (msg) {
985 liTag.setAttribute('title', msg);
986 return r;
987 },
988 click: function (handler) {
989 liTag.addEventListener('click', handler);
990 return r;
991 },
992 right: function () {
993 liTag.className += 'NEright';
994 return r;
995 },
996 remove: function () {
997 dropdownList.removeChild(liTag);
998 return r;
999 },
1000 insert: function () {
1001 dropdownList.appendChild(liTag);
1002 return r;
1003 },
1004 backwalk: function () {
1005 dropdownList.removeChild(ilTag);
1006 dropdownList.appendChild(ilTag);
1007 return r;
1008 }
1009 };
1010 return r;
1011 },
1012 right: function () {
1013 liTag.className += 'NEright';
1014 return hr;
1015 },
1016 hoverMessage: function (txt) {
1017 dropdownButton.setAttribute('title', txt);
1018 return hr;
1019 },
1020 clear: function () {
1021 dropdownList.innerHTML = '';
1022 return hr;
1023 }
1024 };
1025 return hr;
1026 };
1027 return Designer;
1028 }
1029
1030 function createTextProcessor(txtarea) {
1031 var r = {};
1032 r.value = function () {
1033 if (arguments.length == 0) return txtarea.value;
1034 else txtarea.value = arguments[0];
1035 };
1036 r.selectionText = function () {
1037 if (arguments.length == 0) return txtarea.value.substring(txtarea.selectionStart, txtarea.selectionEnd);
1038 else {
1039 var s = txtarea.selectionStart;
1040 var t = txtarea.value.substring(0, txtarea.selectionStart);
1041 t += arguments[0];
1042 t += txtarea.value.substring(txtarea.selectionEnd);
1043 txtarea.value = t;
1044 txtarea.focus();
1045 txtarea.selectionStart = s;
1046 txtarea.selectionEnd = s + arguments[0].length;
1047 }
1048 };
1049 r.selectionStart = function () {
1050 if (arguments.length == 0) return txtarea.selectionStart;
1051 else txtarea.selectionStart = arguments[0];
1052 };
1053 r.selectionTest = function (r) {
1054 return this.selectionText().search(r) != -1;
1055 };
1056 r.valueTest = function (r) {
1057 return this.value().search(r) != -1;
1058 };
1059 r.selectionEnd = function () {
1060 if (arguments.length == 0) return txtarea.selectionEnd;
1061 else txtarea.selectionEnd = arguments[0];
1062 };
1063 r.selectionLength = function () {
1064 if (arguments.length == 0) return (txtarea.selectionEnd - txtarea.selectionStart);
1065 else txtarea.selectionEnd = txtarea.selectionStart + arguments[0];
1066 };
1067 r.select = function (s, e) {
1068 txtarea.focus();
1069 txtarea.selectionStart = s;
1070 if (typeof e !== 'undefined') txtarea.selectionEnd = e;
1071 }
1072 r.WrapSelection = function (l, r) {
1073 if (arguments.length == 1) var r = l;
1074 var t = this.selectionText();
1075 if (typeof t === 'undefined' || t == null || t == '') t = '내용';
1076 var s = this.selectionStart()
1077 t = l + t + r;
1078 this.selectionText(t);
1079 this.select(s + l.length, s + t.length - r.length)
1080 };
1081 r.ToggleWrapSelection = function (l, r) {
1082 function isWrapped(t) {
1083 return t.indexOf(l) == 0 && t.lastIndexOf(r) == (t.length - r.length);
1084 }
1085 if (arguments.length == 1) var r = l;
1086 var t = this.selectionText();
1087 var t_m = this.value().substring(this.selectionStart() - l.length, this.selectionEnd() + r.length);
1088 var wrappedInSelection = isWrapped(t);
1089 var wrappedOutOfSelection = isWrapped(t_m);
1090 if (wrappedInSelection) {
1091 var s = this.selectionStart();
1092 this.selectionText(t.substring(l.length, t.length - r.length));
1093 this.select(s, s + t.length - l.length - r.length);
1094 } else if (wrappedOutOfSelection) {
1095 var s = this.selectionStart() - l.length;
1096 this.selectionStart(s);
1097 this.selectionEnd(s + t_m.length);
1098 this.selectionText(t_m.substring(l.length, t_m.length - r.length));
1099 this.select(s, s + t_m.length - l.length - r.length);
1100 } else {
1101 this.WrapSelection(l, r);
1102 }
1103 };
1104 return r;
1105 }
1106
1107 function getFile(callback, allowMultiple) {
1108 if (typeof allowMultiple === "undefined") var allowMultiple = false;
1109 var elm = document.createElement("input");
1110 elm.setAttribute("type", "file");
1111 if (allowMultiple) elm.setAttribute("multiple", "1");
1112 elm.style.visibility = "hidden";
1113 elm.setAttribute("accept", "image/*");
1114 document.body.appendChild(elm);
1115 elm.addEventListener('change', function (evt) {
1116 callback(evt.target.files, function () {
1117 document.body.removeChild(elm);
1118 })
1119 });
1120 elm.click();
1121 }
1122
1123 await INITSET();
1124 console.log("[NamuFix] 설정 초기화 완료");
1125 if (SET.umiCookie.trim().length !== 0) {
1126 document.cookie = 'umi=' + SET.umiCookie + ";path=/;domain=namu.wiki";
1127 console.log("[NamuFix] umi 쿠키 설정 완료");
1128 }
1129
1130 async function mainFunc() {
1131 // 환경 감지
1132 var ENV = {};
1133 ENV.IsSSL = /^https/.test(location.href);
1134 ENV.IsEditing = location.pathname.toLowerCase().indexOf('/edit/') == 0;
1135 ENV.Discussing = location.pathname.toLowerCase().indexOf('/thread/') == 0;
1136 ENV.IsDocument = location.pathname.toLowerCase().indexOf('/w/') == 0; //&& document.querySelector('p.wiki-edit-date');
1137 ENV.IsSettings = location.pathname.toLowerCase().indexOf('/settings/') == 0;
1138 ENV.IsHistory = location.pathname.toLowerCase().indexOf('/history/') == 0;
1139 ENV.IsUserContribsPage = /^\/contribution\/(?:author|ip)\/.+\/(?:document|discuss)/.test(location.pathname);
1140 ENV.IsUploadPage = location.pathname.toLowerCase().indexOf('/upload/') == 0;
1141 ENV.IsDiff = location.pathname.toLowerCase().indexOf('/diff/') == 0;
1142 ENV.IsLoggedIn = document.querySelectorAll('body.Liberty img.profile-img, img.user-img').length == 1;
1143 ENV.IsSearch = location.pathname.indexOf('/search/') == 0;
1144 ENV.IsEditingRequest = /^\/edit_request\/([0-9]+)\/edit/.test(location.pathname);
1145 ENV.IsWritingRequest = /^\/new_edit_request\/.+/.test(location.pathname);
1146 ENV.IsIPACL = /^\/admin\/ipacl/.test(location.pathname);
1147 ENV.IsBoardIPACL = /^\/admin\/boardipacl/.test(location.pathname);
1148 ENV.IsSuspendAccount = /^\/admin\/boardsuspendaccount/.test(location.pathname);
1149 ENV.IsBoardSuspendAccount = /^\/admin\/suspend_account/.test(location.pathname);
1150 ENV.IsBlockHistory = /^\/BlockHistory/.test(location.pathname);
1151 ENV.IsRecentChanges = location.pathname.indexOf('/RecentChanges') == 0;
1152 ENV.skinName = /(senkawa|Liberty|namuvector)/i.exec(document.body.className)[1].toLowerCase();
1153 if (location.pathname.indexOf('/edit_request') == 0)
1154 ENV.EditRequestNo = /^\/edit_request\/([0-9]+)/.exec(location.pathname);
1155 if (ENV.IsLoggedIn) {
1156 ENV.UserName = document.querySelector('body.Liberty .navbar-login .login-menu .dropdown-menu .dropdown-item:first-child, div.user-info > div.user-info > div:first-child').textContent.trim();
1157 }
1158 if (document.querySelector("input[name=section]"))
1159 ENV.section = document.querySelector("input[name=section]").value;
1160 if (document.querySelector("body.senkawa h1.title > a"))
1161 ENV.docTitle = document.querySelector("body.senkawa h1.title > a").textContent;
1162 else if (document.querySelector("body.senkawa h1.title"))
1163 ENV.docTitle = document.querySelector("body.senkawa h1.title").textContent;
1164 else if (ENV.skinName == "liberty" && ENV.IsDiscussing)
1165 ENV.docTitle = /^(.+) \(토론\)/.exec(document.querySelector('.liberty-content .liberty-content-header .title h1').textContent.trim())[1]
1166 else if (/^\/[a-zA-Z_]+\/(.+)/.test(location.pathname))
1167 ENV.docTitle = decodeURIComponent(/^\/[a-zA-Z_]+\/(.+)/.exec(location.pathname)[1]);
1168 else
1169 ENV.docTitle = document.querySelector('body.Liberty .liberty-content-header .title h1').textContent;
1170 ENV.docTitle = ENV.docTitle.trim();
1171 if (ENV.Discussing) {
1172 ENV.topicNo = /^\/thread\/([^#]+)/.exec(location.pathname)[1];
1173 ENV.topicTitle = document.querySelector('body.Liberty .wiki-article h2.wiki-heading:first-child , article > h2').innerHTML.trim();
1174 }
1175 if (ENV.IsDiff) {
1176 //ENV.docTitle = /diff\/(.+?)\?/.exec(location.href)[1];
1177 ENV.beforeRev = Number(/[\&\?]oldrev=([0-9]+)/.exec(location.href)[1]);
1178 ENV.afterRev = Number(/[\&\?]rev=([0-9]+)/.exec(location.href)[1]);
1179 }
1180 if (ENV.IsSearch) {
1181 ENV.SearchQuery = decodeURIComponent(location.pathname.substring(8));
1182 }
1183 if (nOu(ENV.section))
1184 ENV.section = -2;
1185 GM.xmlHttpRequest({
1186 method: "GET",
1187 url: "https://wtfismyip.com/json",
1188 onload: function (res) {
1189 var ip = JSON.parse(res.responseText).YourFuckingIPAddress;
1190 if (!ENV.IsLoggedIn) ENV.UserName = ip;
1191 ENV.IPAddress = ip;
1192 }
1193 });
1194
1195 // 서버 공지사항 확인
1196 // 서버 점검안내 같은거 이걸로 띄울 계획
1197 GM.xmlHttpRequest({
1198 method: 'GET',
1199 url: 'https://namufix.wikimasonry.org/notice?' + Date.now(),
1200 onload: (res) => {
1201 let obj;
1202 try {
1203 obj = JSON.parse(res.responseText);
1204 } catch (err) {
1205 console.error("[NamuFix] NamuFix 서버 관련 공지사항을 불러오는 중 오류가 발생했습니다.");
1206 console.error(err);
1207 }
1208 // after < now < before
1209 if (!obj.hasNotice) {
1210 return;
1211 } else if (obj.validAfter && obj.validAfter >= Date.now()) {
1212 return;
1213 } else if (obj.validBefore && obj.validBefore <= Date.now()) {
1214 return;
1215 } else if (obj.id && SET.checkedServerNotices.includes(obj.id)) {
1216 return;
1217 }
1218 console.log("[NamuFix] 서버 공지사항 존재함.");
1219 if (obj.id) {
1220 SET.checkedServerNotices.push(obj.id);
1221 SET.save();
1222 }
1223 if (obj.content) {
1224 let win = TooSimplePopup();
1225 win.title("서버 공지사항");
1226 win.content(e => e.innerHTML = obj.content);
1227 win.button('닫기', win.close);
1228 }
1229 if (obj.hotfix) {
1230 eval(obj.hotfix);
1231 }
1232 if (obj.easterEgg) {
1233 eval(obj.easterEgg);
1234 }
1235 }
1236 });
1237 eval(SET.additionalScript);
1238
1239 if (ENV.IsEditing || ENV.Discussing || ENV.IsEditingRequest || ENV.IsWritingRequest) {
1240 if (document.querySelector("textarea") !== null && !document.querySelector("textarea").hasAttribute("readonly")) {
1241 var rootDiv = document.createElement("div");
1242 if (ENV.IsEditing || ENV.IsEditingRequest || ENV.IsWritingRequest) {
1243 // 탭 추가
1244 var previewTab = document.createElement("div");
1245 var diffTab = document.createElement("div");
1246 var initalPreviewTabHTML = '<iframe id="nfPreviewFrame" name="nfPreviewFrame" style="width: 100%; height: 600px; display: block; border: 1px solid black;"></iframe>';
1247 document.querySelector('textarea').parentNode.insertBefore(previewTab, document.querySelector('textarea').nextSibling);
1248 document.querySelector('textarea').parentNode.insertBefore(diffTab, document.querySelector('textarea').nextSibling);
1249
1250 // 나무위키 자체 편집/미리보기 탭 제거
1251 document.querySelector('#editForm .nav.nav-tabs').setAttribute("style", "display:none;");
1252
1253 function hideAndShow(no) {
1254 rootDiv.style.display = no == 0 ? '' : 'none';
1255 previewTab.style.display = no == 1 ? '' : 'none';
1256 diffTab.style.display = no == 2 ? '' : "none";
1257 }
1258 hideAndShow(0);
1259 var tabs = makeTabs();
1260 tabs.tab("편집").selected().click(function () {
1261 hideAndShow(0);
1262 });
1263 tabs.tab("미리보기").click(function () {
1264 previewTab.innerHTML = initalPreviewTabHTML;
1265 hideAndShow(1);
1266 var form = document.querySelector('form#editForm');
1267 form.setAttribute("method", "POST");
1268 form.setAttribute("target", "nfPreviewFrame");
1269 form.setAttribute("action", "/preview/" + ENV.docTitle);
1270 form.submit();
1271 });
1272 tabs.tab("비교").click(function () {
1273 hideAndShow(2);
1274 diffTab.innerHTML = '<span style="font-size: 15px;">처리중입니다...</span>';
1275 var editUrl = 'https://' + location.host + (ENV.IsWritingRequest ? '/new_edit_request/' : '/edit/').concat(ENV.docTitle, ENV.section != -2 ? '?section='.concat(ENV.section) : '');
1276 if (ENV.IsEditingRequest)
1277 editUrl = location.href; // 귀찮음....
1278 namuapi.theseedRequest({
1279 url: editUrl,
1280 method: "GET",
1281 onload: function (res) {
1282 var parser = new DOMParser();
1283 var doc = parser.parseFromString(res.responseText, "text/html");
1284 var latestBaseRev = doc.querySelector('input[name="baserev"]').value;
1285 var token = doc.querySelector('input[name="token"]').value;
1286
1287 //update edit token
1288 document.querySelector('input[name="token"]').value = token;
1289
1290 if (doc.querySelectorAll('textarea').length < 1) {
1291 diffTab.innerHTML = '<span style="font-size: 15px; color:red;">오류가 발생했습니다.</span>';
1292 return;
1293 }
1294 var remoteWikitext = doc.querySelector('textarea').value;
1295 var wikitext = document.querySelector("textarea.NamaEditor.NETextarea").value;
1296 diffTab.innerHTML = '<div style="width: 100%;">' +
1297 '<div style="padding: 0; width: 100%; margin: 0px; max-height: 600px; overflow: scroll;" id="diffResult">' +
1298 '</div>' +
1299 '</div>';
1300 var result = diffTab.querySelector('#diffResult');
1301 var base = difflib.stringAsLines(remoteWikitext);
1302 var newtxt = difflib.stringAsLines(wikitext);
1303
1304 // create a SequenceMatcher instance that diffs the two sets of lines
1305 var sm = new difflib.SequenceMatcher(base, newtxt);
1306 var opcodes = sm.get_opcodes();
1307
1308 while (result.firstChild) result.removeChild(result.firstChild);
1309 result.appendChild(diffview.buildView({
1310 baseTextLines: base,
1311 newTextLines: newtxt,
1312 opcodes: opcodes,
1313 // set the display titles for each resource
1314 baseTextName: "리비전 r" + document.querySelector('input[name="baserev"]').value,
1315 newTextName: "편집중",
1316 contextSize: 3,
1317 viewType: 2
1318 }));
1319 }
1320 });
1321 });
1322 document.querySelector("#editForm").insertBefore(tabs.get(), document.querySelector("#editForm").firstChild);
1323 }
1324
1325 // Init (Add Elements)
1326 var buttonBar = document.createElement('div');
1327 var txtarea = document.createElement('textarea');
1328 buttonBar.className = 'NamaEditor NEMenu';
1329 txtarea.className = 'NamaEditor NETextarea'
1330 txtarea.name = document.querySelector("textarea").name;
1331 rootDiv.className += ' NamaEditor NERoot';
1332 rootDiv.appendChild(buttonBar);
1333 rootDiv.appendChild(txtarea);
1334
1335 // Functions To Design
1336 var Designer = createDesigner(buttonBar);
1337
1338 // Functions To Process
1339 var TextProc = createTextProcessor(txtarea);
1340
1341 // Some Basic MarkUp Functions
1342 function FontSizeChanger(isIncrease) {
1343 var pattern = /^{{{\+([1-5]) (.+?)}}}$/;
1344 var t, s = TextProc.selectionStart();
1345 if (TextProc.selectionTest(pattern)) {
1346 var t = TextProc.selectionText();
1347 var fontSize = t.replace(pattern, '$1');
1348 var innerText = t.replace(pattern, '$2');
1349 if (isIncrease) fontSize++;
1350 else fontSize--;
1351
1352 if (5 < fontSize) fontSize = 5;
1353 if (fontSize < 1) fontSize = 1;
1354 t = '{{{+' + fontSize + ' ' + innerText + '}}}';
1355 } else {
1356 t = '{{{+1 ' + TextProc.selectionText() + '}}}';
1357 }
1358 TextProc.selectionText(t);
1359 TextProc.select(s, s + t.length);
1360 }
1361
1362 function NewHeadingMacro() {
1363 var headingPattern = /^(=+) (.+?) (=+)$/;
1364
1365 function repeatString(str, count) {
1366 var r = "";
1367 for (var i = 0; i < count; i++) r += str;
1368 return r;
1369 }
1370 if (TextProc.selectionTest(headingPattern)) {
1371 var matches = headingPattern.exec(TextProc.selectionText());
1372 var headingLevel, headingLevel_Left = matches[1].length,
1373 headingContent = matches[2],
1374 headingLevel_Right = matches[3].length;
1375 if (headingLevel_Left != headingLevel_Right) {
1376 // normalize
1377 TextProc.selectionText(repeatString("=", headingLevel_Left) + " " + headingContent + " " + repeatString("=", headingLevel_Left));
1378 return;
1379 } else {
1380 headingLevel = headingLevel_Left;
1381 if (headingLevel < 6) headingLevel++;
1382 else if (headingLevel == 6) headingLevel = 1;
1383 else if (headingLevel > 6) headingLevel = 6;
1384 TextProc.selectionText(repeatString("=", headingLevel) + " " + headingContent + " " + repeatString("=", headingLevel));
1385 return;
1386 }
1387 } else if (TextProc.selectionText().length == 0) {
1388 TextProc.selectionText("\n== 제목 ==\n")
1389 TextProc.selectionStart(TextProc.selectionStart() + 1);
1390 TextProc.selectionEnd(TextProc.selectionEnd() - 1);
1391 } else if (TextProc.selectionText().length != 0) {
1392 TextProc.selectionText("\n== " + TextProc.selectionText().replace(/\n/mg, '[br]') + " ==\n")
1393 TextProc.selectionStart(TextProc.selectionStart() + 1);
1394 TextProc.selectionEnd(TextProc.selectionEnd() - 1);
1395 }
1396 }
1397
1398 function TextColorChange() {
1399 var colorMarkUpPattern = /^{{{(#[a-zA-Z0-9]+) (.*)}}}$/;
1400 var color = '#000000',
1401 text = '';
1402 if (TextProc.selectionTest(colorMarkUpPattern)) {
1403 // 색상 마크업이 적용된 텍스트
1404 var matches = colorMarkUpPattern.exec(TextProc.selectionText());
1405 color = matches[1];
1406 text = matches[2];
1407 } else if (TextProc.selectionText().lengh == 0) {
1408 // 선택된 텍스트 없음
1409 text = '내용';
1410 } else {
1411 // 텍스트 선택됨
1412 text = TextProc.selectionText();
1413 }
1414 var w = window.TooSimplePopup();
1415 var c = w.close;
1416 w.title('색 지정').content(function (e) {
1417 var pickerWrapper = document.createElement('div');
1418 var sliderWrapper = document.createElement('div');
1419 var picker = document.createElement('div');
1420 var slider = document.createElement('div');
1421 var pickerIndicator = document.createElement('div');
1422 var sliderIndicator = document.createElement('div');
1423 var colorPreview = document.createElement('div');
1424 pickerWrapper.appendChild(picker);
1425 pickerWrapper.appendChild(pickerIndicator);
1426 sliderWrapper.appendChild(slider);
1427 sliderWrapper.appendChild(sliderIndicator);
1428
1429 picker.className = "NamaEditor FlexiColorPicker Picker";
1430 pickerIndicator.className = "NamaEditor FlexiColorPicker PickerIndicator";
1431 slider.className = "NamaEditor FlexiColorPicker Slider";
1432 sliderIndicator.className = "NamaEditor FlexiColorPicker SliderIndicator";
1433 pickerWrapper.className = "NamaEditor FlexiColorPicker PickerWrapper";
1434 sliderWrapper.className = "NamaEditor FlexiColorPicker SliderWrapper";
1435 colorPreview.className = "NamaEditor FlexiColorPicker ColorPreview";
1436
1437 ColorPicker.fixIndicators(
1438 sliderIndicator,
1439 pickerIndicator
1440 );
1441 ColorPicker(slider, picker, function (hex, hsv, rgb, pickerCo, sliderCo) {
1442 ColorPicker.positionIndicators(
1443 sliderIndicator,
1444 pickerIndicator,
1445 sliderCo, pickerCo
1446 );
1447 color = hex;
1448 var reversedColor = {
1449 r: 255 - rgb.r,
1450 g: 255 - rgb.g,
1451 b: 255 - rgb.b
1452 };
1453 colorPreview.style.background = `rgb(${reversedColor.r}, ${reversedColor.g}, ${reversedColor.b})`;
1454 colorPreview.style.color = color;
1455 colorPreview.innerText = color;
1456 }).setHex(color);
1457
1458 e.appendChild(pickerWrapper);
1459 e.appendChild(sliderWrapper);
1460 e.appendChild(colorPreview);
1461 }).button('지정', function () {
1462 TextProc.selectionText('{{{' + color + ' ' + text + '}}}');
1463 c();
1464 }).button('닫기', c);
1465 }
1466
1467 // Add Basic MarkUp Buttons
1468 var decoDropdown = Designer.dropdown('<span class="ion-wand fa fa-magic"></span>').hoverMessage('텍스트 꾸미기');
1469 decoDropdown.button('<strong>A</strong>', '굵게').click(function () {
1470 TextProc.ToggleWrapSelection("'''");
1471 });
1472 decoDropdown.button('<em>A</em>', '기울임꼴').click(function () {
1473 TextProc.ToggleWrapSelection("''");
1474 });
1475 decoDropdown.button('<del>A</del>', '취소선').click(function () {
1476 TextProc.ToggleWrapSelection("--");
1477 });
1478 decoDropdown.button('<span style="text-decoration: underline;">A</span>', '밑줄').click(function () {
1479 TextProc.ToggleWrapSelection("__");
1480 });
1481 decoDropdown.button('<span style="color:red;">A</span>', '글씨색').click(TextColorChange);
1482 decoDropdown.button('-', '글씨 작게').click(function () {
1483 FontSizeChanger(false);
1484 });
1485 decoDropdown.button('+', '글씨 크게').click(function () {
1486 FontSizeChanger(true);
1487 });
1488
1489 // Insertable Media Functions
1490 function namuUpload(present_files, present_finisher) {
1491 if (typeof present_files === 'undefined' || present_files.screenX)
1492 var present_files = null;
1493 if (typeof present_finisher === 'undefined')
1494 var present_finisher = function () {};
1495
1496 function getCopyrightInfo(callback) {
1497 var win = TooSimplePopup();
1498 var contelem;
1499 win.title("저작권 정보");
1500 win.content(function (el) {
1501 contelem = el;
1502 el.innerHTML = '<label>출처</label><input type="text" class="cpinfo" data-name="출처"></input><br>' +
1503 '<label>날짜</label><input type="text" class="cpinfo" data-name="날짜"></input><br>' +
1504 '<label>저작자</label><input type="text" class="cpinfo" data-name="저작자"></input><br>' +
1505 '<label>저작권</label><input type="text" class="cpinfo" data-name="저작권"></input><br>' +
1506 '<label>기타</label><input type="text" class="cpinfo" data-name="기타"></input><br>' +
1507 '<label>설명</label><input type="text" class="cpinfo" data-name="설명"></input>' +
1508 '<p>라이선스, 분류는 구현하기 귀찮습니다. 라이선스는 저작권란에 알아서 써주시고 분류는 알아서 하세요.</p>'
1509 });
1510 win.button("삽입", function () {
1511 var result = "== 기본 정보 ==\n";
1512 var cpinfos = contelem.querySelectorAll(".cpinfo");
1513 for (var i = 0; i < cpinfos.length; i++) {
1514 var cpinfo = cpinfos[i];
1515 result += "|| " + cpinfo.dataset.name + " || " + cpinfo.value + " ||\n";
1516 }
1517 result += `\n\n== 기타 ==\n[[NamuFix]] ${GM.info.script.version} 버전을 이용하여 업로드된 이미지입니다.`;
1518 callback(result);
1519 });
1520 win.button("닫기", win.close);
1521 }
1522 getCopyrightInfo(function (docuText) {
1523 function getFileCallback(files, finish) {
1524 if (files.length == 0) {
1525 alert('선택된 파일이 없습니다');
1526 return finish();
1527 }
1528
1529 let uploadLimit = SET.fileUploadReqLimit || 3;
1530 let win = TooSimplePopup();
1531 win.title('업로드 중...');
1532 win.content(el => el.innerHTML = `현재 ${uploadLimit}개의 이미지들을 동시에 업로드하고 있습니다. 설정에서 변경가능합니다.<br><ul><li>현재 진행중</li><li><ul id="inprog"></ul></li></ul>`);
1533 async.mapLimit(files, uploadLimit, (file, callback) => {
1534 if (!file) callback(null, null);
1535 var fn = "파일:" + SHA256(String(Date.now()) + file.name).substring(0, 6) + "_" + file.name;
1536 if (SET.unprefixedFilename) {
1537 fn = "파일:" + file.name;
1538 }
1539 if (/\.[A-Z]+$/.test(fn)) {
1540 var fnSplitted = fn.split('.');
1541 fnSplitted[fnSplitted.length - 1] = fnSplitted[fnSplitted.length - 1].toLowerCase();
1542 fn = fnSplitted.join('.');
1543 } else if (/\.jpeg$/i.test(fn)) {
1544 return callback(null, {
1545 success: false,
1546 reason: '확장자가 jpeg인 경우 오류가 발생합니다. jpg로 변경해주세요.',
1547 name: file.name
1548 });
1549 }
1550 var uploadImageParams = {
1551 file: file,
1552 fn: fn,
1553 docuText: docuText,
1554 log: "NamuFix " + GM.info.script.version + "버전으로 자동 업로드됨.",
1555 identifier: (ENV.IsLoggedIn ? "m" : "i") + ":" + ENV.UserName
1556 };
1557 let currentListItem = document.createElement("li");
1558 currentListItem.textContent = `${file.name} -> ${fn}`;
1559 win.content(el => el.querySelector('ul#inprog').appendChild(currentListItem));
1560 namuapi.uploadImage(uploadImageParams, function (err, resultName) {
1561 if (err === null) {
1562 callback(null, {
1563 success: true,
1564 name: file.name,
1565 docName: resultName
1566 });
1567 } else if (err === "recaptcha_required") {
1568 namuapi.resolveRecaptcha(function (res) {
1569 if (res == null) {
1570 alert('reCAPTCHA를 입력하지 않아 건너뜁니다.');
1571 callback(null, {
1572 success: false,
1573 reason: 'reCAPTCHA를 입력하지 않았습니다.',
1574 name: file.name
1575 });
1576 return;
1577 }
1578 uploadImageParams.recaptchaKey = res;
1579 namuapi.uploadImage(uploadImageParams, (err, resultName) => {
1580 if (err === null) {
1581 callback(null, {
1582 success: true,
1583 name: file.name,
1584 docName: resultName
1585 });
1586 } else if (err === "recaptcha_required") {
1587 callback(null, {
1588 success: false,
1589 reason: 'reCAPTCHA를 해결하였으나 다시 한번 더 요구받았습니다.',
1590 name: file.name
1591 });
1592 } else if (err === "html_error") {
1593 callback(null, {
1594 success: false,
1595 reason: '예상하지 못한 오류입니다. 자세한 정보를 다운로드하여 NamuFix 이슈트래커에 제보해주세요.',
1596 name: file.name,
1597 data: resultName
1598 });
1599 } else {
1600 callback(null, {
1601 success: false,
1602 reason: '알 수 없는 오류입니다.',
1603 name: file.name
1604 });
1605 }
1606 });
1607 });
1608 } else if (err === "html_error") {
1609 callback(null, {
1610 success: false,
1611 reason: '예상하지 못한 오류입니다. 자세한 정보를 다운로드하여 NamuFix 이슈트래커에 제보해주세요.',
1612 name: file.name,
1613 data: resultName
1614 });
1615 } else {
1616 callback(null, {
1617 success: false,
1618 reason: '알 수 없는 오류입니다.',
1619 name: file.name
1620 });
1621 }
1622 win.content(el => el.querySelector('ul#inprog').removeChild(currentListItem));
1623 });
1624 }, (err, results) => {
1625 if (err) {
1626 alert('오류가 발생했습니다.');
1627 console.error("[NamuFix] 이미지 업로드 중 오류가 발생했습니다.");
1628 console.error(err);
1629 return;
1630 }
1631 finish();
1632 win.close();
1633 results = results.filter(v => v !== null);
1634 let hasError = false;
1635 let imgLinks = '';
1636 for (i of results) {
1637 if (i.success) {
1638 imgLinks += '[[' + i.docName + ']]';
1639 } else {
1640 hasError = true;
1641 }
1642 }
1643 if (imgLinks.length != 0)
1644 TextProc.selectionText(TextProc.selectionText() + imgLinks);
1645 if (hasError) {
1646 let win = TooSimplePopup();
1647 win.title('이미지 업로드 오류');
1648 win.content(el => {
1649 el.innerHTML = '<p>오류들이 발생했습니다.</p><ul></ul>';
1650 for (let i of results.filter(v => !v.success)) {
1651 let li = document.createElement("li");
1652 li.textContent = `${i.name} : ${i.reason}`;
1653 if (i.data)
1654 li.innerHTML += ` <a href="${URL.createObjectURL(new Blob(i.data, 'text/html'))}" download="log.html">(자세한 정보 다운로드)</a>`
1655 el.querySelector('ul').appendChild(li);
1656 }
1657 })
1658 win.button('닫기', win.close);
1659 }
1660 })
1661 }
1662 if (present_files != null) {
1663 getFileCallback(present_files, present_finisher);
1664 } else {
1665 getFile(getFileCallback, true);
1666 }
1667 })
1668 }
1669
1670 function InsertYouTube() {
1671 var win = TooSimplePopup();
1672 win.title('YouTube 동영상 삽입');
1673 win.content(function (el) {
1674 el.innerHTML = '<p style="background: cyan; box-shadow: 2px 2px 2px gray; color:white; padding: 8px; border-radius: 3px; margin-bottom: 5px;">YouTube 동영상을 검색하거나 동영상 주소를 입력하여 YouTube 동영상을 삽입할 수 있습니다.</p>' +
1675 '<p><label for="vidUrl" style="width: 120px; display: inline-block;">YouTube 동영상 주소</label><input type="text" name="vidUrl" id="vidUrl" style="width:620px; max-width: 100vw;"></input><button id="insertUrl">삽입</button></p>' +
1676 '<hr>' +
1677 '<div>' +
1678 '<label for="vidQuery" style="width: 120px; display: inline-block;">검색어</label><input type="text" name="vidQuery" id="vidQuery" style="width:620px; max-width: 100vw;"></input><button id="searchVids">검색</button>' +
1679 '<div id="results" style="overflow-y: scroll; overflow-x: hidden; width: 820px; max-width: 100vw; height: 400px; max-height: calc(100vh - 300px);"><span style="color:red">검색 결과가 없습니다.</span></div>' +
1680 '</div>';
1681 })
1682 var finish = function (vid) {
1683 if (vid == null) {
1684 alert('무언가 잘못된것 같습니다.');
1685 return;
1686 }
1687 TextProc.selectionText(TextProc.selectionText() + '[youtube(' + vid + ')]');
1688 win.close();
1689 }
1690 // 주소로 삽입 기능
1691 win.content(function (el) {
1692 var ExtractYouTubeID = function (url) {
1693 // from Lasnv's answer from http://stackoverflow.com/questions/3452546/javascript-regex-how-to-get-youtube-video-id-from-url
1694 var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
1695 var match = url.match(regExp);
1696 if (match && match[7].length == 11)
1697 return match[7];
1698 else
1699 return null;
1700 }
1701 var insertByUrlFunc = function () {
1702 var url = el.querySelector('#vidUrl').value;
1703 var vidId = ExtractYouTubeID(url);
1704 finish(vidId);
1705 }
1706 el.querySelector('#insertUrl').addEventListener('click', insertByUrlFunc);
1707 el.querySelector('#vidUrl').addEventListener('keyup', function (evt) {
1708 if (evt.which == 13 || evt.keycode == 13) {
1709 insertByUrlFunc();
1710 return false;
1711 }
1712 })
1713 });
1714 // 검색 기능
1715 win.content(function (el) {
1716 // https://developers.google.com/youtube/v3/docs/search/list
1717 var vidSearchFunc = function () {
1718 var q = el.querySelector('#vidQuery').value;
1719 var resultDiv = el.querySelector('#results');
1720 resultDiv.innerHTML = '<span style="color:orange;">검색중입니다.</span>'
1721 GM.xmlHttpRequest({
1722 method: "GET",
1723 url: 'https://namufix.wikimasonry.org/youtube/search?q=' + encodeURIComponent(q),
1724 onload: function (res) {
1725 var jobj = JSON.parse(res.responseText);
1726 resultDiv.innerHTML = '<ul></ul>';
1727 var ul = resultDiv.querySelector('ul');
1728 if (res.status != 200 || !jobj.success) {
1729 resultDiv.innerHTML = '<span style="color:red;">검색중 오류가 발생했습니다.</span>';
1730 return;
1731 }
1732 for (var i = 0; i < jobj.items.length; i++) {
1733 var vidNow = jobj.items[i];
1734 var li = document.createElement("li");
1735 li.height = '90px';
1736 li.innerHTML = '<img style="height: 90px;" src="//namufix.wikimasonry.org/youtube/thumb/' + vidNow.id.videoId + '"></img>' +
1737 '<div style="position: relative; display: inline-block; margin-left: 5px; overflow: hidden; width: 670px; max-width: 100vw;">' +
1738 '<span style="font-weight: bold; font-size: 12pt; margin-bottom: 3px;">' + vidNow.snippet.title + '</span><button name="insertThis" class="moreFlat">삽입</button><button name="preview" class="moreFlat">미리보기</button><br><span style="font-size:10pt;">' + vidNow.snippet.description + '</span>' +
1739 '</div>';
1740 li.querySelector('[name="preview"]').parentNode.dataset.videoId = vidNow.id.videoId;
1741 li.querySelector('[name="preview"]').addEventListener('click', function (evt) {
1742 var previewWin = TooSimplePopup();
1743 previewWin.title('미리보기');
1744 previewWin.content(function (el) {
1745 var iframe = document.createElement("iframe");
1746 iframe.setAttribute("frameborder", "0");
1747 iframe.setAttribute("src", "//www.youtube.com/embed/" + evt.target.parentNode.dataset.videoId);
1748 iframe.style.height = "360px";
1749 iframe.style.width = "640px";
1750 iframe.style.maxWidth = "100vw";
1751 iframe.style.maxHeight = "calc(100vh - 80px)"
1752 el.appendChild(iframe);
1753 });
1754 previewWin.button('닫기', previewWin.close);
1755 });
1756 li.querySelector('[name="insertThis"]').addEventListener('click', function (evt) {
1757 finish(evt.target.parentNode.dataset.videoId);
1758 })
1759 ul.appendChild(li);
1760 }
1761 }
1762 });
1763 };
1764
1765 el.querySelector('#searchVids').addEventListener('click', vidSearchFunc);
1766 el.querySelector('#vidQuery').addEventListener('keyup', function (evt) {
1767 if (evt.which == 13 || evt.keycode == 13) {
1768 vidSearchFunc();
1769 return false;
1770 }
1771 })
1772 el.querySelector('#vidUrl').focus();
1773 })
1774 win.button('닫기', win.close);
1775 }
1776
1777 function MapMacro() {
1778 // title(text), content(callback), foot(callback), button(text,onclick), close
1779 var win = TooSimplePopup();
1780 win.title("지도 삽입");
1781 win.content(function (el) {
1782 var mapDiv = document.createElement("div");
1783 mapDiv.id = "NFMapDiv";
1784 mapDiv.style.maxHeight = "calc(100vh - 100px)";
1785 mapDiv.style.maxWidth = "100vw";
1786 mapDiv.style.height = '480px';
1787 mapDiv.style.width = '640px';
1788 el.appendChild(mapDiv);
1789 var initFuncContext = 'var NFMap;\n' +
1790 'function NFMapInit(){\n' +
1791 'var firstLocation=new google.maps.LatLng(37.46455,126.67435);\n' +
1792 'var mapOptions={\n' +
1793 'zoom: 8,\n' +
1794 'center: firstLocation\n,' +
1795 'streetViewControl: false\n' +
1796 '};\n' +
1797 'NFMap=new google.maps.Map(document.querySelector("#NFMapDiv"),mapOptions);\n' +
1798 '}';
1799 var onloadScript = document.createElement("script");
1800 onloadScript.innerHTML = initFuncContext;
1801 el.appendChild(onloadScript);
1802 setTimeout(function () {
1803 var mapsAPILib = document.createElement("script");
1804 mapsAPILib.setAttribute("src", "//maps.googleapis.com/maps/api/js?key=AIzaSyAqi9PjUr_F54U0whrbMeavFfvNap3kjvA&callback=NFMapInit");
1805 el.appendChild(mapsAPILib);
1806 }, 500);
1807 });
1808 win.button("삽입", function () {
1809 var lat = unsafeWindow.NFMap.getCenter().lat();
1810 var lng = unsafeWindow.NFMap.getCenter().lng();
1811 var zoom = unsafeWindow.NFMap.getZoom();
1812 TextProc.selectionText(TextProc.selectionText() + ' [Include(틀:지도,장소=' + lat + '%2C' + lng + ',zoom=' + zoom + ')] ');
1813 win.close();
1814 })
1815 win.button("닫기", win.close);
1816 }
1817
1818 function DaumTVPotMarkUp() {
1819 var vurl = prompt('참고 : 개발중인 기능이므로 이상하게 작동할 수 있습니다.\n\n1. 삽입하고픈 TV팟 동영상을 봅니다\n2. 공유 버튼을 누릅니다.\n3. 거기서 복사한 URL을 입력하십시오.');
1820 var pattern2 = /http:\/\/tvpot\.daum\.net\/v\/(.+?)/;
1821 if (!pattern2.test(vurl)) {
1822 alert('지원되지 않는 주소 형식입니다.')
1823 } else {
1824 TextProc.selectionText(TextProc.selectionText() + '{{{#!html <iframe src="//videofarm.daum.net/controller/video/viewer/Video.html?vid=' + vurl.replace(pattern2, '$1') + '&play_loc=undefined&alert=true" style="max-height: 100%; max-width:100%;" frameborder=\'0\' scrolling=\'0\' width=\'640px\' height=\'360px\'></iframe>}}}');
1825 }
1826 };
1827 // Add Insertable Things
1828 var insertablesDropDown = Designer.dropdown('<span class="ion-paperclip fa fa-paperclip"></span>').hoverMessage('삽입 가능한 미디어');
1829 insertablesDropDown.button('<span class="ion-social-youtube fa fa-youtube-play" style="color:red;"></span>', 'YouTube 동영상').click(InsertYouTube);
1830 insertablesDropDown.button('<span class="ion-map fa fa-map"></span>', '지도').click(MapMacro);
1831 insertablesDropDown.button('<span class="ion-ios-play-outline fa fa-play" style="color: Aqua;"></span>', '다음 TV팟 동영상').click(DaumTVPotMarkUp);
1832 insertablesDropDown.button('<span class="ion-images fa fa-picture-o" style="color: #008275;"></span>', '이미지 업로드').click(namuUpload);
1833
1834 Designer.button('<span class="ion-ios-grid-view fa fa-table"></span>').hoverMessage('간단한 표 만들기').click(function () {
1835 var numbers = prompt('행과 열을 행숫x열숫 형태로 입력해주세요. 예시: 2x3, 2*3')
1836 if (/([0-9]+).([0-9]+)/.test(numbers)) {
1837 var num_matches = /([0-9]+).([0-9]+)/.exec(numbers)
1838 numbers = [parseInt(num_matches[1]), parseInt(num_matches[2])];
1839 } else {
1840 alert('입력이 올바르지 않습니다.');
1841 return;
1842 }
1843 var win = TooSimplePopup();
1844 win.title('간단한 표 만들기');
1845 win.content(function (container) {
1846 // cell: align( left=(, center=:, right=) ), rowspan (^|0-9 |0-9 v|0-9), colspan bgcolor, width, height
1847 // row: rowbgcolor
1848 // table: align, bgcolor, bordercolor, width
1849 container.innerHTML = '<strong>현재 실험중인 기능입니다. 불안정할 수 있습니다.</strong><br>표를 만듭니다.... 공대 감성을 듬뿍 담아 디자인했습니다.<br>칸 안에는 나무마크 위키텍스트를 입력하면 됩니다.<br>Ctrl + 화살표 단축키로 칸 사이를 이동할 수 있습니다.' +
1850 '<style>#target-table td {border: 1px solid #dddddd; padding: 5px 10px} #target-table tr {background-color: #f5f5f5 border-collapse: collapse;}</style>' +
1851 '<table id="target-table"></table>' +
1852 '<div style="display: none;"><button id="disableShortcut" onclick="window.namu.disableShortcutKey=true;"></button><button id="enableShortcut" onclick="window.namu.disableShortcutKey=false;"></button></div>';
1853 var table = container.querySelector('table');
1854 document.querySelector('#disableShortcut').click();
1855 for (var i = 0; i < numbers[1]; i++) {
1856 var row = document.createElement("tr");
1857 for (var j = 0; j < numbers[0]; j++)
1858 row.innerHTML += '<td contenteditable="true"></td>';
1859 var cols = row.querySelectorAll('td');
1860 for (var j = 0; j < cols.length; j++) {
1861 cols[j].addEventListener('keyup', function (evt) {
1862 var doPreventDefault = true;
1863 var cellOrder = [].indexOf.call(evt.target.parentNode.querySelectorAll('td'), evt.target);
1864 if (evt.key == "ArrowDown" && evt.ctrlKey) {
1865 var nearCols = evt.target.parentNode.nextElementSibling.querySelectorAll('td');
1866 nearCols[cellOrder < nearCols.length ? cellOrder : 0].focus();
1867 } else if (evt.key == "ArrowUp" && evt.ctrlKey) {
1868 var nearCols = evt.target.parentNode.previousElementSibling.querySelectorAll('td');
1869 nearCols[cellOrder < nearCols.length ? cellOrder : 0].focus();
1870 } else if (evt.key == "ArrowRight" && evt.ctrlKey) {
1871 evt.target.nextSibling.focus();
1872 } else if (evt.key == "ArrowLeft" && evt.ctrlKey) {
1873 evt.target.previousSibling.focus();
1874 } else {
1875 doPreventDefault = false;
1876 }
1877 if (doPreventDefault)
1878 evt.preventDefault();
1879 });
1880 }
1881 table.appendChild(row);
1882 }
1883 win.button('닫기', function () {
1884 document.querySelector('#enableShortcut').click();
1885 win.close();
1886 });
1887 win.button('삽입', function () {
1888 var rows = table.querySelectorAll('tr');
1889 var result = '';
1890 for (var i = 0; i < rows.length; i++) {
1891 result += '||';
1892 var cols = rows[i].querySelectorAll('td');
1893 for (var j = 0; j < cols.length; j++) {
1894 result += cols[j].innerHTML + '||';
1895 }
1896 result += '\n';
1897 }
1898 TextProc.selectionText(TextProc.selectionText() + '\n' + result);
1899 document.querySelector('#enableShortcut').click();
1900 win.close();
1901 });
1902 });
1903 })
1904
1905 Designer.button('<span class="ion-checkmark-round fa fa-check"></span>').hoverMessage('맞춤법 검사기').click(function () {
1906 var win = TooSimplePopup();
1907 win.title('나사 빠진 맞춤법 검사기');
1908 win.content(function (el) {
1909 el.innerHTML = "진행중입니다...."
1910 });
1911 var textToCheck = TextProc.selectionText().length == 0 ? TextProc.value() : TextProc.selectionText();
1912 GM.xmlHttpRequest({
1913 url: 'https://namufix.wikimasonry.org/kospell',
1914 method: 'POST',
1915 data: JSON.stringify({
1916 text: textToCheck,
1917 unstripNamumark: true
1918 }),
1919 headers: {
1920 "Content-Type": "application/json"
1921 },
1922 onload: function (res) {
1923 try {
1924 var resObj = JSON.parse(res.responseText);
1925 if (!resObj.success) {
1926 alert('서버에서 오류가 발생했습니다.\n\n오류 코드 : ' + resObj.error.code + '\n메세지 : ' + resObj.error.message);
1927 win.close();
1928 return;
1929 }
1930 } catch (errr) {
1931 alert('서버에서 알 수 없는 오류가 발생했습니다.');
1932 win.close();
1933 return;
1934 }
1935 win.content(function (winel) {
1936 winel.innerHTML = '<p>맞춤법 검사 결과입니다. 아직 개발중인 기능이라 나사 빠진듯이 돌아갑니다.</p><style>table.spellresult tbody td, tr {border: #9D75D9 1px solid; border-collapse: collapse;}table.spellresult tbody td:not(.spellhelp) {padding: 3px 7px;word-break: keep-all; white-space: nowrap;}table.spellresult tbody td.spellhelp {word-break: keep-all;}table.spellresult tbody td.spellerror {color: red;}table.spellresult tbody td.spellsuggest {color: darkgreen;}</style><table class="spellresult"><thead><tr><th>틀린말</th><th>대체어</th><th>도움말</th></tr></thead><tbody></tbody></table>';
1937 for (var i = 0; i < resObj.result.length; i++) {
1938 var resultEntry = resObj.result[i];
1939 var row = document.createElement('tr');
1940 row.innerHTML = '<td class="spellerror"></td><td class="spellsuggest"></td><td class="spellhelp"></td>';
1941 row.querySelector('.spellerror').innerHTML = resultEntry.errors.join('<br>');
1942 row.querySelector('.spellsuggest').innerHTML = resultEntry.replacements.join('<br>');
1943 row.querySelector('.spellhelp').innerHTML = encodeHTMLComponent(resultEntry.help);
1944 winel.querySelector('tbody').appendChild(row);
1945 }
1946 });
1947 win.button('닫기', win.close);
1948 }
1949 })
1950 });
1951
1952 Designer.button('<span class="ion-ios-timer-outline fa fa-clock-o"></span>').hoverMessage('아카이브하고 외부링크 삽입').click(function () {
1953 var win = TooSimplePopup();
1954 win.title("아카이브한 후 외부링크 삽입");
1955 var linkTo = "",
1956 linkText = "",
1957 WayBack = false,
1958 WayBack = false,
1959 WayBackAsMobile = false,
1960 archiveIs = false,
1961 archiveLinks = [];
1962 var refresh;
1963 win.content(function (container) {
1964 container.innerHTML = '<h1 style="margin: 0px 0px 5px 0px; font-size: 20px;">링크할 곳(외부링크)</h1>' +
1965 '<style>#linkTo, #visibleOutput {position: absolute; left: 120px;}</style>' +
1966 '<label>링크할 대상</label> <input type="text" id="linkTo" placeholder="e.g. http://www.naver.com" /><br>' +
1967 '<label>표시할 텍스트 (출력)</label> <input type="text" id="visibleOutput" placeholder="e.g. 구글" /><br>' +
1968 '<h1 style="margin: 5px 0px 5px 0px; font-size: 20px;">아카이브</h1>' +
1969 '<strong>참고</strong> : 동일한 주소의 아카이브를 자주 하다 보면 아까 했던 아카이브가 또 나올 수도 있습니다, 이런 경우엔 잠시 몇분정도 기다렸다가 하시면 됩니다.<br>' +
1970 '<strong>참고</strong> : 기존의 아카이브들은 무시됩니다.<br>' +
1971 '<strong>참고</strong> : 아카이빙하려는 사이트의 서버 설정 결과에 따라 아카이빙이 거부될 수 있습니다.' +
1972 '<strong style="color:red;">주의</strong> : 불안정한 기능입니다. 버그에 주의하세요.<br>' +
1973 '<input type="checkbox" id="WayBack" /> <label><a href="https://archive.org/web/" target="_blank">WayBack Machine</a>으로 아카이브</label> (<input type="checkbox" id="WayBackMobi" /> 모바일 버전으로)<br>' +
1974 '<input type="checkbox" id="archiveIs" /> <label><a href="https://archive.is/" target="_blank" checked>archive.is</a>에서 아카이브</label>';
1975 refresh = function () {
1976 linkTo = container.querySelector('#linkTo').value;
1977 linkText = container.querySelector('#visibleOutput').value;
1978 WayBack = container.querySelector('#WayBack').checked;
1979 WayBackAsMobile = container.querySelector('#WayBackMobi').checked;
1980 archiveIs = container.querySelector('#archiveIs').checked;
1981 }
1982 });
1983 win.button("박제/삽입", function () {
1984 var waitwin = TooSimplePopup();
1985 waitwin.title('박제중....');
1986 refresh();
1987 if (linkTo.indexOf('http://') != 0 && linkTo.indexOf('https://') != 0) {
1988 alert('http:// 또는 https://로 시작하는 외부링크가 아닙니다!');
1989 }
1990 waitwin.content(function (container) {
1991 container.innerHTML = '박제중입니다....'
1992 });
1993
1994 function finishLinking() {
1995 var link = '[[' + linkTo + '|링크]]';
1996 if (archiveLinks.length != 0) {
1997 link += '(';
1998 for (var i = 0; i < archiveLinks.length; i++) {
1999 link += '[[' + archiveLinks[i] + '|아카이브' + (i + 1) + ']]';
2000 if (i != archiveLinks.length - 1) link += ',';
2001 }
2002 link += ')';
2003 }
2004 TextProc.selectionText(link + TextProc.selectionText());
2005 waitwin.close();
2006 win.close();
2007 }
2008
2009 function archiveOne() {
2010 var archiveType;
2011 if (WayBack) {
2012 archiveType = 'wb';
2013 WayBack = false;
2014 } else if (archiveIs) {
2015 archiveType = 'ai';
2016 archiveIs = false;
2017 } else {
2018 finishLinking();
2019 return;
2020 }
2021 var r = {};
2022 if (archiveType == 'wb') {
2023 // 'http://web.archive.org/save/'
2024 // 1 -> Mobile Agent
2025 r.method = "GET";
2026 r.url = "http://web.archive.org/save/" + linkTo;
2027 if (WayBackAsMobile) {
2028 r.headers = {};
2029 r.headers["User-Agent"] = "Mozilla/5.0 (iPhone; U; CPU iPhone OS 3_0 like Mac OS X; en-us) AppleWebKit/528.18 (KHTML, like Gecko) Version/4.0 Mobile/7A341 Safari/528.16";
2030 }
2031 r.onload = function (res) {
2032 if (res.status == 403) {
2033 alert('오류가 발생했습니다. 크롤링이 금지된 사이트일 수도 있습니다.');
2034 setTimeout(archiveOne, 50);
2035 return;
2036 } else if (res.status != 200) {
2037 alert('알 수 없는 오류가 발생했습니다.');
2038 setTimeout(archiveOne, 50);
2039 return;
2040 }
2041 var matches = /var redirUrl = \"(.+?)\";/.exec(res.responseText);
2042 if (matches == null) {
2043 alert('WayBack Machine 아카이브 주소를 얻는 데 실패했습니다.');
2044 setTimeout(archiveOne, 50);
2045 return;
2046 }
2047 var archiveUrl = 'http://web.archive.org' + matches[1];
2048 archiveLinks.push(archiveUrl);
2049 setTimeout(archiveOne, 50);
2050 };
2051 } else if (archiveType == 'ai') {
2052 r.method = "GET";
2053 r.url = "https://archive.is";
2054 r.onload = function (res) {
2055 // get submitid
2056 var parser = new DOMParser();
2057 var indexDoc = parser.parseFromString(res.responseText, "text/html");
2058 var token = indexDoc.querySelector("input[name=submitid][value]");
2059 if (token) {
2060 token = token.value;
2061
2062 // archive form
2063 // 'http://archive.is/submit/'
2064 var r2 = {};
2065 r2.method = "POST";
2066 r2.url = "https://archive.is/submit/";
2067 r2.headers = {};
2068 r2.headers["Content-Type"] = "application/x-www-form-urlencoded";
2069 r2.data = "anyway=1&url=" + encodeURIComponent(linkTo) + "&submitid=" + encodeURIComponent(token);
2070 r2.onload = function (res) {
2071 var matches = /document\.location\.replace\("(.+?)"\)/.exec(res.responseText);
2072 if (matches == null) matches = /<meta property="og:url" content="(.+?)"/.exec(res.responseText);
2073 if (matches == null) {
2074 alert('archive.is 아카이브 주소를 얻는 데 실패했습니다.');
2075 setTimeout(archiveOne, 50);
2076 return;
2077 }
2078 var archiveUrl = matches[1];
2079 archiveLinks.push(archiveUrl);
2080 setTimeout(archiveOne, 50);
2081 }
2082 GM.xmlHttpRequest(r2);
2083 } else {
2084 alert('archive.is 아카이브 중 토큰을 얻는 데 실패했습니다.')
2085 }
2086 }
2087 }
2088 GM.xmlHttpRequest(r);
2089 }
2090 archiveOne();
2091 });
2092 win.button("닫기", win.close);
2093 });
2094 if (ENV.IsEditing) {
2095 // Manager Class
2096 var tempsaveManager = new function () {
2097 var ht = this;
2098 this.getTitles = function () {
2099 var r = [];
2100 for (var i in SET.tempsaves) {
2101 r.push(i);
2102 }
2103 return r;
2104 }
2105 this.getByTitle = async function (docTitle) {
2106 await SET.load();
2107 if (nOu(SET.tempsaves[docTitle])) {
2108 SET.tempsaves[docTitle] = [];
2109 await SET.save();
2110 }
2111 return SET.tempsaves[docTitle]; // {section, text, timestamp}
2112 };
2113 this.getByTitleAndSectionNo = async function (docTitle, sectno) {
2114 await SET.load();
2115 var b = ht.getByTitle(docTitle);
2116 var a = [];
2117 for (var i = 0; i < b.length; i++) {
2118 if (b[i].section == sectno)
2119 a.push(b[i]);
2120 }
2121 return a;
2122 }
2123 this.save = async function (docTitle, sectno, timestamp, text, wikihost) {
2124 if (typeof wikihost === 'undefined')
2125 var wikihost = location.host;
2126 await SET.load();
2127 if (nOu(SET.tempsaves[docTitle])) {
2128 SET.tempsaves[docTitle] = [];
2129 await SET.save();
2130 }
2131 SET.tempsaves[docTitle].push({
2132 section: sectno,
2133 timestamp: timestamp,
2134 text: text,
2135 host: wikihost
2136 });
2137 await SET.save();
2138 }
2139 this.delete = async function (docTitle, sectno, timestamp) {
2140 await SET.load();
2141 if (nOu(SET.tempsaves[docTitle])) return;
2142 var newArray = [];
2143 for (var i = 0; i < SET.tempsaves[docTitle].length; i++) {
2144 var keepThis = true;
2145 var now = SET.tempsaves[docTitle][i];
2146 switch (arguments.length) {
2147 case 1:
2148 keepThis = false;
2149 break;
2150 case 2:
2151 if (now.section == sectno) keepThis = false;
2152 break;
2153 case 3:
2154 if (now.section == sectno && now.timestamp == timestamp) keepThis = false;
2155 break;
2156 }
2157 if (keepThis) {
2158 newArray.push(SET.tempsaves[docTitle][i]);
2159 }
2160 }
2161 SET.tempsaves[docTitle] = newArray;
2162 await SET.save();
2163 }
2164 }
2165 // Tempsave Menu
2166 var tempsaveDropdown = Designer.dropdown('<span class="ion-ios-pricetags-outline fa fa-save"></span>').hoverMessage('임시저장');
2167 tempsaveDropdown.button('<span class="ion-ios-pricetag-outline fa fa-save"></span>', '임시저장').click(async function () {
2168 await tempsaveManager.save(ENV.docTitle, ENV.section, Date.now(), txtarea.value);
2169 });
2170 tempsaveDropdown.button('<span class="ion-filing fa fa-folder-open-o"></span>', '임시저장 불러오기').click(async function () {
2171 // title(text), content(callback), foot(callback), button(text,onclick), close
2172 var win = TooSimplePopup();
2173 win.title('임시저장 불러오기')
2174 var tempsaveList = await tempsaveManager.getByTitle(ENV.docTitle);
2175 win.content(function (el) {
2176 el.innerHTML = '<p>현재 편집중인 문단인 경우 문단 번호가 <strong>굵게</strong> 표시됩니다.<br>문단 번호가 -2인 경우는 문단 번호가 감지되지 않은 경우입니다.</p>';
2177 var divWithscrollbars = document.createElement("div");
2178 divWithscrollbars.style.height = '300px';
2179 divWithscrollbars.style.overflow = 'auto';
2180 var table = document.createElement("table");
2181 var headrow = document.createElement("tr");
2182 headrow.innerHTML = '<th>문단 번호</th><th>저장된 날짜와 시간</th><th>불러오기</th>';
2183 table.appendChild(headrow);
2184 for (var i = 0; i < tempsaveList.length; i++) {
2185 var now = tempsaveList[i];
2186 var tr = document.createElement("tr");
2187 tr.innerHTML = '<td>' + (now.section == ENV.section ? '<strong>' : '') + now.section + (now.section == ENV.section ? '</strong>' : '') + '</td><td>' + formatDateTime(now.timestamp) + '</td>'
2188 var td = document.createElement("td");
2189 var btn = document.createElement("button");
2190 btn.setAttribute("type", "button");
2191 btn.innerHTML = "불러오기";
2192 btn.dataset.json = JSON.stringify(now);
2193 btn.addEventListener('click', function (evt) {
2194 var now = JSON.parse(evt.target.dataset.json);
2195 txtarea.value = now.text;
2196 });
2197 td.appendChild(btn);
2198 tr.appendChild(td);
2199 table.appendChild(tr);
2200 }
2201 divWithscrollbars.appendChild(table);
2202 el.appendChild(divWithscrollbars);
2203 });
2204 win.button('닫기', win.close);
2205 });
2206 tempsaveDropdown.button('<span class="ion-trash-a fa fa-trash" style="color:red;"></span>', '이 문서의 모든 임시저장 삭제').click(async function () {
2207 await tempsaveManager.delete(ENV.docTitle);
2208 });
2209 tempsaveDropdown.button('<span class="ion-trash-a fa fa-trash" style="color:orangered;"></span>', '이 문서의 이 문단의 모든 임시저장 삭제').click(async function () {
2210 await tempsaveManager.delete(ENV.docTitle, ENV.section);
2211 });
2212 tempsaveDropdown.button('<span class="ion-trash-a fa fa-trash" style="color:orange;"></span>', '특정 임시저장만 삭제').click(async function () {
2213 // title(text), content(callback), foot(callback), button(text,onclick), close
2214 var win = TooSimplePopup();
2215 var tempsaveList = await tempsaveManager.getByTitle(ENV.docTitle);
2216 win.title('임시저장 삭제');
2217 win.content(function (el) {
2218 el.innerHTML = '<p>현재 편집중인 문단인 경우 문단 번호가 <strong>굵게</strong> 표시됩니다.<br>문단 번호가 -2인 경우는 문단 번호가 감지되지 않은 경우입니다.</p>';
2219 var divWithscrollbars = document.createElement("div");
2220 divWithscrollbars.style.height = '300px';
2221 divWithscrollbars.style.overflow = 'auto';
2222 var table = document.createElement("table");
2223 var headrow = document.createElement("tr");
2224 headrow.innerHTML = '<th>문단 번호</th><th>저장된 날짜와 시간</th><th>삭제 버튼</th>';
2225 table.appendChild(headrow);
2226 for (var i = 0; i < tempsaveList.length; i++) {
2227 var now = tempsaveList[i];
2228 var tr = document.createElement("tr");
2229 tr.innerHTML = '<td>' + (now.section == ENV.section ? '<strong>' : '') + now.section + (now.section == ENV.section ? '</strong>' : '') + '</td><td>' + formatDateTime(now.timestamp) + '</td>'
2230 var td = document.createElement("td");
2231 var btn = document.createElement("button");
2232 btn.setAttribute("type", "button");
2233 btn.innerHTML = "삭제하기";
2234 btn.dataset.json = JSON.stringify(now);
2235 btn.addEventListener('click', async function (evt) {
2236 var now = JSON.parse(evt.target.dataset.json);
2237 await tempsaveManager.delete(ENV.docTitle, now.section, now.timestamp);
2238 win.close();
2239 });
2240 td.appendChild(btn);
2241 tr.appendChild(td);
2242 table.appendChild(tr);
2243 }
2244 divWithscrollbars.appendChild(table);
2245 el.appendChild(divWithscrollbars);
2246 });
2247 win.button('닫기', win.close);
2248 });
2249 if (SET.autoTempsaveSpan > 0)
2250 setInterval(async function () {
2251 await tempsaveManager.save(ENV.docTitle, ENV.section, Date.now(), txtarea.value);
2252 }, SET.autoTempsaveSpan);
2253 }
2254 // Template Insert Feature
2255 var templatesDropdown = Designer.dropdown('<span class="ion-ios-copy-outline fa fa-file"></span>').hoverMessage('템플릿/틀 삽입과 최근에 사용/삽입한 템플릿/틀 기록');
2256 var refreshTemplatesDropdown = async function () {
2257 await SET.load();
2258 templatesDropdown.clear();
2259 var rutl = SET.recentlyUsedTemplates.length;
2260
2261 function InsertTemplateClosure(na) {
2262 return async function () {
2263 namuapi.raw(na, async function (templateContent) {
2264 await SET.load();
2265 if (SET.recentlyUsedTemplates.indexOf(na) == -1) SET.recentlyUsedTemplates.push(na);
2266 await SET.save();
2267 if (na.indexOf('틀:') == 0)
2268 TextProc.selectionText(TextProc.selectionText() + '[include(' + na + ')]');
2269 else
2270 txtarea.value = templateContent;
2271 setTimeout(refreshTemplatesDropdown, 300);
2272 }, function () {
2273 alert('존재하지 않는 템플릿/틀입니다.');
2274 return;
2275 });
2276 };
2277 }
2278 for (var i = 0; i < (rutl < 9 ? rutl : 9); i++) {
2279 templatesDropdown.button('<span class="ion-ios-paper-outline fa fa-file"></span>', SET.recentlyUsedTemplates[i]).click(InsertTemplateClosure(SET.recentlyUsedTemplates[i]));
2280 }
2281 templatesDropdown.button('<span class="ion-close-round fa fa-times"></span>', '기록 삭제').click(async function () {
2282 await SET.load();
2283 SET.recentlyUsedTemplates = [];
2284 await SET.save();
2285 setTimeout(refreshTemplatesDropdown, 300);
2286 });
2287 templatesDropdown.button('<span class="ion-plus-round fa fa-plus"></span>', '템플릿/틀 삽입').click(function () {
2288 var templateName = prompt('템플릿/틀 이름을 입력하세요.');
2289 if (!/^(?:템플릿|Template|틀):.+/.test(templateName) && !confirm('올바른 템플릿/틀 이름이 아닌 것 같습니다. 계속할까요?')) return;
2290 InsertTemplateClosure(templateName)();
2291 setTimeout(refreshTemplatesDropdown, 300);
2292 });
2293 };
2294 setTimeout(refreshTemplatesDropdown, 500);
2295
2296 // set Size
2297 if (ENV.Discussing)
2298 rootDiv.style.height = '170px';
2299 else
2300 rootDiv.style.height = '600px';
2301
2302 // Add Keyboard Shortcut
2303 function overrideBrowserDefaultShortcutKey(evt) {
2304 var overrideShortcutKey = true;
2305 if (evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
2306 switch (evt.keyCode) { // Ctrl
2307 case 66: // B
2308 case 98:
2309 case 73: // I
2310 case 105:
2311 case 68: // D
2312 case 100:
2313 case 85: // U
2314 case 117:
2315 case 72: // H
2316 case 104:
2317 case 219:
2318 case 123:
2319 case 91: // [
2320 case 221:
2321 case 125:
2322 case 93: // ]
2323 case 83: // S
2324 case 115:
2325 break;
2326 default:
2327 overrideShortcutKey = false;
2328 break;
2329 }
2330 } else if (evt.ctrlKey && evt.altKey && !evt.shiftKey) {
2331 switch (evt.keyCode) { // Ctrl + Alt
2332 case 73: // I
2333 case 105:
2334 default:
2335 overrideShortcutKey = false;
2336 break;
2337 }
2338 } else {
2339 overrideShortcutKey = false;
2340 }
2341 if (overrideShortcutKey) {
2342 evt.preventDefault();
2343 evt.stopPropagation();
2344 }
2345 }
2346 txtarea.addEventListener('keydown', overrideBrowserDefaultShortcutKey);
2347 txtarea.addEventListener('keypress', overrideBrowserDefaultShortcutKey);
2348 txtarea.addEventListener('keyup', function (evt) {
2349 var overrideShortcutKey = true;
2350 if (evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
2351 switch (evt.keyCode) { // Ctrl
2352 case 66: // B
2353 case 98:
2354 TextProc.ToggleWrapSelection("'''");
2355 break;
2356 case 73: // I
2357 case 105:
2358 TextProc.ToggleWrapSelection("''");
2359 break;
2360 case 68: // D
2361 case 100:
2362 TextProc.ToggleWrapSelection("--");
2363 break;
2364 case 85: // U
2365 case 117:
2366 TextProc.ToggleWrapSelection("__");
2367 break;
2368 case 72:
2369 case 104:
2370 NewHeadingMacro();
2371 break;
2372 case 219:
2373 case 123:
2374 case 91: // [
2375 FontSizeChanger(false);
2376 break;
2377 case 221:
2378 case 125:
2379 case 93: // ]
2380 FontSizeChanger(true);
2381 break;
2382 case 83: // S
2383 case 115:
2384 tempsaveManager.save(ENV.docTitle, ENV.section, Date.now(), txtarea.value);
2385 break;
2386 default:
2387 overrideShortcutKey = false;
2388 break;
2389 }
2390 } else if (evt.ctrlKey && evt.altKey && !evt.shiftKey) {
2391 switch (evt.keyCode) { // Ctrl + Alt
2392 case 73: // I
2393 case 105:
2394 namuUpload();
2395 break;
2396 default:
2397 overrideShortcutKey = false;
2398 break;
2399 }
2400 } else {
2401 overrideShortcutKey = false;
2402 }
2403 if (overrideShortcutKey) {
2404 evt.preventDefault();
2405 evt.stopPropagation();
2406 }
2407 });
2408
2409 // Support drag-drop file upload
2410 // from https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/File_drag_and_drop
2411 // from https://stackoverflow.com/questions/7237436/how-to-listen-for-drag-and-drop-plain-text-in-textarea-with-jquery
2412 txtarea.addEventListener('dragover', function (evt) {
2413 evt.preventDefault();
2414 });
2415 txtarea.addEventListener('dragend', function (evt) {
2416 evt.preventDefault();
2417 });
2418 txtarea.addEventListener('dragenter', function (evt) {
2419 evt.preventDefault();
2420 });
2421 txtarea.addEventListener('drop', function (evt) {
2422 evt.preventDefault();
2423 var dt = evt.dataTransfer;
2424 var files = [];
2425 if (dt.items) {
2426 for (var i = 0; i < dt.items.length; i++)
2427 if (dt.items[i].kind == "file") {
2428 var f = dt.items[i].getAsFile();
2429 if (f.type.indexOf('image/') == 0)
2430 files.push(f);
2431 }
2432 } else {
2433 for (var i = 0; i < dt.files.length; i++) {
2434 var f = dt.files[i];
2435 if (f.type.indexOf('image/') == 0)
2436 files.push(f);
2437 }
2438 }
2439 if (files.length > 0) {
2440 namuUpload(files, function () {});
2441 }
2442 })
2443
2444 // Support image upload by pasting
2445 txtarea.addEventListener('paste', function (evt) {
2446 var items = (evt.clipboardData || evt.originalEvent.clipboardData).items;
2447 var files = [];
2448 for (index in items) {
2449 var item = items[index];
2450 if (item.kind === 'file') {
2451 var file = item.getAsFile();
2452 if (file.type.indexOf('image/') == 0)
2453 files.push(file);
2454 }
2455 }
2456 if (files.length > 0) {
2457 namuUpload(files, function () {});
2458 }
2459 })
2460
2461 // Add NamuFix Div
2462 var oldTextarea = document.querySelector("textarea");
2463 var wText = oldTextarea.value;
2464 oldTextarea.parentNode.insertBefore(rootDiv, oldTextarea);
2465 oldTextarea.parentNode.removeChild(oldTextarea);
2466 txtarea.value = wText;
2467
2468 var srwPattern = /\?redirectTo=([^\&]+)/;
2469 if (srwPattern.test(location.search)) {
2470 if ((txtarea.value.trim().search(/^#redirect .+/) == 0 || txtarea.value.trim().length == 0) || confirm('빈 문서가 아닌것 같습니다만 그래도 계속?')) {
2471 txtarea.value = '#redirect ' + decodeURIComponent(srwPattern.exec(location.search)[1]);
2472 if (document.querySelectorAll('iframe[title="CAPTCHA 위젯"]').length == 0 && document.querySelector('.alert.alert-danger') !== null) {
2473 if (document.querySelector("input#logInput")) document.querySelector("input#logInput").value = "NamuFix를 이용하여 자동 리다이렉트 처리됨.";
2474 document.querySelector('#editBtn').click();
2475 }
2476 }
2477 }
2478 }
2479 } else if (ENV.IsDocument) {
2480 if (ENV.docTitle.trim().indexOf('기여:') == 0) {
2481 var target = ENV.docTitle.trim().substring(3).trim();
2482 if (validateIP(target)) {
2483 location.assign('/contribution/ip/' + target + '/document');
2484 } else {
2485 location.assign('/contribution/author/' + target + '/document');
2486 }
2487 }
2488
2489
2490
2491 addArticleButton('삭제', (evt) => {
2492 evt.preventDefault();
2493
2494 location.href = 'https://namu.wiki/delete/' + ENV.docTitle;
2495 })
2496 addArticleButton('이동', (evt) => {
2497 evt.preventDefault();
2498
2499 location.href = 'https://namu.wiki/move/' + ENV.docTitle;
2500 })
2501 addArticleButton('롤백', function () {
2502 var revNo = prompt('리버전 번호를 입력하세요.').trim();
2503 if (revNo.indexOf('r') == 0) revNo = revNo.substring(1);
2504 if (/[^0-9]/.test(revNo)) {
2505 alert('올바른 입력이 아닙니다! r1 혹은 1과 같이 입력해주세요.');
2506 return;
2507 }
2508 location.href = "/revert/" + encodeURIComponent(ENV.docTitle) + "?rev=" + revNo;
2509 })
2510 addArticleButton('RAW', (evt) => {
2511 evt.preventDefault();
2512
2513 location.href = 'https://namu.wiki/raw/' + ENV.docTitle;
2514 })
2515 addArticleButton('Blame', (evt) => {
2516 evt.preventDefault();
2517
2518 location.href = 'https://namu.wiki/blame/' + ENV.docTitle;
2519 })
2520
2521 // 리다이렉트 버튼 추가
2522 addArticleButton('출발지', function (evt) {
2523 evt.preventDefault();
2524
2525 var redirectFrom = prompt('출발지 문서명을 입력하세요.');
2526 if (redirectFrom != null && redirectFrom.trim().length != 0)
2527 location.href = 'https://' + location.host + '/edit/' + redirectFrom + '?redirectTo=' + ENV.docTitle;
2528 });
2529
2530
2531 if (SET.addSnsShareButton && ENV.IsDocument) {
2532 addArticleButton('<span class="icon ion-social-facebook fa fab fa-facebook-square"></span>', (evt) => {
2533 evt.preventDefault();
2534
2535 GM.openInTab(`http://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(location.href)}`)
2536 })
2537 addArticleButton('<span class="icon ion-social-twitter fa fab fa-twitter"></span>', (evt) => {
2538 evt.preventDefault();
2539
2540 GM.openInTab(`http://twitter.com/intent/tweet?url=${encodeURIComponent(location.href)}&text=${ENV.docTitle}`)
2541 })
2542 }
2543 // 리다이렉트로 왔을 시 그 라디이렉트 문서 편집/삭제 링크 추가
2544 if (document.querySelector('article .alert.alert-info, .Liberty .wiki-article .alert.alert-info') && document.querySelector('article .alert.alert-info, .Liberty .wiki-article .alert.alert-info').innerHTML.indexOf('에서 넘어옴') != -1) {
2545 var redirectAlert = document.querySelector('article .alert.alert-info, .Liberty .wiki-article .alert.alert-info');
2546 var origDocuName = decodeURIComponent(/\/w\/(.+?)\?noredirect=1/.exec(redirectAlert.querySelector('a.document').href)[1]);
2547 var editUrl = '/edit/' + origDocuName;
2548 var deleteUrl = '/delete/' + origDocuName;
2549
2550 redirectAlert.innerHTML = '<a href="/w/' + encodeURIComponent(origDocuName) + '?noredirect=1" class="document" title="' + encodeHTMLComponent(origDocuName) + '">' + encodeHTMLComponent(origDocuName) + '</a>' +
2551 '에서 여기로 넘어왔습니다. 당신은 ' + encodeHTMLComponent(origDocuName) + ' 문서를 <a href="' + editUrl + '">수정</a>하거나 <a href="' + deleteUrl + '">삭제</a>할 수 있습니다.';
2552 }
2553 // 상위 문서로의 링크
2554 if (ENV.docTitle.indexOf('/') != -1) {
2555 function spSplit(a, b) {
2556 var splitted = a.split(b);
2557 var result = [];
2558 for (var i = 1; i <= splitted.length; i++) {
2559 var now = '';
2560 for (var ii = 0; ii < i; ii++) {
2561 var append = splitted[ii];
2562 if (ii != i - 1) append += '/';
2563 now += append;
2564 }
2565 result.push(now);
2566 }
2567 return result;
2568 }
2569 var higherDocsWE = {};
2570 var higherDocs = spSplit(ENV.docTitle, '/');
2571 for (var i = 0; i < higherDocs.length; i++) {
2572 higherDocsWE[i] = null;
2573 }
2574 var codwe = 0;
2575 var codwnf = 0;
2576 for (var i = 0; i < higherDocs.length; i++) {
2577 namuapi.raw(higherDocs[i], function (r, t) {
2578 higherDocsWE[t] = true;
2579 codwe++;
2580 }, function (t) {
2581 higherDocsWE[t] = false;
2582 codwnf++;
2583 });
2584 if (i == higherDocs.length - 1) {
2585 var hdinid = setInterval(function () {
2586 if (codwe + codwnf != higherDocs.length) return;
2587 var docTitleTag = document.querySelector('h1.title, body.Liberty .liberty-content .liberty-content-header h1.title');
2588 var hdsPT = document.createElement("p");
2589 var sstl = 0;
2590 for (var i = 0; i < higherDocs.length; i++) {
2591 if (!higherDocsWE[higherDocs[i]]) continue;
2592 var higherDoc = higherDocs[i];
2593 var a = document.createElement("a");
2594 a.setAttribute("href", '/w/' + encodeURIComponent(higherDoc));
2595 a.setAttribute("title", higherDoc);
2596 a.className = "wiki-link-internal";
2597 if (i != 0 && higherDoc.substring(sstl).indexOf('/') == 0)
2598 a.innerHTML = higherDoc.substring(sstl + 1);
2599 else
2600 a.innerHTML = higherDoc.substring(sstl);
2601 sstl = higherDoc.length;
2602 hdsPT.appendChild(a);
2603 if (i != higherDocs.length - 1) hdsPT.appendChild(document.createTextNode(" > "));
2604 }
2605 docTitleTag.style.marginBottom = '0px';
2606 hdsPT.style.marginBottom = '25px';
2607 docTitleTag.parentNode.insertBefore(hdsPT, docTitleTag.nextSibling);
2608 clearInterval(hdinid);
2609 }, 200);
2610 }
2611 }
2612 }
2613
2614 // 문단 접기 이탤릭 표시
2615 GM_addStyle('.namufix-folded-heading {color: darkgray; opacity: 0.5;}');
2616 var wikiHeadings = document.querySelectorAll('.wiki-heading')
2617 for (var i = 0; i < wikiHeadings.length; i++) wikiHeadings[i].addEventListener('click', function (evt) {
2618 if (evt.target.tagName === 'A') return;
2619 if (evt.target.nextSibling.style.display === 'none') {
2620 evt.target.className += ' namufix-folded-heading';
2621 } else {
2622 evt.target.className = evt.target.className.replace('namufix-folded-heading', '');
2623 }
2624 })
2625
2626 // 차단 링크 링크화
2627 if (ENV.docTitle.startsWith('사용자:')) {
2628 let banbox = document.querySelector('.wiki-content [onmouseover][onmouseout]')
2629 if (banbox.querySelector('span:first-child').textContent.includes('이 사용자는 차단된 사용자입니다.')) {
2630 let reason = banbox.lastChild.textContent,
2631 urlPattern = /((http[s]?|ftp):\/)?\/?([^:\/\s]+)((\/\w+)*\/)([\w\-\.]+[^#?\s]+)(.*)?(#[\w\-]+)?$/mg,
2632 urlMatch = urlPattern.exec(reason),
2633 linkIndex = 1,
2634 firstLink = null;
2635 while(urlMatch) {
2636 let link = document.createElement("a");
2637 link.href = urlMatch[0];
2638 link.textContent = `차단 링크 ${linkIndex++}`;
2639 banbox.appendChild(link);
2640 if(!firstLink) firstLink = link;
2641 urlMatch = urlPattern.exec(reason);
2642 }
2643 if (firstLink) {
2644 banbox.insertBefore(document.createElement("br"), firstLink);
2645 banbox.insertBefore(document.createElement("br"), firstLink);
2646 }
2647 }
2648 }
2649 }
2650
2651
2652
2653 if (ENV.Discussing) {
2654 // 보여지지 않은 쓰레드도 불러오기
2655 let unvisibleResesAllLoaded = false;
2656 if (SET.loadUnvisibleReses) {
2657 function doLoadUnvisibleReses() {
2658 var scriptTag = document.createElement("script");
2659 scriptTag.innerHTML = 'function nfformattime(){$("time[data-nf-format-this]").each(function(){var format = $(this).attr("data-format");var time = $(this).attr("datetime");$(this).text(formatDate(new Date(time), format));});}';
2660 document.body.appendChild(scriptTag);
2661 var allUnlockedReses = document.querySelectorAll('#res-container div.res-loading[data-locked="false"]');
2662 for (var i = 0; i < allUnlockedReses.length; i++) {
2663 allUnlockedReses[i].setAttribute('data-locked', 'true');
2664 }
2665 var reqId = parseInt(allUnlockedReses[0].dataset.id),
2666 lastReqId = parseInt(allUnlockedReses[allUnlockedReses.length - 1].dataset.id);
2667 console.log('[NamuFix] 보이지 않은 레스 불러오기를 시작합니다. 범위 : ' + reqId + ' 에서 ' + lastReqId);
2668 for (console.log('[NamuFix] loadUnvisibleReses 루프 시작!'); reqId <= lastReqId; reqId += 30) {
2669 console.log('[NamuFix] 레스 요청중, 시작 번호는 ' + reqId);
2670 namuapi.theseedRequest({
2671 method: "GET",
2672 url: "https://" + location.host + "/thread/" + ENV.topicNo + "/" + reqId,
2673 onload: function (res) {
2674 console.log('[NamuFix] 레스 응답 받음, 시작 번호는 ' + reqId);
2675 var parser = new DOMParser();
2676 var doc = parser.parseFromString(res.responseText, "text/html");
2677 var timeTags = doc.querySelectorAll('time');
2678 var resTags = doc.querySelectorAll('.res-wrapper');
2679 for (var i = 0; i < timeTags.length; i++)
2680 timeTags[i].dataset.nfFormatThis = "true";
2681 for (var i = 0; i < resTags.length; i++) {
2682 var resTag = resTags[i];
2683 var targetTag = document.querySelector('#res-container div.res-loading[data-id="' + resTag.dataset.id + '"]');
2684 if (targetTag == null) continue;
2685 targetTag.parentNode.insertBefore(resTag, targetTag.nextSibling);
2686 targetTag.parentNode.removeChild(targetTag);
2687 }
2688 var scriptTagId = 'nf-temp-s' + Date.now() + reqId;
2689 var scriptTag = document.createElement('script');
2690 scriptTag.id = scriptTagId;
2691 scriptTag.innerHTML = 'nfformattime(); var thisTag = document.querySelector("#' + scriptTagId + '"); thisTag.parentNode.removeChild(thisTag);';
2692 document.body.appendChild(scriptTag);
2693 setTimeout(() => {
2694 if (document.querySelectorAll('#res-container div.res-loading[data-locked="false"]')) {
2695 if (!unvisibleResesAllLoaded) {
2696 unvisibleResesAllLoaded = true;
2697 console.log("[NamuFix] 모든 보이지 않은 쓰레를 불러옴!");
2698 }
2699 }
2700 }, 100);
2701 }
2702 });
2703 }
2704 }
2705 setTimeout(doLoadUnvisibleReses, 600);
2706 }
2707
2708 // 아이덴티콘 설정들과 변수들
2709 var isIcon = SET.discussIdenti == 'icon';
2710 var isThreadicLike = SET.discussIdenti == 'headBg';
2711 var isIdenticon = SET.discussIdenti == 'identicon';
2712 var colorDictionary = {},
2713 identiconDictionary = {};
2714
2715 // TO-DO : Rewrite Loop codes
2716 // #[0-9]+ 엥커 미리보기
2717 function mouseoverPreview() {
2718 var anchors = document.querySelectorAll('.res .r-body .wiki-self-link:not([data-nf-title-processed])');
2719 for (var i = 0; i < anchors.length; i++) {
2720 var anchor = anchors[i];
2721 if (!/#[0-9]+$/.test(anchor.href)) {
2722 continue;
2723 }
2724 var anchorDirection = document.querySelector('.r-head .num a[id=\'' + /#([0-9]+)$/.exec(anchor.href)[1] + '\']');
2725
2726 anchor.dataset.target = (anchorDirection) ? anchorDirection.id : "";
2727 anchor.addEventListener('mouseenter', function (evt) {
2728 var anchorDirection = document.getElementById(evt.target.dataset.target);
2729 var obj = {};
2730 if (anchorDirection == null) {
2731 obj = {
2732 talker: "?????????",
2733 message: "존재하지 않는 메세지입니다.",
2734 isFirstAuthor: false,
2735 notExists: true
2736 }
2737 } else if (anchorDirection.parentNode.parentNode.parentNode.parentNode.className.indexOf('res-loading') != -1) {
2738 obj = {
2739 talker: "NOT LOADED YET",
2740 message: "아직 불러오지 않은 메세지입니다.",
2741 isFirstAuthor: false,
2742 notExists: true
2743 }
2744 } else {
2745 var anchorTarget = anchorDirection.parentNode.parentNode.parentNode;
2746 obj = {
2747 talker: anchorTarget.querySelector('.r-head > a').textContent,
2748 message: anchorTarget.querySelector('.r-body').innerHTML,
2749 isFirstAuthor: anchorTarget.querySelector('.r-head.first-author') !== null,
2750 notExists: false
2751 };
2752 }
2753 var headBackground = obj.notExists ? "red" : obj.isFirstAuthor ? "#a5df9f" : "#b3b3b3";
2754 var elem = document.createElement("div");
2755 elem.className = 'nfTopicMessage';
2756 elem.innerHTML = `<div style="font-size: 17px; font-family: sans-serif; background: ${headBackground}; padding: 7px 10px 7px 15px; font-weight: bold;">${obj.talker}</div><div style="padding: 15px; font-size: 11px; font-weight: normal;">${obj.message}</div>`;
2757 elem.style.position = 'absolute';
2758 elem.style.color = 'black';
2759 elem.style.borderRadius = '4px';
2760 elem.style.background = obj.notExists ? 'darkred' : '#d9d9d9';
2761 elem.style.zIndex = 3;
2762 evt.target.appendChild(elem);
2763 evt.target.title = '';
2764 });
2765 anchor.addEventListener('mouseleave', function (evt) {
2766 //var obj = JSON.parse(evt.target.dataset.targetMessage);
2767 if (evt.target.querySelector('.nfTopicMessage')) {
2768 var elemToRemove = evt.target.querySelector('.nfTopicMessage');
2769 elemToRemove.parentNode.removeChild(elemToRemove);
2770 }
2771 });
2772
2773 anchor.dataset.nfTitleProcessed = true;
2774 }
2775 }
2776
2777 function previewAsQuote(message) {
2778 var anchors = message.bodyElement.querySelectorAll('.wiki-self-link:not([data-nf-title-processed])');
2779 for (var i = 0; i < anchors.length; i++) {
2780 var anchor = anchors[i];
2781 if (!/#[0-9]+$/.test(anchor.href)) {
2782 continue;
2783 }
2784 var numbericId = /#([0-9]+)$/.exec(anchor.href)[1];
2785 var anchorDirection = document.querySelector('.r-head .num a[id=\'' + numbericId + '\']');
2786 if (anchorDirection == null) continue;
2787 var anchorTarget = deserializeResDom(anchorDirection.parentNode.parentNode.parentNode);
2788 var talker = anchorTarget.author.name,
2789 targetMessage = anchorTarget.bodyElement.innerHTML,
2790 talkedAt = anchorTarget.element.querySelector('.r-head > span.pull-right').textContent;
2791 var blockquoteId = uniqueID();
2792 var blockquoteElement = document.createElement("blockquote");
2793 blockquoteElement.className = "wiki-quote nf-anchor-preview";
2794 blockquoteElement.innerHTML = targetMessage;
2795 blockquoteElement.id = blockquoteId;
2796 blockquoteElement.innerHTML += `<div style="text-align: right; font-style: italic;">--#${numbericId}, ${talker}, ${talkedAt}</div>`;
2797 message.bodyElement.insertBefore(blockquoteElement, message.bodyElement.firstChild);
2798
2799 anchor.dataset.quoteId = blockquoteId;
2800 anchor.addEventListener('mouseenter', function (evt) {
2801 var quote = document.getElementById(evt.target.dataset.quoteId);
2802 quote.style.borderColor = '#CCC #CCC #CCC red !important';
2803 quote.style.boxShadow = '2px 2px 3px orange';
2804 });
2805 anchor.addEventListener('mouseleave', function (evt) {
2806 var quote = document.getElementById(evt.target.dataset.quoteId);
2807 quote.style.borderColor = '';
2808 quote.style.boxShadow = '';
2809 })
2810 }
2811 }
2812 var previewFunction;
2813 switch (SET.discussAnchorPreviewType) {
2814 case 1:
2815 previewFunction = mouseoverPreview;
2816 break;
2817 case 2:
2818 GM_addStyle('' +
2819 'blockquote.nf-anchor-preview{' +
2820 'border-color: #CCC #CCC #CCC #FF9900 !important;' +
2821 'background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAABTklEQVRoge2ZvYrCQBSF71ulEVLIFIIgC5LOaQRhwcbCKSLbCYtFunmDBTuLEKwEKx9ASOsjWNlYni2WwK5ZV83PTMa9B053IedjJpCcS8RisZ5baaIxG/YhfA+eZ8i+QH84g05SlAofSd9c6Cv2ZVQMIuy0rIfP3OqEj0GsQ2E99KVFuL4fQjYgcN7yPoDjctyAsL97vDzehtjrwHrQaw70ngEYgAEYgAFsA4gR9OaAEwDghMNGYyRqmqkeoIfF7oxLnXcL9CqfqQVAIc49FgBiqMpn+ASe8h2wawawbQawbQaw7f8B4HwrQeR4L0REFKt2AwL/dFvFj9WL6sZ3iVELVazgnQcNaKeDebmKPU00poOu8f1AdzAtvx+oQ+HLX7V9watiWttIwncZINNrboniGAAR0eot+HYaDgJk+vqddBiAiOhj8u42AItlQZ8Z9UiwBSnJVAAAAABJRU5ErkJggg==") !important;' +
2822 'margin: 0.5em 0px !important;' +
2823 '}'
2824 );
2825 previewFunction = previewAsQuote;
2826 break;
2827 default:
2828 previewFunction = function () {};
2829 }
2830
2831 // 인용형식 앵커 미리보기안의 앵커 미리보기 삭제 옵션 설정시 CSS 추가
2832 if (SET.removeNFQuotesInAnchorPreview) {
2833 GM_addStyle("blockquote.nf-anchor-preview blockquote.nf-anchor-preview {display: none;}");
2834 }
2835
2836 // 아이덴티콘
2837 function identiconLoop(message) {
2838 if (/^\/discuss\/(.+?)/.test(location.pathname)) return;
2839 var colorHash = isThreadicLike ? new ColorHash({
2840 lightness: Number(SET.discussIdentiLightness),
2841 saturation: Number(SET.discussIdentiSaturation)
2842 }) : new ColorHash();
2843 if (isIdenticon && document.querySelector('#nf-identicon-css') == null) {
2844 var cssContent = 'div.nf-identicon { border: 1px solid #808080; margin: 10px; width: 64px; border: 1px black solid; background: white;} .res.hasNFIdenticon {margin-left: 88px; position: relative; top: -76px;}';
2845 var styleTag = document.createElement("style");
2846 styleTag.innerHTML = cssContent;
2847 styleTag.id = "nf-identicon-css";
2848 document.head.appendChild(styleTag);
2849 }
2850 if (isIcon && message.author.isFirst) return;
2851 let n = message.author.name;
2852 if (!message.author.isIP) {
2853 // 로그인
2854 n = '!ID!' + n;
2855 } else {
2856 // IP
2857 n = '!IP!' + n;
2858 }
2859 n = SHA1(n);
2860
2861 var nColor;
2862 if (typeof colorDictionary[n] === 'undefined') {
2863 nColor = colorHash.hex(n);
2864 colorDictionary[n] = nColor;
2865 } else {
2866 nColor = colorDictionary[n];
2867 }
2868
2869 if (isThreadicLike) {
2870 message.element.querySelector('.r-head').style.background = nColor;
2871 message.element.querySelector('.r-head').style.color = 'white';
2872 message.element.querySelector('.r-head > a').style.color = 'white';
2873 message.element.querySelector('.r-head .num a').style.color = 'white';
2874 } else if (isIcon) {
2875 var a = message.element.querySelector('.r-head > a');
2876 var span = document.createElement("span");
2877 span.style.background = nColor;
2878 span.style.color = nColor;
2879 span.style.marginLeft = '1em';
2880 span.innerHTML = '__';
2881 a.parentNode.insertBefore(span, a.nextSibling);
2882 } else if (isIdenticon) {
2883 message.element.className += " hasNFIdenticon";
2884 let identicon = document.createElement("div");
2885 identicon.className = "nf-identicon";
2886 identicon.style.position = 'relative';
2887 identicon.innerHTML = '<div class="identicon-pointer" style="width: 0px;height: 0px;position: absolute;border-top: 20px solid transparent;border-right: 15px solid #B0D3AD;left: 64px;border-bottom: 20px solid transparent;"></div><a><img style="width: 64px; height: 64px;"></img></a>';
2888 identicon.querySelector("img").dataset.hash = n;
2889 identicon.querySelector("a").dataset.hash = n;
2890 identicon.querySelector("a").href = "#NothingToLink";
2891 identicon.querySelector("a").addEventListener('click', async function (evt) {
2892 evt.preventDefault();
2893
2894 await SET.load();
2895 var h = evt.target.dataset.hash;
2896 if (typeof SET.customIdenticons[h] !== 'undefined') {
2897 // custom identicon exists
2898 if (confirm('이미 이미지가 설정되어 있습니다. 제거할까요?')) {
2899 delete SET.customIdenticons[h];
2900 await SET.save();
2901 alert('설정됐습니다. 새로고침시 적용됩니다');
2902 }
2903 } else {
2904 if (!confirm('이 아이디 또는 닉네임에 기존 아이덴티콘 대신 다른 이미지를 설정할 수 있습니다.\n설정할까요?')) return;
2905 // doesn't exists
2906 getFile(function (files, finish) {
2907 if (files.length < 0) {
2908 alert('선택된 파일이 없습니다.')
2909 if (isLastItem) finish();
2910 return;
2911 }
2912 if (files.length > 1) {
2913 alert('한 개의 파일만 선택해주세요.');
2914 finish();
2915 return;
2916 }
2917 var file = files[0];
2918 if (file) {
2919 var reader = new FileReader();
2920 reader.onload = async function (evt) {
2921 SET.customIdenticons[h] = reader.result;
2922 await SET.save();
2923 alert('설정됐습니다. 새로고침시 적용됩니다');
2924 finish();
2925 };
2926 reader.readAsDataURL(file);
2927 }
2928 });
2929 }
2930 });
2931 if (typeof identiconDictionary[n] === 'undefined' && typeof SET.customIdenticons[n] !== 'undefined')
2932 identiconDictionary[n] = SET.customIdenticons[n];
2933 if (typeof identiconDictionary[n] === 'undefined') {
2934 switch(SET.identiconLibrary) {
2935 case 'gravatar':
2936 identiconDictionary[n] = "https://secure.gravatar.com/avatar/" + n.substring(0, 32) + "?s=64&d=identicon"
2937 break;
2938 case 'gravatar-monster':
2939 identiconDictionary[n] = "https://secure.gravatar.com/avatar/" + n.substring(0, 32) + "?s=64&d=monsterid"
2940 break;
2941 case 'identicon':
2942 identiconDictionary[n] = "data:image/svg+xml;base64," + new Identicon(n, {size: 64, format: 'svg'}).toString();
2943 break;
2944 default:
2945 case 'jdenticon':
2946 identiconDictionary[n] = "data:image/svg+xml;base64," + btoa(jdenticon.toSvg(n, 64));
2947 break;
2948 }
2949 }
2950 let identiconImage = identiconDictionary[n];
2951 identicon.querySelector('img').src = identiconImage;
2952 message.element.parentNode.insertBefore(identicon, message.element);
2953 message.element.querySelector('.r-head').style.borderLeft = 'none';
2954
2955 identicon.querySelector('.identicon-pointer').style.borderRight = '15px solid ' + getComputedStyle(message.element.querySelector('.r-head')).backgroundColor;
2956 if (message.element.parentNode.dataset.id != 1) {
2957 message.element.parentNode.style.marginTop = '-76px';
2958 identicon.style.marginTop = '-66px';
2959 }
2960 }
2961 }
2962
2963 function checkIP(message) {
2964 if (!message.author.isIP)
2965 return;
2966 var span = document.createElement("span");
2967 span.style.color = "red";
2968 message.nfHeadspan.appendChild(span);
2969 span.innerHTML = "[(IP 확인중: IP정보 조회중)]";
2970 // get ip info
2971 getIpInfo(message.author.name, function (resObj) {
2972 if (resObj !== null) {
2973 var country = resObj.country;
2974 var countryName = korCountryNames[country.toUpperCase()] || country;
2975 var isp = resObj.org;
2976 getFlagIcon(country.toLowerCase(), async function (data) {
2977 let whoisResult = SET.checkWhoisNetTypeOnDiscuss ? await (new Promise((resolve, reject) => {
2978 getIpWhois(message.author.name, whoisRes => resolve(whoisRes));
2979 })) : null;
2980 let whoisNetType = false;
2981 if (whoisResult && whoisResult.success && !whoisResult.raw) {
2982 if (whoisResult.result.korean.ISP && whoisResult.result.korean.ISP.netinfo && whoisResult.result.korean.ISP.netinfo.netType)
2983 whoisNetType = whoisResult.result.korean.ISP.netinfo.netType
2984 else if (whoisResult.result.korean.user && whoisResult.result.korean.user.netinfo && whoisResult.result.korean.user.netinfo.netType)
2985 whoisNetType = whoisResult.result.korean.user.netinfo.netType
2986 }
2987 span.innerHTML = `[<img src="${data}" style="height: 0.9rem;" title="${countryName}"></img> ${isp}${await checkVPNGateIP(message.author.name) ? " (VPNGATE)" : ""}]${whoisNetType ? "<span style=\"color: darkred;\">[" + whoisNetType + "]</span>" : ""}<a href="#" class="get-whois">[WHOIS]</a>`;
2988 span.querySelector('a.get-whois').addEventListener('click', function (evt) {
2989 evt.preventDefault();
2990 whoisPopup(message.author.name);
2991 });
2992 });
2993 } else {
2994 span.innerHTML = "[IP조회실패]<a href=\"#\" class=\"get-whois\">[WHOIS]</a>"
2995 span.querySelector('a.get-whois').addEventListener('click', function (evt) {
2996 evt.preventDefault();
2997 whoisPopup(message.author.name);
2998 })
2999 }
3000 });
3001 }
3002
3003 function quickBlockLoop(i) {
3004 let blockAnchor = document.createElement("a");
3005 blockAnchor.href = "#";
3006 blockAnchor.textContent = "[차단]";
3007 blockAnchor.addEventListener('click', (evt) => {
3008 evt.preventDefault();
3009 quickBlockPopup({
3010 author: i.author,
3011 defaultDuration: SET.quickBlockDefaultDuration,
3012 defaultReason: SET.quickBlockReasonTemplate_discuss.replace(/\$\{host\}/g, location.host)
3013 .replace(/\$\{threadNo\}/g, ENV.topicNo)
3014 .replace(/\$\{messageNo\}/g, i.no)
3015 });
3016 });
3017 i.nfHeadspan.appendChild(blockAnchor);
3018 }
3019
3020 let emphasizeResStyle = document.createElement("style");
3021 let emphasizedHash = null;
3022 document.head.appendChild(emphasizeResStyle);
3023
3024 function emphasizeWhenMouseoverLoop(i) {
3025 let usernameHash = SHA1((i.author.isIP ? '!IP!' : '!ID!') + i.author.name);
3026 i.element.dataset.usernameHash = usernameHash;
3027 let linkAnchor = document.createElement("a");
3028 linkAnchor.href = "#";
3029 linkAnchor.className = "nf-emp-thread-link"
3030 linkAnchor.innerHTML = '[강조]' // ionicon에 압정 아이콘이 안 보인다. 이런데 쓰면 딱인데...
3031 linkAnchor.title = "이 사용자의 쓰레드를 강조합니다."
3032 linkAnchor.addEventListener("click", (evt) => {
3033 evt.preventDefault();
3034 if (emphasizedHash !== usernameHash) {
3035 emphasizedHash = usernameHash;
3036 emphasizeResStyle.innerHTML = `.res[data-username-hash="${usernameHash}"] .r-head {background: #8a8a8a !important} .res[data-username-hash="${usernameHash}"] .r-head.first-author {background: #8DAD8A !important} .res[data-username-hash="${usernameHash}"] .nf-emp-thread-link {color: red;} .res:not([data-username-hash="${usernameHash}"]) {filter: blur(1.5px);}`
3037 } else {
3038 emphasizedHash = null;
3039 emphasizeResStyle.innerHTML = "";
3040 }
3041 console.log(emphasizedHash);
3042 });
3043 i.nfRightHeadSpan.appendChild(linkAnchor);
3044 i.element.addEventListener('mouseover', () => {
3045 if(emphasizedHash === null) emphasizeResStyle.innerHTML = `.res[data-username-hash="${usernameHash}"] .r-head {background: #8a8a8a !important} .res[data-username-hash="${usernameHash}"] .r-head.first-author {background: #8DAD8A !important}`
3046 })
3047 i.element.addEventListener('mouseout', () => {
3048 if(emphasizedHash === null) emphasizeResStyle.innerHTML = ''
3049 });
3050 }
3051
3052 function deserializeResDom(resElement) {
3053 let userLink = resElement.querySelector('.r-head > a'),
3054 anchor = resElement.querySelector('.r-head .num > a');
3055 return {
3056 element: resElement,
3057 no: anchor.id,
3058 author: {
3059 name: resElement.querySelector('.r-head > a').textContent.trim(),
3060 isFirst: resElement.querySelector('.r-head').className.includes('first-author'),
3061 isIP: resElement.querySelector('.r-head > a').href.includes('/contribution/ip')
3062 },
3063 bodyElement: resElement.querySelector('.r-body'),
3064 get nfHeadspan() {
3065 let headspan = resElement.querySelector('.nf-headinfo')
3066 if (!headspan) {
3067 headspan = document.createElement("span");
3068 headspan.className = "nf-headinfo";
3069 userLink.parentNode.insertBefore(headspan, userLink.nextSibling);
3070 headspan.style.marginLeft = "1em";
3071 }
3072 delete this.nfHeadspan;
3073 return this.nfHeadspan = headspan;
3074 },
3075 get nfRightHeadSpan() {
3076 let rightHeadSpan = resElement.querySelector('.nf-headinfo-right')
3077 if (!rightHeadSpan) {
3078 rightHeadSpan = document.createElement("span");
3079 rightHeadSpan.className = "nf-headinfo-right"
3080 let time = resElement.querySelector('.r-head > .pull-right > time');
3081 time.parentNode.insertBefore(rightHeadSpan, time.nextSibling);
3082 rightHeadSpan.style.marginLeft = "0.25rem";
3083 }
3084 delete this.nfRightHeadSpan;
3085 return this.nfRightHeadSpan = rightHeadSpan;
3086 }
3087 }
3088 }
3089
3090 function discussLoop() {
3091 let handles = {
3092 "preview": previewFunction,
3093 "identicon": identiconLoop
3094 };
3095 if (SET.addQuickBlockLink) handles.quickBlock = quickBlockLoop
3096 if (SET.lookupIPonDiscuss) handles.checkIp = checkIP;
3097 if (SET.emphasizeResesWhenMouseover) handles.emphasizeReses = emphasizeWhenMouseoverLoop;
3098 let messages = document.querySelectorAll('.res-wrapper:not(.res-loading) > .res:not([nf-looped])');
3099 let tmpcnt = 0; //fordebug
3100 for (let i of messages)
3101 for (let j in handles) {
3102 let resObj = deserializeResDom(i);
3103 if (i.dataset["nfLoop_" + j] !== "yes") {
3104 i.dataset["nfLoop_" + j] = "yes";
3105 setTimeout(() => {
3106 handles[j](resObj);
3107 }, 0);
3108 }
3109 }
3110 if (SET.notifyForUnvisibleThreads) {
3111 Notification.requestPermission().then(() => {
3112 if (document.visibilityState === 'hidden') {
3113 if (SET.loadUnvisibleReses && !unvisibleResesAllLoaded)
3114 return;
3115 let n = new Notification(`토론 알림 : ${ENV.topicTitle}`, {
3116 body: '토론을 확인해주세요. 알람을 원하지 않으시면 NamuFix 설정에서 비활성화할 수 있습니다.',
3117 icon: `/favicon.ico`
3118 })
3119 }
3120 });
3121 }
3122 }
3123 discussLoop();
3124 var observer = new MutationObserver(discussLoop);
3125 observer.observe(document.querySelector("#res-container"), {
3126 childList: true
3127 });
3128
3129 } else if (ENV.IsUserContribsPage) {
3130 function insertBeforeTable(element) {
3131 var bread = document.querySelector("article > ol.breadcrumb.link-nav, body.Liberty .wiki-article ol.breadcrumb.link-nav");
3132 bread.parentNode.insertBefore(element, bread);
3133 }
3134
3135 function makeHeatTable(times) {
3136 try {
3137 // 가공
3138 var maps = {}; // { day: {0: int, 1: int}, .... }
3139 var maxValue = 0;
3140 for (var i = 0; i < 7; i++) {
3141 maps[i] = {};
3142 for (var ii = 0; ii < 24; ii++) {
3143 maps[i][ii] = 0;
3144 }
3145 }
3146 for (var i = 0; i < times.length; i++) {
3147 var ti = times[i];
3148 var v = ++maps[ti.getDay()][ti.getHours()];
3149 if (maxValue < v) maxValue = v;
3150 }
3151
3152 // 표 생성
3153 var table = document.createElement("table");
3154 var headTr = document.createElement("tr");
3155 headTr.innerHTML = '<th>요일</th>';
3156 table.appendChild(headTr);
3157 var dayNames = ['일', '월', '화', '수', '목', '금', '토'];
3158 for (var i = 0; i < 7; i++) {
3159 var tr = document.createElement("tr");
3160 tr.innerHTML += `<th>${dayNames[i]}</th>`;
3161 for (var ii = 0; ii < 24; ii++) {
3162 var td = document.createElement("td");
3163 td.innerHTML = '&nbsp;'
3164 td.style.background = `rgba(61,0,61,${maps[i][ii] / maxValue})`;
3165 if (i == 0) {
3166 function twoDigits(a) {
3167 var p = String(a);
3168 return p.length == 1 ? '0' + p : p;
3169 }
3170 headTr.innerHTML += `<th>${twoDigits(ii)}:00 ~ ${twoDigits(ii + 1)}:00</th>`;
3171 }
3172 tr.appendChild(td);
3173 }
3174 table.appendChild(tr);
3175 }
3176 return table;
3177 } catch (err) {
3178 alert(err.message);
3179 }
3180 }
3181
3182 var p = document.createElement("p");
3183 p.innerHTML += '<style>.contInfo { border-collapse: collapse; border: 1px solid black; padding: 2px;} #contInfo td {padding: 3px;} #contInfo td:nth-child(2) {border-left: 1px solid black;}</style>';
3184 var ipPattern = /\/ip\/([a-zA-Z0-9:\.]+)\/(?:document|discuss)(?:#.+|)$/;
3185 if (ipPattern.test(location.href)) {
3186 // ip
3187 // check ip
3188 var ip = ipPattern.exec(location.href)[1];
3189 var ipInfo = document.createElement("p");
3190 ipInfo.innerHTML = '<div style="border: 1px black solid; padding: 2px;">IP 관련 정보를 조회중입니다. 잠시만 기다려주세요.</div>'
3191 insertBeforeTable(ipInfo);
3192 getIpInfo(ip, function (resObj) {
3193 var country = resObj.country;
3194 var countryName = korCountryNames[country.toUpperCase()] || country.toUpperCase();
3195 var isp = resObj.org;
3196 getFlagIcon(country.toLowerCase(), async function (countryIcon) {
3197 ipInfo.innerHTML = `<table class="contInfo">
3198 <tbody>
3199 <tr><td>국가</td><td><img src=\"${countryIcon}\" style=\"height: 0.9rem;\"></img> ${countryName}</td></tr>
3200 <tr><td>통신사</td><td>${isp}</td></tr>
3201 <tr><td>VPNGATE</td><td>${await checkVPNGateIP(ip) ? "<span style=\"color: black;\">Y</span>" : "N"}</td></tr>
3202 <tr><td>KISA WHOIS</td><td><a href="#" class="get-whois">조회하기</a></td></tr>
3203 <tr class="nf_ipblockchecking"><td colspan="2">IP 차단기록을 검색하고 있습니다... 잠시만 기다려주세요.</td></tr>
3204 <tr class="nf-quickblock-row"><td>빠른차단</td><td><a class="nf-quickblock">빠른차단</a></td></tr>
3205 </tbody>
3206 <tfoot>
3207 <tr><td colspan="2" style="border-top: 1px solid black;">기술적인 한계로, VPNGATE 여부는 "현재 VPNGATE VPN인가?"의 여부이지, "작성 당시에 VPNGATE VPN인가?"의 여부가 아닙니다.<br>
3208 또한 외국 IP의 차단기록의 경우 /32 마스크만 검색하며, 사측의 비공개 차단은 검색이 불가능합니다.</td></tr>
3209 </foot>
3210 </table>`;
3211 ipInfo.querySelector('a.get-whois').addEventListener('click', function (evt) {
3212 evt.preventDefault();
3213 whoisPopup(ip);
3214 })
3215 ipInfo.querySelector('a.nf-quickblock').addEventListener('click', (evt) => {
3216 evt.preventDefault();
3217 quickBlockPopup({
3218 author: {
3219 name: ip,
3220 isIP: true
3221 },
3222 defaultDuration: SET.quickBlockDefaultDuration,
3223 defaultReason: "긴급조치"
3224 })
3225 });
3226 if(!SET.addQuickBlockLink)
3227 ipInfo.querySelector('.nf-quickblock-row').style.display = 'none';
3228 function displayIsBlocked(mask, tbody) {
3229 let row = document.createElement("tr");
3230 row.innerHTML = "<td>IP차단기록 (" + mask + ")</td><td class=\"nf_isipblocked\">조회중입니다...</td>";
3231 tbody.appendChild(row);
3232 namuapi.searchBlockHistory({
3233 query: ip + mask,
3234 isAuthor: false
3235 }, function (result) {
3236 var filtered = result.filter(function (v) {
3237 return v.blocked === ip + mask;
3238 });
3239 if (filtered.length == 0) {
3240 return row.querySelector('.nf_isipblocked').innerHTML = "차단기록 없음.";
3241 }
3242 var actType = filtered[0].type;
3243 if (actType == "blockIP") {
3244 row.querySelector('.nf_isipblocked').innerHTML = filtered[0].blocker + '에 의해 ' + (filtered[0].duration || '') + ' <span style="color:red">차단됨.</span> (일시 : ' + formatDateTime(filtered[0].at) + ', 이유 : ' + filtered[0].reason + ')';
3245 } else if (actType == "unblockIP") {
3246 row.querySelector('.nf_isipblocked').innerHTML = filtered[0].blocker + '에 의해 ' + (filtered[0].duration || '') + ' <span style="color:green">차단이 해제됨.</span> (일시 : ' + formatDateTime(filtered[0].at) + ')';
3247 } else {
3248 row.querySelector('.nf_isipblocked').innerHTML = "??? 기록 검색중 오류가 발생함.";
3249 }
3250 });
3251 }
3252 getIpWhois(ip, (whoisRes) => {
3253 ipInfo.querySelector('.nf_ipblockchecking').parentNode.removeChild(ipInfo.querySelector('.nf_ipblockchecking'));
3254 if(!whoisRes.success || whoisRes.raw) {
3255 displayIsBlocked('/32', ipInfo.querySelector('tbody'))
3256 } else {
3257 let prefixes = [];
3258 if(whoisRes.result.korean.ISP && whoisRes.result.korean.ISP.netinfo && whoisRes.result.korean.ISP.netinfo.prefix)
3259 prefixes = prefixes.concat(whoisRes.result.korean.ISP.netinfo.prefix.split('+'));
3260 if(whoisRes.result.korean.user && whoisRes.result.korean.user.netinfo && whoisRes.result.korean.user.netinfo.prefix)
3261 prefixes = prefixes.concat(whoisRes.result.korean.user.netinfo.prefix.split('+'));
3262 prefixes = prefixes.filter((v,i,s) => s.indexOf(v) === i); // https://stackoverflow.com/a/14438954
3263 if(!prefixes.includes('/32')) prefixes.push('/32');
3264 let delay = 0;
3265 for(let prefix of prefixes) {
3266 setTimeout(() => {displayIsBlocked(prefix, ipInfo.querySelector('tbody'));}, (delay++) * SET.ipBlockHistoryCheckDelay + 1);
3267 }
3268 }
3269 });
3270 });
3271 });
3272 } else {
3273 // parse username
3274 var userIdPattern = /^\/contribution\/author\/(.+?)\/(?:document|discuss)/;
3275 var userId = userIdPattern.exec(location.pathname)[1];
3276
3277 // block user link
3278 if(SET.addQuickBlockLink) {
3279 let quickBlockLink = document.createElement("div");
3280 quickBlockLink.innerHTML = '<div style="border: 1px black solid; padding: 2px;">빠른차단 : <a href="#">[차단]</a></div>'
3281 quickBlockLink.querySelector('a').addEventListener('click', (evt)=>{
3282 evt.preventDefault();
3283 quickBlockPopup({
3284 author: {
3285 name: userId,
3286 isIP: false
3287 },
3288 defaultDuration: SET.quickBlockDefaultDuration,
3289 defaultReason: "긴급조치"
3290 })
3291 })
3292 insertBeforeTable(quickBlockLink);
3293 }
3294 // user blockhistory
3295 var userInfo = document.createElement("p");
3296 userInfo.innerHTML = '<div style="border: 1px black solid; padding: 2px;" class="nf_blockhistory">차단 기록 조회중...</div>'
3297 insertBeforeTable(userInfo);
3298 namuapi.searchBlockHistory({
3299 query: userId,
3300 isAuthor: false
3301 }, function (result) {
3302 var filtered = result.filter(function (v) {
3303 return v.blocked == userId
3304 });
3305 if (filtered.length == 0) {
3306 return userInfo.querySelector('.nf_blockhistory').innerHTML = "차단기록 없음.";
3307 }
3308 userInfo.querySelector('.nf_blockhistory').innerHTML = '<style>.nf_blockhistory p {margin: 1px; padding: 0px;}</style>';
3309 for(var i = 0; i < filtered.length; i++) {
3310 var filteredItem = filtered[i];
3311 var actType = filteredItem.type;
3312 if (actType == "blockUser") {
3313 userInfo.querySelector(i == 0 ? '.nf_blockhistory' : '.nf_blockhistory_rest').innerHTML += '<p>' + filteredItem.blocker + '에 의해 ' + (filteredItem.duration || '') + ' <span style="color:red">차단됨.</span> (일시 : ' + formatDateTime(filteredItem.at) + ', 이유 : ' + filteredItem.reason + ')' + '</p>';
3314 } else if (actType == "unblockUser") {
3315 userInfo.querySelector(i == 0 ? '.nf_blockhistory' : '.nf_blockhistory_rest').innerHTML += '<p>' + filteredItem.blocker + '에 의해 ' + (filteredItem.duration || '') + ' <span style="color:green">차단이 해제됨.</span> (일시 : ' + formatDateTime(filteredItem.at) + ')' + '</p>';
3316 }
3317 if (i == 0) {
3318 userInfo.querySelector('.nf_blockhistory').innerHTML += '<a href="#" id="nf_more_blockhistory">[차단기록 더 보기]</a><div class="nf_blockhistory_rest" style="display: none;"></div>'
3319 userInfo.querySelector('#nf_more_blockhistory').addEventListener('click', (evt) => {
3320 evt.preventDefault();
3321 document.querySelector('.nf_blockhistory_rest').style.display = '';
3322 evt.target.style.display = 'none';
3323 })
3324 } else if (i == filtered.length - 1) {
3325 userInfo.querySelector('.nf_blockhistory_rest').innerHTML += '<a href="#" id="nf_less_blockhistory">[숨기기]</a>';
3326 userInfo.querySelector('#nf_less_blockhistory').addEventListener('click', (evt) => {
3327 evt.preventDefault();
3328 document.querySelector('.nf_blockhistory_rest').style.display = 'none';
3329 userInfo.querySelector('#nf_more_blockhistory').style.display = '';
3330 })
3331 }
3332 }
3333 });
3334 }
3335
3336 if (/\/document(?:#.+|)$/.test(location.href)) {
3337 var rows = document.querySelectorAll('table tbody tr');
3338 var contCount = 0,
3339 contTotalBytes = 0,
3340 contDocuments = 0,
3341 deletedDocuments = [],
3342 createdDocuments = [],
3343 documentNameBefore = "",
3344 contributedAt = [];
3345 var documents = [];
3346 for (var i = 0; i < rows.length; i++) {
3347 var row = rows[i];
3348 if (row.querySelector('a')) {
3349 var documentName = row.querySelector('a').getAttribute('href');
3350 documentNameBefore = documentName;
3351 var contributedBytes = row.querySelector('span.f_r > span').innerHTML;
3352 var negativeContribution = /^\-[0-9]+/.test(contributedBytes);
3353 if (/^\+[0-9]+/.test(contributedBytes)) contributedBytes = contributedBytes.substring(contributedBytes.indexOf('+'));
3354 contributedBytes = Number(contributedBytes);
3355 if (documents.indexOf(documentName) == -1) documents.push(documentName);
3356 contCount++;
3357 if (negativeContribution)
3358 contTotalBytes -= contributedBytes;
3359 else
3360 contTotalBytes += contributedBytes;
3361
3362 if (row.querySelector('time')) {
3363 contributedAt.push(new Date(row.querySelector('time').getAttribute("datetime")));
3364 }
3365 } else if (row.querySelector('i')) {
3366 var italicText = row.querySelector('i').innerHTML;
3367 if (italicText == '(새 문서)' && createdDocuments.indexOf(documentNameBefore) == -1) createdDocuments.push(documentNameBefore);
3368 else if (italicText == '(삭제)' && deletedDocuments.indexOf(documentNameBefore) == -1) deletedDocuments.push(documentNameBefore);
3369 }
3370 }
3371 p.innerHTML += `<table class="contInfo">
3372 <tfoot>
3373 <tr><td colspan="2" style="border-top: 1px solid black;">최근 30일간의 데이터만 반영되었으므로, 최근 30일 간의 기여 정보입니다.</td></tr>
3374 </foot>
3375 <tbody>
3376 <tr><td>총 기여 횟수</td><td>${contCount}회</td></tr>
3377 <tr><td>기여한 바이트 총합</td><td>${contTotalBytes}자</td></tr>
3378 <tr><td>총 기여한 문서 (ACL 변경, 문서 이동 포함) 수</td><td>${documents.length}개</td></tr>
3379 <tr><td>삭제한 문서 수</td><td>${deletedDocuments.length}개</td></tr>
3380 <tr><td>새로 만든 문서 수</td><td>${createdDocuments.length}개</td></tr>
3381 <tr><td>한 문서당 평균 기여 바이트</td><td>${(contTotalBytes / documents.length)}자</td></tr>
3382 <tr><td>시간대별 기여/활동 횟수 분포(문서 기여)</td><td><a href="#NothingToLink" id="punch">여기를 눌러 확인</a></td></tr>
3383 </tbody>
3384 </table>`;
3385 p.querySelector('a#punch').addEventListener('click', function (evt) {
3386 evt.preventDefault();
3387
3388 var win = TooSimplePopup();
3389 win.title('시간대별 기여/활동 횟수 분포(문서 기여)');
3390 win.content(function (element) {
3391 element.appendChild(makeHeatTable(contributedAt));
3392 });
3393 win.button('확인', win.close);
3394 })
3395 } else if (/\/discuss(?:#.+|)$/.test(location.href)) {
3396 function standardDeviation(numbers) {
3397 var total = 0;
3398 for (var i = 0; i < numbers.length; i++) {
3399 total += numbers[i];
3400 }
3401 var avg = total / numbers.length;
3402 var temp1 = 0;
3403 for (var i = 0; i < numbers.length; i++) {
3404 temp1 += Math.pow(numbers[i] - avg, 2);
3405 }
3406 temp1 /= numbers.length;
3407 return Math.sqrt(temp1);
3408 }
3409 var rows = document.querySelectorAll('table tbody tr');
3410 var docuAndTalks = {};
3411 var talkedAt = [];
3412 for (var i = 0; i < rows.length; i++) {
3413 var row = rows[i];
3414 if (row.querySelectorAll('a').length == 0) continue;
3415 var docuNow = rows[i].querySelector('a').getAttribute('href').replace(/#[0-9]+$/, '');
3416 docuNow = /^\/thread\/(.+)(?:#[0-9]+|)/.exec(docuNow)[1];
3417 if (docuAndTalks[docuNow]) {
3418 docuAndTalks[docuNow]++;
3419 } else {
3420 docuAndTalks[docuNow] = 1;
3421 }
3422 if (row.querySelector('time')) {
3423 talkedAt.push(new Date(row.querySelector('time').getAttribute("datetime")));
3424 }
3425 }
3426 var totalTalks = 0,
3427 avgTalks = 0,
3428 discussCount = 0,
3429 Talks = [];
3430 for (var i in docuAndTalks) {
3431 totalTalks += docuAndTalks[i];
3432 Talks.push(docuAndTalks[i]);
3433 }
3434 discussCount = Object.keys(docuAndTalks).length;
3435 avgTalks = totalTalks / discussCount;
3436 p.innerHTML += `<table class="contInfo">
3437 <tfoot>
3438 <tr><td colspan="2" style="border-top: 1px solid black;">최근 30일 간의 토론 정보만 반영되었으므로, 최근 30일 간의 토론 정보입니다.</td></tr>
3439 </tfoot>
3440 <tbody>
3441 <tr><td>총 발언 수</td><td>${totalTalks}</td></tr>
3442 <tr><td>참여한 토론 수</td><td>${discussCount}</td></tr>
3443 <tr><td>한 토론당 평균 발언 수</td><td>${avgTalks}</td></tr>
3444 <tr><td>한 토론당 발언 수 표준편차</td><td>${standardDeviation(Talks)}</td></tr>
3445 <tr><td>시간대별 기여/활동 횟수 분포(토론)</td><td><a href="#NothingToLink" id="punch">여기를 눌러 확인</a></td></tr>
3446 </tbody>
3447 </table>`;
3448 p.querySelector('a#punch').addEventListener('click', function (evt) {
3449 evt.preventDefault();
r762
3450
r2070
3451 var win = TooSimplePopup();
3452 win.title('시간대별 기여/활동 횟수 분포(토론)');
3453 win.content(function (container) {
3454 container.appendChild(makeHeatTable(talkedAt));
3455 });
3456 win.button('확인', win.close);
3457 })
3458 } else {
3459 delete p;
3460 }
3461 if (typeof p !== 'undefined') insertBeforeTable(p);
3462 } else if (ENV.IsDiff) {
3463 setTimeout(function () {
3464 var diffLinksHtml = `<nav>
3465 <ul class="pagination">
3466 <li class="page-item"><a href="/diff/${ENV.docTitle}?oldrev=${ENV.beforeRev - 1}&rev=${ENV.beforeRev}">&lt;-- r${ENV.beforeRev - 1} vs r${ENV.beforeRev}</a></li>
3467 <li class="page-item"><a href="/w/${ENV.docTitle}?rev=${ENV.beforeRev}">r${ENV.beforeRev} 보기</a></li>
3468 <li class="page-item"><a href="/history/${ENV.docTitle}?from=${ENV.afterRev}">역사로</a></li>
3469 <li class="page-item"><a href="/w/${ENV.docTitle}?rev=${ENV.afterRev}">r${ENV.afterRev} 보기</a></li>
3470 <li class="page-item"><a href="/diff/${ENV.docTitle}?oldrev=${ENV.afterRev}&rev=${ENV.afterRev + 1}">r${ENV.afterRev} vs r${ENV.afterRev + 1} --&gt;</a></li>
3471 </ul>
3472 </nav>`;
3473 if (ENV.skinName == "liberty") {
3474 var divTag = document.createElement("div");
3475 var articleTag = document.querySelector('.wiki-article');
3476 if (articleTag.querySelector('.diff') == null) return;
3477 divTag.innerHTML = diffLinksHtml;
3478 articleTag.insertBefore(divTag, articleTag.firstChild.nextSibling);
3479 }
3480 var divTag = document.createElement("div");
3481 var articleTag = document.querySelector('article');
3482 if (articleTag == null) return;
3483 divTag.innerHTML = diffLinksHtml;
3484 articleTag.insertBefore(divTag, articleTag.querySelector('article h1').nextSibling);
3485 }, 500);
3486 } else if (ENV.IsSearch) {
3487 if (ENV.SearchQuery.indexOf('기여:') == 0) {
3488 var target = ENV.SearchQuery.substring(3).trim();
3489 if (validateIP(target)) {
3490 location.pathname = '/contribution/ip/' + target + '/document';
3491 } else {
3492 location.pathname = '/contribution/author/' + target + '/document';
3493 }
3494 }
3495 } else if (ENV.IsIPACL || ENV.IsSuspendAccount || ENV.IsBoardIPACL || ENV.IsBoardSuspendAccount) {
3496 var expireSelect = document.querySelector('select[name=expire]');
r1099
3497
r2070
3498 function enterEasily() {
3499 enterTimespanPopup('차단기간 쉽게 입력하기', function (result) {
3500 if (result !== null)
3501 document.querySelector('input[name="expire"]').value = result;
3502 });
3503 }
r1099
3504
r2070
3505 function replaceExpireSelect() {
3506 var newExpireInput = document.createElement('input');
3507 newExpireInput.setAttribute("type", "number");
3508 newExpireInput.setAttribute("class", "form-control");
3509 newExpireInput.setAttribute("name", "expire");
3510 var explain = document.createElement("p");
3511 explain.innerText = "차단기간은 초 단위로 입력해야 하며, 영구차단시에는 0을, 사용자 차단에서 차단 해제시에는 -1을 입력하시면 됩니다.";
3512 var enterEasilyLink = document.createElement("a");
3513 enterEasilyLink.innerText = "차단기간 간편하게 입력하기";
3514 enterEasilyLink.href = "#";
3515 enterEasilyLink.addEventListener('click', function (evt) {
3516 evt.preventDefault();
3517 enterEasily();
3518 })
3519 expireSelect.parentNode.insertBefore(newExpireInput, expireSelect);
3520 expireSelect.parentNode.insertBefore(explain, expireSelect);
3521 expireSelect.parentNode.insertBefore(document.createElement("br"), expireSelect);
3522 expireSelect.parentNode.insertBefore(enterEasilyLink, expireSelect);
3523 expireSelect.parentNode.removeChild(expireSelect);
3524 }
3525 if (expireSelect != null) {
3526 var replaceExpireLink = document.createElement("a");
3527 replaceExpireLink.href = "#";
3528 replaceExpireLink.innerText = "초 단위로 차단 기간 입력하기";
3529 replaceExpireLink.addEventListener('click', function (evt) {
3530 evt.preventDefault();
3531 replaceExpireSelect();
3532 replaceExpireLink.parentNode.removeChild(replaceExpireLink);
3533 });
3534 expireSelect.parentNode.insertBefore(replaceExpireLink, expireSelect.nextSibling);
3535 }
3536 } else if (ENV.IsHistory) {
3537 addArticleButton('토론', function () {
3538 location.href = "/discuss/" + encodeURIComponent(ENV.docTitle);
3539 })
3540 addArticleButton('리버전 점프', function () {
3541 var revNo = prompt('보고 싶은 리버전 번호를 입력하세요.').trim();
3542 if (revNo.indexOf('r') == 0) revNo = revNo.substring(1);
3543 if (/[^0-9]/.test(revNo)) {
3544 alert('올바른 입력이 아닙니다! r1 혹은 1과 같이 입력해주세요.');
3545 return;
3546 }
3547 location.href = "/w/" + encodeURIComponent(ENV.docTitle) + "?rev=" + revNo;
3548 })
3549 var historyRows = document.querySelectorAll('article .wiki-list li, body.Liberty .wiki-article .wiki-list li');
3550 for (let historyRow of [].slice.call(historyRows)) {
3551 // 긴급차단
3552 if (SET.addQuickBlockLink) {
3553 let revisionNo = historyRow.querySelector('strong').textContent.trim();
3554 let temp = historyRow.querySelector('span').innerHTML.trim();
3555 historyRow.querySelector('span').innerHTML = temp.substring(0, temp.length - 1) + ' | <a href="#" class="nf-history-quickblock">차단</a>)';
3556 historyRow.querySelector('a.nf-history-quickblock').addEventListener('click', (evt) => {
3557 evt.preventDefault();
3558 let user = historyRow.querySelectorAll('a');
3559 user = user[user.length - 1].textContent.trim();
3560 quickBlockPopup({
3561 author: {
3562 name: user,
3563 isIP: validateIP(user)
3564 },
3565 defaultDuration: SET.quickBlockDefaultDuration,
3566 defaultReason: SET.quickBlockReasonTemplate_history.replace(/\$\{host\}/g, location.host)
3567 .replace(/\$\{revisionNo\}/g, revisionNo)
3568 .replace(/\$\{docName\}/g, ENV.docTitle)
3569 });
3570 });
3571 }
3572 // ACL 가독성
3573 var italicTag = historyRow.querySelector('i');
3574 if (italicTag) {
3575 var pattern = /\(([a-zA-Z,]+?)으로 ACL 변경\)/;
3576 // 열람 수정 삭제 (한국) 토론 이동
3577 var valuePatternWithBlockKorea = /(admin|member|everyone),(admin|member|everyone),(admin|member|everyone),(true|false),(admin|member|everyone),(admin|member|everyone)/;
3578 var valuePattern = /(admin|member|everyone),(admin|member|everyone),(admin|member|everyone),(admin|member|everyone),(admin|member|everyone)/;
3579 var icons = ['ion-eye fa fa-eye', 'ion-edit fa fa-edit', 'ion-trash-a fa fa-trash', 'ion-android-textsms fa fa-comments', 'ion-arrow-right-c fa fa-arrow-right', 'ion-flag fa fa-flag'];
3580 var koreanAclConds = {
3581 'member': '회원',
3582 'admin': '관리자',
3583 'everyone': '모두'
3584 };
3585 if (pattern.test(italicTag.innerHTML)) {
3586 var aclText = pattern.exec(italicTag.innerHTML)[1];
3587 var newAclText = '';
3588 var acl = null,
3589 isKoreaBlocked = null;
3590 var matched = false;
3591 if (aclText == 'delete') {
3592 newAclText = '(ACL 초기화)';
3593 matched = true;
3594 } else if (valuePatternWithBlockKorea.test(aclText)) {
3595 acl = valuePatternWithBlockKorea.exec(aclText);
3596 isKoreaBlocked = acl[4] == 'true';
3597 acl = [acl[0], acl[1], acl[2], acl[3], acl[5], acl[6]];
3598 matched = true;
3599 } else if (valuePattern.test(aclText)) {
3600 acl = valuePattern.exec(aclText);
3601 matched = true;
3602 }
3603 if (!matched)
3604 continue;
3605 if (newAclText == '') {
3606 newAclText = '(';
3607 for (var i = 1; i <= 5; i++) {
3608 var color = acl[i] == 'admin' ? 'red' : acl[i] == 'member' ? 'orange' : null;
3609 newAclText += '<span ' + (color ? 'style="color:' + color + '" ' : '') + '><span class="icon ' + icons[i - 1] + '"></span>' + koreanAclConds[acl[i]] + '</span>,';
3610 }
3611 if (isKoreaBlocked) {
3612 newAclText += '<span style="color: blue;"><span class="icon ' + icons[5] + '"></span>한국 IP 차단</span>,';
3613 }
3614 newAclText = newAclText.substring(0, newAclText.length - 1);
3615 newAclText += '으로 ACL 변경)';
3616 }
3617 italicTag.innerHTML = italicTag.innerHTML.replace(pattern.exec(italicTag.innerHTML)[0], newAclText);
3618 }
3619 }
3620 }
3621 } else if (ENV.IsRecentChanges) {
3622 let changeRows = document.querySelectorAll('article table.table tbody tr');
3623 for (let row of changeRows) {
3624 if (!row.querySelector('a')) continue; // 편집 코멘트 필요없음.
3625 let author = {
3626 name: row.querySelector('td:nth-child(2) a').textContent.trim()
3627 }
3628 author.isIP = validateIP(author.name);
3629 if(SET.addQuickBlockLink) {
3630 let quickBlockAnchor = document.createElement("a");
3631 quickBlockAnchor.textContent = " [차단] "
3632 quickBlockAnchor.href = "#";
3633 quickBlockAnchor.addEventListener('click', (evt) => {
3634 evt.preventDefault();
3635 quickBlockPopup({
3636 author: author,
3637 defaultReason: '긴급차단 - 반달리즘',
3638 defaultDuration: SET.quickBlockDefaultDuration
3639 });
3640 })
3641 row.querySelector('td:first-child').insertBefore(quickBlockAnchor, row.querySelector('td:first-child span'));
3642 }
3643 }
3644 }
r1099
3645
r2070
3646 if (ENV.skinName == "liberty") {
3647 if (SET.addAdminLinksForLiberty) {
3648 // 관리자 링크 추가
3649 document.querySelector('nav.navbar ul.nav li.nav-item.dropdown .dropdown-menu').innerHTML += '<div class="dropdown-divider"></div>' +
3650 '<a class="dropdown-item" href="/admin/suspend_account">계정 차단</a>' +
3651 '<a class="dropdown-item" href="/admin/ipacl">IP 차단</a>' +
3652 '<a class="dropdown-item" href="/admin/grant">권한 부여</a>';
3653 if (ENV.IsDocument) {
3654 addArticleButton("ACL", function () {
3655 location.href = "/acl/" + ENV.docTitle;
3656 })
3657 }
3658 }
3659 if (ENV.IsDocument && ENV.docTitle.indexOf('사용자:') == 0) {
3660 addArticleButton("기여내역", function () {
3661 var userName = ENV.docTitle.substring(4);
3662 location.href = "/contribution/" + (validateIP(userName) ? "ip/" : "author/") + userName + "/document";
3663 })
3664 }
3665 }
3666 }
3667 document.querySelector('nav.navbar ul.nav li.nav-item.dropdown .dropdown-menu').innerHTML += '<div class="dropdown-divider"></div>' +
3668 '<a class="dropdown-item" href="https://phpgongbu.ga/archive"><span class="icon ion-wrench"></span> 게시판 아카이브 도구</a>' +
3669 '<a class="dropdown-item" href="https://phpgongbu.ga/tocont.php"><span class="icon ion-wrench"></span> 기여내역 점프기</a>' +
3670 '<a class="dropdown-item" href="https://phpgongbu.ga/ip.php"><span class="icon ion-wrench"></span> 내 IP 주소 확인</a>';
r1099
3671
3672
r2070
3673 // 아이덴티콘 버그 수정
3674 setInterval(function () {
3675 if (!/^\/discuss\/(.+?)/.test(location.pathname)) {
3676 return;
3677 }
3678 var identicons = document.querySelectorAll('.nf-identicon');
3679 for (var i = 0; i < identicons.length; i++) {
3680 var ide = identicons[i];
3681 var pa = ide.parentNode;
r1201
3682
r2070
3683 pa.removeChild(ide);
3684 pa.style.marginTop = '';
3685 }
3686 if (document.querySelector('#nf-identicon-css') != null) {
3687 var cssTag = document.querySelector('#nf-identicon-css');
3688 cssTag.parentNode.removeChild(cssTag);
3689 }
3690 }, 50);
r1795
3691
r2070
3692 addItemToMemberMenu('내 사용자 토론', function (evt) {
3693 evt.preventDefault();
r1795
3694
r2070
3695 location.href = 'https://namu.wiki/discuss/%EC%82%AC%EC%9A%A9%EC%9E%90:Accessable'
3696 });
r1795
3697
r2070
3698 addItemToMemberMenu('<div class="dropdown-divider"></div>');
r1795
3699
r2070
3700 // 설정 메뉴 추가
3701 addItemToMemberMenu("NamuFix 설정", async function (evt) {
3702 evt.preventDefault();
r1795
3703
r2070
3704 var win = TooSimplePopup();
3705 var elems = {};
3706 win.title('NamuFix 설정');
3707 await SET.load();
3708 win.content(async function (el) {
3709 el.className += " NFSettingsContainer";
3710 el.innerHTML = `<style>.NFSettingsContainer h1 {font-size: 17pt; margin-left: 8px;} .NFSettingsContainer h2 {font-size: 13pt; margin-left: 8px;} .NFSettingsContainer .settings-paragraph {margin-left: 8px;}</style>
3711 <h1>NamuFix 전역</h1>
3712 <div class="settings-paragraph">
3713 <h2>IP정보 조회시 기관명</h2>
3714 <div class="settings-paragraph">
3715 <p>IP정보 조회시에 무슨 기관명을 이용할지 결정합니다. 아래 설정에서 KISA WHOIS 결과에서의 기관명이 선택됐는데 KISA WHOIS 결과가 조회되지 않을 시 자동으로 ipinfo.io에서 조회됩니다. 이 설정은 KISA WHOIS 조회를 제외한 모든 IP정보 조회를 동반한 기능(예: 토론시 익명 기여자 IP주소 조회, 익명 기여자 기여목록에서의 IP 정보 등)에 영향을 끼칩니다.</p>
3716 <input type="radio" name="ipInfoDefaultOrg" data-setname="ipInfoDefaultOrg" data-setvalue="ipinfo.io">ipinfo.io에서 조회 (기본값)<br>
3717 <input type="radio" name="ipInfoDefaultOrg" data-setname="ipInfoDefaultOrg" data-setvalue="KISAuser">KISA WHOIS 결과에서 IP 이용 기관명<br>
3718 <input type="radio" name="ipInfoDefaultOrg" data-setname="ipInfoDefaultOrg" data-setvalue="KISAISP">KISA WHOIS 결과에서 IP 보유 기관명<br>
3719 <input type="radio" name="ipInfoDefaultOrg" data-setname="ipInfoDefaultOrg" data-setvalue="KISAuserOrISP">KISA WHOIS 결과에서 IP 보유 기관명 혹은 IP 이용 기관명<br>
3720 </div>
3721 <h2>동시요청제한</h2>
3722 <div class="settings-paragraph">
3723 관리작업시의 동시요청제한 :
3724 <input type="number" data-setname="adminReqLimit"></input><br>
3725 이미지 업로드시의 동시 요청 제한 :
3726 <input type="number" data-setname="fileUploadReqLimit"></input><br>
3727 IP이용자의 기여목록에서 차단기록 검색 딜레이 :
3728 <input type="number" data-setname="ipBlockHistoryCheckDelay"></input>ms (1000ms=1초)
3729 <br><strong>경고 : 너무 높게 설정하면 reCAPTCHA가 뜹니다.</strong>
3730 </div>
3731 <h2>umi 쿠키</h2>
3732 <div class="settings-paragraph">
3733 <p>NamuFix 실행시 umi 쿠키값을 다음과 같이 변경합니다. 공백으로 설정시 변경하지 않습니다.</p>
3734 <input type="text" data-setname="umiCookie"></input>
3735 </div>
3736 </div>
3737 <h1>토론 편의성</h1>
3738 <div class="settings-paragraph">
3739 <h2>토론 아이덴티콘</h2>
3740 <div class="settings-paragraph">
3741 <input type="radio" name="discussIdenti" data-setname="discussIdenti" data-setvalue="icon">디시라이트 갤러콘 방식<br>
3742 <input type="radio" name="discussIdenti" data-setname="discussIdenti" data-setvalue="headBg">스레딕 헬퍼 방식<br>
3743 <input type="radio" name="discussIdenti" data-setname="discussIdenti" data-setvalue="identicon">아이덴티콘<br>
3744 <input type="radio" name="discussIdenti" data-setname="discussIdenti" data-setvalue="none">사용 안함
3745 </div>
3746 <h2>아이덴티콘 라이브러리</h2>
3747 <div class="settings-paragraph">
3748 <p>참고 : NamuFix에서 특정 사용자의 이메일주소를 조회할 수 없기에 Gravatar, Gravatar(monsterid)는 해당 사용자의 그라바타와 다르게 나옴.</p>
3749 <input type="radio" name="identiconLibrary" data-setname="identiconLibrary" data-setvalue="identicon">stewartlord/identicon.js (GitHub스타일의 아이덴티콘)<br>
3750 <input type="radio" name="identiconLibrary" data-setname="identiconLibrary" data-setvalue="jdenticon">jdenticon (원을 포함하는 여러 도형과 다양한 색으로 이루어진 아이덴티콘)<br>
3751 <input type="radio" name="identiconLibrary" data-setname="identiconLibrary" data-setvalue="gravatar">Gravatar (Gravatar에서 생성되는 기하학적 패턴 기반의 아이덴티콘)<br>
3752 <input type="radio" name="identiconLibrary" data-setname="identiconLibrary" data-setvalue="gravatar-monster">Gravatar(monsterid) (Gravatar에서 생성되는 <del style="color: gray;">존나 못생긴</del> 몬스터)
3753 </div>
3754 <h2>토론에서 익명 기여자 IP주소 조회</h2>
3755 <div class="settings-paragraph">
3756 <p>VPNGate 여부, 통신사, 국가이미지를 IP 주소 옆에 표시합니다. 요청 수가 많을 시 실패할 수 도 있습니다.</p>
3757 <input type="checkbox" name="lookupIPonDiscuss" data-setname="lookupIPonDiscuss" data-as-boolean>토론시 익명 기여자 IP 주소 조회</input><br>
3758 <input type="checkbox" name="checkWhoisNetTypeOnDiscuss" data-setname="checkWhoisNetTypeOnDiscuss" data-as-boolean>네트워크 유형도 함께 조회 (단 한국 IP만 가능)</input>
3759 </div>
3760 <h2>토론에서 보여지지 않은 쓰레도 불러오기</h2>
3761 <div class="settings-paragraph">
3762 <p>보여지지 않은 쓰레드도 불러오도록 나무위키 토론 스크립트를 수정합니다.</p>
3763 <input type="checkbox" name="loadUnvisibleReses" data-setname="loadUnvisibleReses" data-as-boolean>보여지지 않은 토론 쓰레도 불러오기</input>
3764 </div>
3765 <h2>토론 아이덴티콘 명도</h2>
3766 <div class="settings-paragraph">
3767 <p>스레딕 헬퍼 방식을 사용하는 경우에만 적용됩니다.</p>
3768 <label for="discussIdentiLightness">명도</label><input name="discussIdentiLightness" data-setname="discussIdentiLightness" type="range" max="1" min="0" step="0.01"><br>
3769 <label for="discussIdentiSaturation">순도</label><input name="discussIdentiSaturation" data-setname="discussIdentiSaturation" type="range" max="1" min="0" step="0.01">
3770 </div>
3771 <h2>토론시 앵커 미리보기</h2>
3772 <div class="settings-paragraph">
3773 <input type="radio" name="discussAnchorPreviewType" data-setname="discussAnchorPreviewType" data-setvalue="0">사용하지 않음<br>
3774 <input type="radio" name="discussAnchorPreviewType" data-setname="discussAnchorPreviewType" data-setvalue="1">마우스를 올리면 미리보기 표시<br>
3775 <input type="radio" name="discussAnchorPreviewType" data-setname="discussAnchorPreviewType" data-setvalue="2">토론 메세지 위에 인용형식으로 표시<br>
3776 <input type="checkbox" name="removeNFQuotesInAnchorPreview" data-setname="removeNFQuotesInAnchorPreview" data-as-boolean>토론 메세지 위에 인용형식으로 표시할때, 인용문 안에 인용 형식으로 표시된 미리보기 제거
3777 </div>
3778 <h2>보지 않는 토론 알림</h2>
3779 <div class="settings-paragraph">
3780 <p>보지 않는 토론(예 : 탭 여러개를 여는 경우)에 새로운 발언 혹은 블라인드가 생길시 브라우저 API를 이용해 알림을 띄웁니다.<br>
3781 참고 : 토론에서 보여지지 않은 쓰레도 불러오기 기능이 활성화된 경우 보이지 않은 쓰레들을 불려오는 동안은 알림이 뜨지 않습니다.<br>
3782 경고 : 현재 실험중인 기능입니다.</p>
3783 <input type="checkbox" data-setname="notifyForUnvisibleThreads" data-as-boolean>보지 않는 토론 알림</input>
3784 </div>
3785 <h2>마우스를 올리면 해당 사용자의 다른 쓰레 강조</h2>
3786 <div class="settings-paragraph">
3787 <p>토론에서 쓰레에 마우스를 올리면 그 사용자의 다른 쓰레를 강조합니다.</p>
3788 <input type="checkbox" data-setname="emphasizeResesWhenMouseover" data-as-boolean>마우스를 올리면 해당 사용자의 다른 쓰레 강조</input>
3789 </div>
3790 </div>
3791 <h1>관리 편의성</h1>
3792 <div class="settings-paragraph">
3793 <h2>편의기능</h2>
3794 <div class="settings-paragraph">
3795 <input type="checkbox" name="addAdminLinksForLiberty" data-setname="addAdminLinksForLiberty" data-as-boolean>Liberty 스킨에 관리자 링크 추가하기</input><br>
3796 <input type="checkbox" name="addBatchBlockMenu" data-setname="addBatchBlockMenu" data-as-boolean>일괄 차단 메뉴 추가</input><br>
3797 <input type="checkbox" name="addQuickBlockLink" data-setname="addQuickBlockLink" data-as-boolean>빠른 차단 링크 추가</input><br>
3798 토론중 빠른차단 기능에서의 차단사유 템플릿 : <input type="text" data-setname="quickBlockReasonTemplate_discuss" style="width: 500px; max-width: 75vw;"></input><br>
3799 역사페이지 빠른차단 기능에서의 차단사유 템플릿 : <input type="text" data-setname="quickBlockReasonTemplate_history" style="width: 500px; max-width: 75vw;"></input><br>
3800 <strong>참고:</strong> 문서명(\${docName})은 URL 인코딩이 되지 않고 리버전 번호(\${revisionNo})는 r로 시작하기 때문에 주소형태의 차단사유 템플릿을 쓰는 것을 권장하지 않습니다.)<br>
3801 빠른차단 기능에서의 차단기간 기본값(초) : <input type="text" data-setname="quickBlockDefaultDuration"></input>
3802 </div>
3803 </div>
3804 <h1>편집 편의성</h1>
3805 <div class="settings-paragraph">
3806 <h2>자동저장 시간 간격</h2>
3807 <div class="settings-paragraph">
3808 <p>편집중 자동저장 간격을 설정합니다. 0 이하의 값으로 설정할 시 자동으로 이루어지지 않으며 이 경우 단축키나 메뉴를 이용해 수동으로 저장해야 합니다.</p>
3809 <input type="number" name="autoTempsaveSpan" data-setname="autoTempsaveSpan"></input>ms (1000ms = 1s)
3810 </div>
3811 <h2>이미지 업로드시 파일이름 유지</h2>
3812 <div class="settings-paragraph">
3813 <p>파일이름에 난수를 덧붙이지 않고 그대로 유지합니다. 파일이름이 중복될시 오류가 발생할 수 있습니다.</p>
3814 <input type="checkbox" data-setname="unprefixedFilename" data-as-boolean>파일이름 그대로 유지</input>
3815 </div>
3816 </div>
3817 <h1>게시판</h1>
3818 <div class="settings-paragraph">
3819 <h2>게시판 시간대 변경</h2>
3820 <div class="settings-paragraph">
3821 <input type="checkbox" name="noLocaltimeOnNamuBoard" data-setname="noLocaltimeOnNamuBoard" data-as-boolean>게시판 시간대를 사용자의 시간대로 자동 변경합니다.</input>
3822 </div>
3823 <h2>댓글 사용구</h2>
3824 <div class="settings-paragraph">
3825 <p>상용구와 상용구는 ,로 구분하며 상용구 이름과 상용구 내용은 :로 구분합니다. (예시: <em>처리중:처리중입니다,기각:기각합니다</em>)</p>
3826 <input type="text" data-setname="commentMacros"></input>
3827 </div>
3828 </div>
3829 <h1>기타</h1>
3830 <div class="settings-paragraph">
3831 <h2>SNS 공유 버튼</h2>
3832 <div class="settings-paragraph">
3833 <input type="checkbox" name="addSnsShareButton" data-setname="addSnsShareButton" data-as-boolean>문서에 트위터/페이스북 공유 버튼을 추가합니다.</input>
3834 </div>
3835 </div>`
3836 var optionTags = document.querySelectorAll('[data-setname]');
3837 await SET.load();
3838 for (var i = 0; i < optionTags.length; i++) {
3839 var tag = optionTags[i];
3840 var t = tag.getAttribute('type');
3841 var sn = tag.dataset.setname;
3842 if (t == "radio" && tag.dataset.setvalue == SET[sn]) {
3843 tag.checked = true;
3844 } else if ((t == "checkbox" && tag.dataset.setvalueOnChecked == SET[sn]) || (t == "checkbox" && tag.hasAttribute("data-as-boolean") && SET[sn])) {
3845 tag.checked = true;
3846 } else if (["text", "password", "number", "range"].indexOf(t) != -1) {
3847 tag.value = ["number", "range"].indexOf(t) != -1 ? Number(SET[sn]) : SET[sn];
3848 }
3849 }
3850 });
3851 win.button('저장하지 않고 닫기', win.close);
3852 win.button('저장하고 닫기', async function () {
3853 var optionTags = document.querySelectorAll('[data-setname]');
3854 await SET.load();
3855 for (var i = 0; i < optionTags.length; i++) {
3856 var tag = optionTags[i];
3857 var t = tag.getAttribute('type');
3858 var sn = tag.dataset.setname;
3859 if (t == "radio" && tag.checked) {
3860 SET[sn] = tag.dataset.setvalue;
3861 } else if (t == "checkbox") {
3862 SET[sn] = tag.hasAttribute("data-as-boolean") ? tag.checked : tag.checked ? t.dataset.setvalueOnChecked : t.dataset.setvalueOnUnchecked;
3863 } else if (["text", "password", "number", "range"].indexOf(t) != -1) {
3864 SET[sn] = tag.value;
3865 }
3866 }
3867 await SET.save();
3868 if (confirm("새로고침해야 설정이 적용됩니다. 새로고침할까요?"))
3869 location.reload();
3870 win.close();
3871 });
3872 win.button('설정 백업', async function () {
3873 let excludeTempsaves = confirm('임시조치를 제외할까요?');
3874 let excludes = [];
3875 if (excludeTempsaves) excludes.push('tempsaves');
3876 let backupText = [JSON.stringify(await SET.export(excludes))];
3877 let win = TooSimplePopup();
3878 win.title('백업중...');
3879 win.content(el => el.innerHTML = "백업중...");
3880 let blob = new Blob(backupText, {
3881 type: "application/json"
3882 });
3883 let url = URL.createObjectURL(blob);
3884 win.content(el => el.innerHTML = `<a href="${url}" download="namufix_backup.json">이 링크</a>을 다른 이름으로 저장해주세요.`)
3885 win.button('닫기', win.close);
3886 });
3887 win.button('설정 복원', async function () {
3888 getFile(async(files) => {
3889 if (files.length === 0)
3890 return alert('아무것도 선택하지 않았습니다!');
3891 try {
3892 let fileReader = new FileReader();
3893 fileReader.onload = async(evt) => {
3894 try {
3895 let data = JSON.parse(evt.target.result);
3896 await SET.import(data);
3897 alert('완료했습니다. 확인 버튼을 누르면 새로고침됩니다.');
3898 location.reload();
3899 } catch (err) {
3900 return alert('파일을 읽는 중 오류가 발생했습니다: ' + err.message);
3901 console.error(err);
3902 }
3903 }
3904 fileReader.readAsText(files[0]);
3905 } catch (err) {
3906 return alert('파일을 읽는 중 오류가 발생했습니다: ' + err.message);
3907 console.error(err);
3908 }
3909 }, false)
3910 });
3911 win.button('설정 초기화', async function () {
3912 if (confirm('되돌릴 수 없습니다. 계속 진행하시겠습니까?')) {
3913 await SET.load();
3914 for (let i in SET) {
3915 if (i == "save" || i == "load" || i == "delete")
3916 continue;
3917 await SET.delete(i);
3918 }
3919 if (confirm('초기화에 성공했습니다. 확인 버튼을 누르면 새로고침합니다.')) {
3920 location.reload();
3921 }
3922 win.close();
3923 }
3924 });
3925 });
3926 addItemToMemberMenu('NamuFix 이슈트래커', function (evt) {
3927 evt.preventDefault();
r1795
3928
r2070
3929 GM.openInTab("https://github.com/LiteHell/NamuFix/issues");
3930 });
3931 addItemToMemberMenu('KISA WHOIS', function (evt) {
3932 evt.preventDefault();
r1795
3933
r2070
3934 whoisPopup(prompt('조회할 IP주소를 입력하세요.'));
3935 })
r1795
3936
r2070
3937 if (SET.addBatchBlockMenu) {
3938 addItemToMemberMenu('계정/IP 일괄 차단', function (evt) {
3939 evt.preventDefault();
3940 var win = TooSimplePopup();
3941 win.title('계정/IP 일괄 차단');
3942 win.content(function (con) {
3943 var expire = 0;
3944 con.innerHTML = '<p>아래에 차단할 IP주소/계정들을 입력해주세요.' +
3945 '차단 사유 : <input type="text" id="note"></input><br>' +
3946 '차단 기간 : <span id="expire_display">영구</span> <a href="#" id="setExpire">(차단기간 설정)</a>(참고 : 0초 = 영구차단)<br>' +
3947 '로그인 허용 : <input type="checkbox" id="allowLogin"></input><br>' +
3948 '※ IP 차단 해제시에는 차단사유/영구차단 여부/로그인 허용 여부를 설정할 필요 없으며 IP는 IPv4만 인식합니다.</p>' +
3949 '차단할 계정/IP (개행으로 구분) : <br>' +
3950 '<textarea style="width: 800px; max-width: 80vw; height: 500px; max-height: 80vh;"></textarea>';
3951 con.querySelector('a#setExpire').addEventListener('click', function (evt) {
3952 evt.preventDefault();
3953 enterTimespanPopup('차단기간 설정', function (span) {
3954 if (span === null) {
3955 return alert('입력이 없습니다.');
3956 } else {
3957 expire = span;
3958 con.querySelector('#expire_display').textContent = span == 0 ? '영구' : expire + '초 ';
3959 }
3960 });
3961 })
3962 win.button('취소', win.close);
3963 win.button('VPNGATE IP 불러오기', async function () {
3964 let win = TooSimplePopup();
3965 win.title('불러오는 중');
3966 win.content(e => e.innerHTML = "불러오는 중입니다. 잠시만 기다려주십시오.");
3967 let vpngateIPs = await getVPNGateIPList();
3968 let textarea = con.querySelector('textarea');
3969 textarea.value += '\n' + vpngateIPs.map(v => v + "/32").join("\n");
3970 win.close();
3971 })
3972 win.button('차단기록 검색', async function () {
3973 let searchWin = TooSimplePopup();
3974 let queryInfo;
3975 searchWin.title('차단기록 검색');
3976 searchWin.content(searchWinCon => {
3977 searchWinCon.innerHTML = `
3978 <style>.search-prev[disabled], .search-next[disabled] {background: darkgray; text-decoration: line-through;}</style>
3979 <div class="table-responsive-sm">
3980 <table class="table table-striped table-bordered table-hover table-sm">
3981 <thead>
3982 <tr>
3983 <td>선택</td>
3984 <td>유형</td>
3985 <td>실행자</td>
3986 <td>피실행자</td>
3987 <td>사유</td>
3988 <td>기간</td>
3989 <td>차단일시</td>
3990 </tr>
3991 </thead>
3992 <tbody>
3993 </tbody>
3994 </table>
3995 </div>
3996 <div class="search-pagination">
3997 </div>
3998 `;
r1795
3999
r2070
4000 function processQuery() {
4001 let waitingWin = TooSimplePopup();
4002 waitingWin.title("진행중입니다");
4003 waitingWin.content(waitingWinCon => waitingWinCon.innerHTML = "진행중입니다. 잠시만 기다려주십시오.");
4004 namuapi.searchBlockHistory(queryInfo, (result) => {
4005 queryInfo.from = result.nextResultPageFrom || null;
4006 queryInfo.until = result.prevResultPageUntil || null;
4007 let tbody = searchWinCon.querySelector('tbody');
4008 tbody.innerHTML = "";
4009 for (let i of result) {
4010 // checkbox, type, blocker, blocked, reason, duration, at
4011 tbody.innerHTML += `<tr data-blocked="${encodeHTMLComponent(JSON.stringify(i.blocked))}"><td><input type="checkbox" checked></td><td>${i.type}</td><td>${encodeHTMLComponent(i.blocker)}</td><td>${encodeHTMLComponent(i.blocked)}</td><td>${encodeHTMLComponent(i.reason)}</td><td>${i.duration}</td><td>${formatDateTime(i.at)}</td></tr>`;
4012 }
4013 waitingWin.close();
4014 });
4015 }
4016 searchWin.button('검색', () => {
4017 let queryWin = TooSimplePopup();
4018 queryWin.title('쿼리 입력');
4019 queryWin.content(queryWinCon => {
4020 queryWinCon.innerHTML = `
4021 <div>
4022 쿼리 :
4023 <input type="text" class="search-query" style="width: 500px; max-width: 80vw;"></input>
4024 </div>`;
4025 queryWin.button('실행자 검색', () => {
4026 queryInfo = {
4027 query: queryWinCon.querySelector('.search-query').value,
4028 isAuthor: true
4029 };
4030 processQuery();
4031 });
4032 queryWin.button('내용 검색', () => {
4033 queryInfo = {
4034 query: queryWinCon.querySelector('.search-query').value,
4035 isAuthor: false
4036 };
4037 processQuery();
4038 });
4039 queryWin.button('닫기', queryWin.close);
4040 });
4041 });
4042 searchWin.button('선택된 항목 추가', () => {
4043 let isFirst = true;
4044 let textarea = con.querySelector('textarea');
4045 let waitingWin = TooSimplePopup();
4046 waitingWin.title('진행중입니다.');
4047 waitingWin.content(c => c.innerHTML = "진행중입니다.");
4048 for (let i of searchWinCon.querySelectorAll('tbody tr')) {
4049 if (isFirst) {
4050 textarea.value += '\n';
4051 isFirst = false;
4052 }
4053 if (i.querySelector('input[type="checkbox"]').checked)
4054 textarea.value += JSON.parse(i.dataset.blocked) + "\n";
4055 }
4056 waitingWin.close();
4057 });
4058 searchWin.button('이전 결과', () => {
4059 if (queryInfo.until) {
4060 delete queryInfo.from;
4061 processQuery();
4062 } else {
4063 alert('첫 페이지입니다.');
4064 }
4065 });
4066 searchWin.button('다음 결과', () => {
4067 if (queryInfo.from) {
4068 delete queryInfo.until;
4069 processQuery();
4070 } else {
4071 alert('마지막 페이지입니다.');
4072 }
4073 });
4074 searchWin.button('닫기', searchWin.close);
4075 });
4076 })
r1795
4077
r2070
4078 function parseTextarea() {
4079 let commonData = {
4080 note: con.querySelector('input#note').value,
4081 expire: expire,
4082 allowLogin: con.querySelector('input#allowLogin').checked
4083 }
4084 return con.querySelector('textarea').value
4085 .split('\n')
4086 .map(v => v.trim())
4087 .filter(v => v != "")
4088 .map((v) => {
4089 let ipWithCIDR = /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))?$/;
4090 let data = JSON.parse(JSON.stringify(commonData));
4091 if (ipWithCIDR.test(v))
4092 data.ip = v;
4093 else
4094 data.id = v;
4095 if (data.ip && !data.ip.includes("/")) data.ip += "/32";
4096 return data;
4097 });
4098 }
r1795
4099
r2070
4100 function commonLoop(_datas, progressCallback) { // returns error object
4101 return new Promise((resolve, reject) => {
4102 let result = {
4103 errors: [],
4104 success: []
4105 };
4106 let datas = JSON.parse(JSON.stringify(_datas));
4107 async.eachLimit(datas, SET.adminReqLimit, (data, callback) => {
4108 namuapi[data.handlerName](data.parameter, (err, target) => {
4109 if (err) {
4110 result.errors.push({
4111 target: data.parameter,
4112 error: err
4113 });
4114 } else {
4115 result.success.push(data.parameter);
4116 }
4117 if (progressCallback)
4118 progressCallback(data);
4119 callback();
4120 });
4121 }, (err) => {
4122 return resolve(result);
4123 });
4124 });
4125 }
4126 win.button('차단', async function () {
4127 var waitingWin = TooSimplePopup();
4128 var errors = [];
4129 waitingWin.title('처리중');
4130 waitingWin.content(function (wwcon) {
4131 wwcon.innerHTML = "처리중입니다."
4132 });
4133 let datas = parseTextarea().map(v => ({
4134 parameter: v,
4135 handlerName: v.ip ? "blockIP" : "blockAccount"
4136 }));
4137 let result = await commonLoop(datas, d => waitingWin.content(wwcon => wwcon.innerHTML = `처리 완료: ${d.parameter.ip || d.parameter.id}`));
4138 if (result.errors.length > 0) {
4139 waitingWin.content(wwcon => {
4140 wwcon.innerHTML = "오류가 있습니다.<br><br>" + result.errors.map(v => `${encodeHTMLComponent(v.target.ip || v.target.id)} : ${v.error}`).join("<br>");
4141 });
4142 waitingWin.button('닫기', waitingWin.close);
4143 } else {
4144 waitingWin.close();
4145 }
4146 });
4147 win.button('차단 해제', async function () {
4148 var waitingWin = TooSimplePopup();
4149 var errors = [];
4150 waitingWin.title('처리중');
4151 waitingWin.content(function (wwcon) {
4152 wwcon.innerHTML = "처리중입니다."
4153 });
4154 let datas = parseTextarea().map(v => {
4155 if (v.ip) {
4156 return {
4157 parameter: v.ip,
4158 handlerName: 'unblockIP'
4159 };
4160 } else {
4161 let tmp = {
4162 parameter: v,
4163 handlerName: 'blockAccount'
4164 };
4165 tmp.parameter.expire = -1;
4166 return tmp;
4167 }
4168 });
4169 let result = await commonLoop(datas, d => waitingWin.content(wwcon => wwcon.innerHTML = `처리 완료: ${d.parameter.id ? d.parameter.id : d.parameter}`));
4170 if (result.errors.length > 0) {
4171 waitingWin.content(wwcon => {
4172 wwcon.innerHTML = "오류가 있습니다.<br><br>" + result.errors.map(v => `${encodeHTMLComponent(v.target.id || v.target)} : ${v.error}`).join("<br>");
4173 });
4174 waitingWin.button('닫기', waitingWin.close);
4175 } else {
4176 waitingWin.close();
4177 }
4178 });
4179 });
4180 })
4181 }
r1795
4182
r2070
4183 listenPJAX(mainFunc);
4184 await mainFunc();
r1795
4185
r2070
4186 if (document.querySelector('body').getAttribute('class').indexOf('senkawa') == -1 && document.querySelector('body').getAttribute('class').indexOf('Liberty') == -1) {
4187 await SET.load();
4188 if (!SET.ignoreNonSenkawaWarning) {
4189 var win = TooSimplePopup();
4190 win.title('스킨 관련 안내');
4191 win.content(function (element) {
4192 element.innerHTML = '<p><strong>안내:</strong> NamuFix는 senkawa/liberty 스킨이 아닌 경우 비정상적으로 작동할 수 있습니다.<br>가능하면 senkawa/liberty 스킨을 사용해주십시오.<br><em>(이 메세지는 한번만 보여집니다.)</em></p>'
4193 });
4194 win.button('확인', win.close);
4195 SET.ignoreNonSenkawaWarning = true;
4196 await SET.save();
4197 }
4198 }
4199 if (GM.info.scriptHandler === "Greasemonkey" && GM.info.version.startsWith("4.") && !SET.ignoreGM4Warning) {
4200 var win = TooSimplePopup();
4201 win.title('Greasemonkey 4와의 호환성 안내');
4202 win.content((container) => container.innerHTML = "<p>Greasemonkey 4+ 버전에서는 NamuFix가 비정상적으로 작동할 가능성이 <strong>매우</strong> 높습니다. 버그를 작동하면 즉시 이슈트래커에 신고해주세요.</p>");
4203 win.button('닫기', win.close);
4204 SET.ignoreGM4Warning = true;
4205 await SET.save();
4206 }
4207 })();
4208} catch (err) {
4209 console.error("[NamuFix] 오류 발생!");
4210 console.error(err);
4211}
4212