Vietphrase converter

The userscript converts chinese novel webpage to Vietphrase format to read on web browser

  1. // ==UserScript==
  2. // @name Vietphrase converter
  3. // @name:vi convert kiểu Vietphrase
  4. // @namespace VP
  5. // @version 1.0.2
  6. // @description The userscript converts chinese novel webpage to Vietphrase format to read on web browser
  7. // @description:vi convert kiểu Vietphrase để đọc truyện trực tiếp trên web
  8. // @author you
  9. // @match http*://*/*
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @run-at document-idle
  13. // ==/UserScript==
  14.  
  15. let Options = GM_getValue('Options', {
  16. Ngoac: false,
  17. Motnghia: true,
  18. daucach: ';',
  19. DichLieu: true,
  20. useSP: false,
  21. font: 'Roboto',
  22. whiteList: [] //[{host:string,leftRight:boolean, noButton:boolean},{}...]
  23. });
  24.  
  25. let dictNames = GM_getValue('dictNames', undefined);
  26. let dictVP = GM_getValue('dictVP', undefined);
  27. let dictPA = GM_getValue('dictPA', undefined);
  28. let dictSP = GM_getValue('dictSP', undefined);
  29.  
  30. let tmpDictPA;
  31. let tmpDictVP;
  32. let tmpDictNames;
  33. let tmpDictSP;
  34.  
  35. function findNonInline(el) {
  36. for (let i = el; i != null; i = i.parentElement) {
  37. if (i.tagName == 'IMG' || i.tagName == 'VIDEO') return false;
  38. el = i;
  39. if (window.getComputedStyle(i)['display'] != 'inline') break;
  40. }
  41. return el;
  42. }
  43.  
  44. //https://github.com/lilydjwg/text-reflow-we
  45. function reFlow(e) {
  46. const sideMargin = 10
  47. const winWidth = window.visualViewport.width
  48. let target = findNonInline(e.target);
  49. if (!target) return;
  50. const bbox = target.getBoundingClientRect()
  51.  
  52. // if box is wider than screen, reset width to make it fit
  53. if (bbox.width > winWidth) {
  54. const newWidth = winWidth - (2 * sideMargin)
  55. target.style.width = newWidth + 'px'
  56. target.__reflowed = true
  57. } else if (target.__reflowed) { // don't remove width set by the page itself
  58. target.style.width = ''
  59. target.__reflowed = false
  60. }
  61. }
  62.  
  63. function isOverflow(el) { return el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth; }
  64. function reflow(el) {
  65. const smallestSize = 12;
  66. let count = 1;
  67. let computedStyle;
  68. do {
  69. count++;
  70. computedStyle = getComputedStyle(el)
  71. fontSize = parseInt(computedStyle.fontSize.slice(0, -2));
  72. fontSize = fontSize * .95;
  73. el.style.fontSize = fontSize + 'px';
  74. } while (isOverflow(el) && fontSize > smallestSize && count < 10)
  75. }
  76.  
  77. function sortSP(a, b) {
  78. let cmp = { 'V': 2, 'N': 3 }
  79. let aM = a.match(/{\d}|{N\d?}|{V\d?}/g);
  80. let bM = b.match(/{\d}|{N\d?}|{V\d?}/g);
  81. if (aM.length > bM.length) return -1;
  82. if (aM.length < bM.length) return 1;
  83. let aS = aM.reduce((s, e) => s += cmp[e.charAt(0)] ?? 0, 0);
  84. let bS = bM.reduce((s, e) => s += cmp[e.charAt(0)] ?? 0, 0);
  85. if (aS > bS) return -1;
  86. if (aS < bS) return 1;
  87. return b.length - a.length || a.localeCompare(b);
  88. }
  89.  
  90. function str2Dict(str) {
  91. let dict = {};
  92. str.trim().split(/\r\n|\r|\n/).forEach(line => {
  93. if (/^(\/\/|#|=)/.test(line)) return; //ghi chu
  94. let [org, trans] = line.split('=');
  95. if (!org || !trans) return;
  96. dict[org] = trans.trim();
  97. })
  98. return dict
  99. }
  100.  
  101. function transPA(str) {
  102. return str.split('').reduce((s, c) => s += dictPA.trans[c] ? (' ' + dictPA.trans[c]) : c, '');
  103. }
  104.  
  105. function transVP(str, Ngoac = true, Motnghia = false, daucach = ';', DichLieu = false) {
  106. const _magic = ''; //'\uf0f3'
  107. if (dictNames) dictNames.org?.forEach(el => str.replaceAll(el, ' ' + dictNames.trans[el]));
  108. if (!dictVP || !dictPA) return str;
  109. let result = '';
  110. const maxLength = dictVP.org[0]?.length;
  111. const dichlieu = ['的', '了', '着'];
  112. for (let i = 0; i < str.length; i++) {
  113. for (let j = maxLength; j > 0; j--) {
  114. let subStr = str.slice(i, i + j);
  115. let VP = dictVP.trans[subStr];
  116. if (typeof VP === 'string' && VP.length > 0) {
  117. if (Motnghia) VP = VP.split(daucach)[0];
  118. if (Ngoac) VP = `[${VP.trim()}]`;//Sometimes VP.trim() got error because no trim property/function in VP???
  119. result += ' ' + VP;
  120. str.replace(subStr, _magic.repeat(subStr.length));
  121. i += j;
  122. }
  123. if (j == 1) {
  124. if (DichLieu && dichlieu.includes(str.charAt(i))) continue;
  125. result += dictPA.trans[str.charAt(i)] ? (' ' + dictPA.trans[str.charAt(i)]) : str.charAt(i);
  126. str.replace(str.charAt(i), _magic);
  127. }
  128. }
  129. }
  130. return result.replaceAll(/[ ]+/g, ' ');
  131. }
  132.  
  133. function transSP1(str) {
  134. const regNumber = /{\d}/g
  135. if (dictSP.org == undefined) return false;
  136. dictSP.org.forEach(sp => {
  137. let aC = sp.match(regNumber);
  138. let vC = new RegExp(sp.replaceAll(regNumber, '[\\p{sc=Han}、,,0-9]+'), 'ug');
  139. let vV = dictSP.trans[sp];
  140. aC.forEach(ac => vV = vV.replace(ac, `$${aC.indexOf(ac) + 1}`));
  141. str.replaceAll(vC, `<${vV}>`);
  142. })
  143. }
  144. const transSP = transSP1;
  145.  
  146. function translateNode(rootNode) {
  147. let nodeArr = [];
  148. let nodesText = '';
  149. const limiter = ''.repeat(2); //'\uf0f5'
  150.  
  151. function nodeToArr(node) {
  152. if (node.nodeType == 3) {
  153. nodeArr.push(node);
  154. nodesText += node.textContent + limiter;
  155. }
  156. node.childNodes.forEach(childNode => nodeToArr(childNode))
  157. }
  158.  
  159. nodeToArr(rootNode);
  160. let translated = transVP(nodesText, Options.Ngoac, Options.Motnghia, Options.daucach, Options.DichLieu);
  161. if (translated.length > 25000) {
  162. translated.split(limiter).forEach((text, index) => {
  163. if (nodeArr[index] == undefined) return;
  164. nodeArr[index].textContent = text;
  165. });
  166. if (Options.font)
  167. document.body.setAttribute('style', `font-family: ${Options.font} !important;`);
  168. }
  169. else {
  170. translated.split(limiter).forEach((text, index) => {
  171. if (nodeArr[index] == undefined) return;
  172. nodeArr[index].textContent = text;
  173. if (Options.font) nodeArr[index].parentElement ? nodeArr[index].parentElement.style = `font-family: ${Options.font} !important` : '';
  174.  
  175. let el = findNonInline(nodeArr[index].parentElement);
  176. if (!el.hasAttribute('reflow') && isOverflow(el)) {
  177. reflow(el);
  178. el.style.overflow = 'hidden';
  179. el.setAttribute('reflow', "");
  180. for (c of el.children) {
  181. let fS = parseInt(getComputedStyle(c).fontSize.slice(0, -2));
  182. if (fS < 12) c.style.fontSize = '12px';
  183. }
  184. }
  185. });
  186. }
  187. }
  188.  
  189. async function fileLoad(event) {
  190. let txt = '';
  191. let tmp;
  192. if (event.target.files[0]) txt = await event.target.files[0].text(); else return false;
  193. switch (event.target.id) {
  194. case 'fPA':
  195. tmpDictPA = {};
  196. tmpDictPA.trans = str2Dict(txt); break;
  197.  
  198. case 'fVP':
  199. tmpDictVP = {};
  200. tmpDictVP.trans = str2Dict(txt);
  201. tmpDictVP.org = [];
  202. tmpDictVP.org[0] = Object.keys(tmpDictVP.trans).toSorted((a, b) => b.length - a.length || a.localeCompare(b))[0] ?? '';
  203. break;
  204.  
  205. case 'fNames':
  206. tmpDictNames = {};
  207. tmpDictNames.trans = str2Dict(txt);
  208. tmpDictNames.org = Object.keys(tmpDictNames.trans).toSorted((a, b) => b.length - a.length || a.localeCompare(b));
  209. break;
  210.  
  211. case 'fSP':
  212. tmpDictSP = {};
  213. tmpDictSP.trans = str2Dict(txt);
  214. tmpDictSP.org = Object.keys(tmpDictSP.trans).toSorted(sortSP);
  215. break;
  216. }
  217. }
  218.  
  219. function addNewSite(ev) {
  220. const me = ev.target;
  221. const match = me.matches('#usDialog li:last-of-type>input[type="text"]');
  222. if (!me.value && match)
  223. return;
  224. if (!me.value && !match)
  225. me.parentElement.remove();
  226. if (me.value && match)
  227. document.querySelector('#usDialog ul').insertAdjacentHTML('beforeend', `
  228. <li><input type="text"> Trái <label><input type="checkbox" checked></label> Phải | <input type="checkbox"> Xóa nút</li>`);
  229. document.querySelector('#usDialog li:last-of-type>input[type="text"]').addEventListener("change", addNewSite);
  230. }
  231.  
  232. (async function () {
  233. 'use strict';
  234. if (window.self != window.top) return;
  235. if (Options.blackList?.split(/[,;]/).some(e => e.trim() && window.location.host.includes(e.trim()))) return;
  236. document.addEventListener('click', reFlow);
  237. const auto = Options.whiteList.filter(e => window.location.host.includes(e.host));
  238. if (auto.length > 0) {
  239. if (auto[0].leftRight) rightFunc(); else leftFunc();
  240. if (auto[0].noButton) {
  241. if (auto[0].leftRight) {
  242. const observer = new MutationObserver((mL) => {
  243. for (const mo of mL)
  244. for (const addNode of mo.addedNodes) translateNode(addNode);
  245. });
  246. observer.observe(document.body, { childList: true, subtree: true });
  247. }
  248. return;
  249. }
  250. }
  251.  
  252. document.body.insertAdjacentHTML('beforeend', `
  253. <style>
  254. div.usButton {
  255. display: flex;
  256. position: fixed;
  257. top: 1%;
  258. right: 1%;
  259. margin: 0;
  260. padding: 0;
  261. border: thin;
  262. z-index: 9999;
  263. }
  264. div.usButton>button {
  265. height: 90%;
  266. border: none;
  267. margin: 0;
  268. text-align: right;
  269. }
  270. div.usButton>button:first-child {
  271. padding: 5px 0px 5px 5px;
  272. }
  273. div.usButton>button:last-child {
  274. padding: 5px 2px 5px 0px;
  275. }
  276. div.usButton>button:nth-child(2) {
  277. padding: 5px 2px 5px 0px;
  278. }
  279. dialog#usDialog {
  280. border: none;
  281. border-radius: .3rem;
  282. font-family: Arial;
  283. padding: .3rem;
  284. margin: auto;
  285. min-width: 20rem;
  286. width: fit-content;
  287. }
  288. #usDialog>div {
  289. display: flex;
  290. justify-content: space-around;
  291. }
  292. #usDialog label:has(#cbMotnghia)+label {
  293. display: none;
  294. }
  295. #usDialog label:has(#cbMotnghia:checked)+label {
  296. display: unset;
  297. }
  298. #usDialog nav>label {
  299. width: 4.5rem;
  300. display: inline-block;
  301. border-radius: 3px 3px 0px 0px;
  302. border: 1px solid black;
  303. border-bottom: none;
  304. text-align: center;
  305. }
  306. #usDialog nav>label:has(input[type="radio"]:checked) {
  307. font-weight: 700;
  308. }
  309. #usDialog input[type="radio"] {
  310. width: 0px;
  311. height: 0px;
  312. display: none;
  313. }
  314. #usDialog fieldset {
  315. display: none;
  316. min-height: 14rem;
  317. text-align: left;
  318. min-width:fit-content;
  319. }
  320. #usDialog input[type="text"] {
  321. border: 1px solid black;
  322. padding:0;
  323. }
  324. #usDialog textarea {
  325. border: 1px solid black;
  326. }
  327. #usDialog nav:has(#rdTudien:checked)~fieldset:nth-child(2) {
  328. display: block;
  329. }
  330. #usDialog nav:has(#rdDich:checked)~fieldset:nth-child(3) {
  331. display: block;
  332. }
  333. #usDialog nav:has(#rdTudong:checked)~fieldset:nth-child(4) {
  334. display: grid;
  335. }
  336. #usDialog ul {
  337. box-sizing: border-box;
  338. padding: 0;
  339. margin: 0;
  340. max-height: 6rem;
  341. overflow-y: scroll;
  342. }
  343. #usDialog li {
  344. display: unset;
  345. padding: 0;
  346. margin: 0;
  347. display:block;
  348. }
  349. #usDialog li>label {
  350. display: inline-block;
  351. position: relative;
  352. border-radius: 1em;
  353. width: 2em;
  354. height: 1em;
  355. background-color: pink;
  356. }
  357. #usDialog li>label:has(input[type="checkbox"])::before {
  358. content: '';
  359. display: unset;
  360. position: absolute;
  361. left: .15em;
  362. top: .1em;
  363. border-radius: 50%;
  364. width: .8em;
  365. height: .8em;
  366. background-color: rgb(193, 6, 245);
  367. }
  368. #usDialog li>label:has(input[type="checkbox"]:checked)::before {
  369. display: none;
  370. }
  371. #usDialog li>label:has(input[type="checkbox"])::after {
  372. display: none;
  373. }
  374. #usDialog li>label:has(input[type="checkbox"]:checked)::after {
  375. content: '';
  376. display: unset;
  377. position: absolute;
  378. border-radius: 50%;
  379. right: .15em;
  380. top: .1em;
  381. width: .8em;
  382. height: .8em;
  383. background-color: green;
  384. }
  385. #usDialog li>label>input[type="checkbox"] {
  386. width: 0px;
  387. height: 0px;
  388. display: none;
  389. }
  390. #usDialog button{
  391. min-width:fit-content;
  392. width: 4rem;
  393. }
  394. </style>
  395. <div class="usButton">
  396. <button>Tran</button>
  397. <button>slate</button>
  398. <button>↓</button>
  399. </div>
  400. <dialog id="usDialog" spellcheck="false" lang="vie">
  401. <nav>
  402. <label id="tudien"><input type="radio" name="groupby" id="rdTudien" checked>T đin</label>
  403. <label id="cachdich"><input type="radio" name="groupby" id="rdDich">Dch</label>
  404. <label id="tudong"><input type="radio" name="groupby" id="rdTudong">T động</label>
  405. </nav>
  406. <fieldset>
  407. <label for="fPA">Phiên Âm&nbsp;&nbsp;&nbsp;<input type="file" id="fPA"></label><br />
  408. <label for="fVP">Vietphrase&nbsp;<input type="file" id="fVP"></label><br />
  409. <label for="fNames">Names&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<input type="file" id="fNames"></label><br />
  410. <label for="fSP">Strucphrase<input type="file" id="fSP"></label><br />
  411. </fieldset>
  412. <fieldset>
  413. <label for="cbNgoac"><input type="checkbox" id="cbNgoac"> Dùng [ngoc]</label><br />
  414. <label for="cbMotnghia"><input type="checkbox" id="cbMotnghia"> Mt nghĩa</label>
  415. <label for="txtdaucach">, du cách nghĩa<input type="text" id="txtdaucach" size="1" maxlength="1"></label><br />
  416. <label for="cbDichLieu"><input type="checkbox" id="cbDichLieu"> Xóa "đích, liễu, trứ"</label><br />
  417. <label for="cbSP"><input type="checkbox" id="cbSP"> Dùng Strucphrase</label><br />
  418. </fieldset>
  419. <fieldset>
  420. <label for="txtfont" style="width:100%">Font thay thế: </label><input type="text" id="txtfont">
  421. <label for="txtWL" style="width:100%">Các site t chy: </label>
  422. <ul>
  423. </ul>
  424. <label for="txtBL" style="width:100%">B qua các tên min cha các chui cách nhau bng , ;</label>
  425. <textarea id="txtBL">.vn;</textarea>
  426. </fieldset>
  427. <div>
  428. <button>OK</button>
  429. <button>Cancel</button>
  430. </div>
  431. </dialog>`);
  432.  
  433. const dialog = document.querySelector('dialog#usDialog');
  434.  
  435. function leftFunc() {
  436. document.title = transPA(document.title);
  437. document.body.innerHTML = transVP(document.body.innerHTML, Options.Ngoac, Options.Motnghia, Options.daucach, Options.DichLieu);
  438. if (Options.font)
  439. document.body.setAttribute('style', `font-family: ${Options.font} !important;`);
  440. }
  441.  
  442. function rightFunc() {
  443. document.title = transPA(document.title);
  444. translateNode(document.body);
  445. }
  446.  
  447. document.querySelector('.usButton button:first-child').onclick = leftFunc;
  448. document.querySelector('.usButton button:nth-child(2)').onclick = rightFunc;
  449.  
  450. document.querySelector('.usButton button:last-child').onclick = () => { // Menu ↓ button
  451. tmpDictPA = undefined;
  452. tmpDictVP = undefined;
  453. tmpDictNames = undefined;
  454. tmpDictSP = undefined;
  455. if (dialog.open) dialog.close();
  456.  
  457. Options = GM_getValue('Options', Options); //sync Options across tabs
  458. dialog.querySelectorAll('input[type="file"]').forEach(el => el.value = null);
  459.  
  460. dialog.querySelector('#cbNgoac').checked = Options.Ngoac;
  461. dialog.querySelector('#cbMotnghia').checked = Options.Motnghia;
  462. dialog.querySelector('#cbDichLieu').checked = Options.DichLieu;
  463. dialog.querySelector('#cbSP').checked = Options.useSP;
  464. dialog.querySelector('#txtdaucach').value = Options.daucach ?? ';';
  465. dialog.querySelector('#txtfont').value = Options.font ?? '';
  466.  
  467. const ul = dialog.querySelector('ul');
  468. ul.innerHTML = '';
  469. [...Array.isArray(Options.whiteList) ? Options.whiteList : []].forEach(el => {
  470. if (!el || !el.host) return;
  471. ul.insertAdjacentHTML('beforeend', `
  472. <li><input type="text" value="${el.host}" > Trái <label><input type="checkbox" ${el.leftRight ? 'checked' : ''}></label> Phải | <input type="checkbox" ${el.noButton ? 'checked' : ''}> Xóa nút</li>`);
  473. });
  474.  
  475. dialog.querySelector('ul').insertAdjacentHTML('beforeend', `
  476. <li><input type="text"> Trái <label><input type="checkbox" checked></label> Phải | <input type="checkbox"> Xóa nút</li>`);
  477. dialog.querySelectorAll('li input[type="text"]').forEach(el => el.addEventListener('change', addNewSite));
  478.  
  479. dialog.querySelector('#txtBL').value = Options.blackList ?? '';
  480. dialog.showModal();
  481. }
  482.  
  483. dialog.querySelectorAll('input[type="file"]').forEach(el => el.onchange = fileLoad);
  484.  
  485. dialog.querySelector('div>button').onclick = () => { //OK button
  486. if (tmpDictPA != undefined) {
  487. dictPA = tmpDictPA;
  488. GM_setValue('dictPA', dictPA)
  489. }
  490.  
  491. if (tmpDictVP != undefined) {
  492. dictVP = tmpDictVP;
  493. GM_setValue('dictVP', dictVP)
  494. }
  495.  
  496. if (tmpDictNames != undefined) {
  497. dictNames = tmpDictNames;
  498. GM_setValue('dictNames', dictNames)
  499. }
  500.  
  501. if (tmpDictSP != undefined) {
  502. dictSP = tmpDictSP;
  503. GM_setValue('dictSP', dictSP)
  504. }
  505.  
  506. Options.Ngoac = dialog.querySelector('#cbNgoac').checked;
  507. Options.Motnghia = dialog.querySelector('#cbMotnghia').checked;
  508. Options.DichLieu = dialog.querySelector('#cbDichLieu').checked;
  509. Options.useSP = dialog.querySelector('#cbSP').checked;
  510. Options.daucach = dialog.querySelector('#txtdaucach').value.charAt(0) ?? ';';
  511. Options.font = dialog.querySelector('#txtfont').value.trim().split(/[,;]/g)[0]//??'Roboto';
  512. Options.whiteList = [];
  513. dialog.querySelectorAll('li').forEach(li => {
  514. let host = li.querySelector('input[type="text"]').value;
  515. if (host) Options.whiteList.push({
  516. host: host,
  517. leftRight: li.querySelector('label>input[type="checkbox"]').checked,
  518. noButton: li.querySelector('label+input[type="checkbox"]').checked
  519. })
  520. });
  521.  
  522. Options.blackList = dialog.querySelector('#txtBL').value;
  523. GM_setValue('Options', Options);
  524. dialog.close();
  525. }
  526.  
  527. dialog.querySelector('div>button:last-child').onclick = () => dialog.close(); //Cancel button
  528. })();