// ==UserScript==
// @name ParaTranz diff
// @namespace https://paratranz.cn/users/44232
// @version 0.8.1
// @description ParaTranz enhanced
// @author ooo
// @match http*://paratranz.cn/*
// @icon https://paratranz.cn/favicon.png
// @require https://cdnjs.cloudflare.com/ajax/libs/medium-zoom/1.1.0/medium-zoom.min.js
// @license MIT
// ==/UserScript==
(async function() {
'use strict';
// #region 主要功能
// #region 自动跳过空白页 initSkip
function initSkip() {
if (document.querySelector('.string-list .empty-sign') &&
location.search.match(/(\?|&)page=\d+/g)) {
document.querySelector('.pagination .page-item a')?.click();
}
}
// #endregion
// #region 添加快捷键 addHotkeys
function addHotkeys() {
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.shiftKey && event.key === 'V') {
event.preventDefault();
mockInput(document.querySelector('.editor-core .original')?.textContent);
}
});
}
// #endregion
// #region 更多搜索高亮 markSearchParams
let markSearchParams = () => {};
function updMark() {
const params = new URLSearchParams(location.search);
const text = params.get('text');
const original = params.get('original');
const translation = params.get('translation');
const context = params.get('context');
if (text) {
markSearchParams = (isOriginUpd) => {
if (isOriginUpd) markNorm('.editor-core .original', text);
return markEditing(text);
}
} else if (original) {
markSearchParams = (isOriginUpd) => {
if (isOriginUpd) markNorm('.editor-core .original', original);
}
} else if (translation) {
markSearchParams = () => {
return markEditing(translation);
}
} else if (context) {
markSearchParams = () => {
markNorm('.context', context);
}
} else {
markSearchParams = () => {};
}
}
let dropLastMark = updMark();
function markNorm(selector, toMark) {
const container = document.querySelector(selector);
if (!container) return;
let toMarkPattern = toMark;
if (document.querySelector('.sidebar .custom-checkbox')?.__vue__.$data.localChecked) { // 忽略大小写
toMarkPattern = new RegExp(`(${toMark.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
}
const HTML = container.innerHTML;
const currentMark = `<mark class="PZS">${toMark}</mark>`;
if (HTML.includes(currentMark)) return;
container.innerHTML = HTML.replaceAll('<mark class="PZS">', '').replace(/(?<=>|^)([^<]*?)(?=<|$)/g, (match) => {
if (typeof toMarkPattern === 'string') {
return match.replaceAll(toMarkPattern, currentMark);
} else {
return match.replace(toMarkPattern, '<mark class="PZS">$1</mark>');
}
});
}
function markEditing(toMark) {
const textarea = document.querySelector('textarea.translation');
if (!textarea) return;
const lastOverlay = document.getElementById('PZSoverlay');
if (lastOverlay) return;
const overlay = document.createElement('div');
overlay.id = 'PZSoverlay';
overlay.className = textarea.className;
const textareaStyle = window.getComputedStyle(textarea);
for (let i = 0; i < textareaStyle.length; i++) {
const property = textareaStyle[i];
overlay.style[property] = textareaStyle.getPropertyValue(property);
}
overlay.style.position = 'absolute';
overlay.style.pointerEvents = 'none';
overlay.style.setProperty('background', 'transparent', 'important');
overlay.style['-webkit-text-fill-color'] = 'transparent';
overlay.style['overflow-y'] = 'hidden';
overlay.style.resize = 'none';
textarea.parentNode.appendChild(overlay);
const updOverlay = () => {
let toMarkPattern = toMark.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('\\n', '<br>');
if (document.querySelector('.sidebar .custom-checkbox')?.__vue__.$data.localChecked) { // 忽略大小写
toMarkPattern = new RegExp(`(${toMark.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
}
overlay.innerText = textarea.value;
if (typeof toMarkPattern === 'string') {
overlay.innerHTML = overlay.innerHTML.replaceAll(toMarkPattern, `<mark class="PZS" style="-webkit-text-fill-color:${
window.getComputedStyle(textarea).getPropertyValue('-webkit-text-fill-color')
};opacity:.5">${toMarkPattern}</mark>`);
} else {
overlay.innerHTML = overlay.innerHTML.replace(toMarkPattern, `<mark class="PZS" style="-webkit-text-fill-color:${
window.getComputedStyle(textarea).getPropertyValue('-webkit-text-fill-color')
};opacity:.5">$1</mark>`);
}
overlay.style.top = textarea.offsetTop + 'px';
overlay.style.left = textarea.offsetLeft + 'px';
overlay.style.width = textarea.offsetWidth + 'px';
overlay.style.height = textarea.offsetHeight + 'px';
};
updOverlay();
textarea.addEventListener('input', updOverlay);
const observer = new MutationObserver(updOverlay);
observer.observe(textarea, { attributes: true, childList: true, subtree: true });
window.addEventListener('resize', updOverlay);
const cancelOverlay = () => {
observer.disconnect();
textarea.removeEventListener('input', updOverlay);
window.removeEventListener('resize', updOverlay);
}
return cancelOverlay;
}
// #endregion
// #region 高亮上下文 markContext(originTxt)
function markContext(originTxt) {
const contextBox = document.querySelector('.context');
if (!contextBox) return;
const context = contextBox.innerHTML.replaceAll(/<a.*?>(.*?)<\/a>/g, '$1').replaceAll(/<(\/?)(li|b|u|h\d|span)>/g, '<$1$2>');
originTxt = originTxt.replaceAll('<br>', '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
if (contextBox.querySelector('#PZmark')?.textContent === originTxt) return;
contextBox.innerHTML = context.replace('<mark id="PZmark" class="mark">', '').replace(originTxt, `<mark id="PZmark" class="mark">${originTxt}</mark>`);
}
// #endregion
// #region 修复原文排版崩坏和<<>> fixOrigin(originElem)
function fixOrigin(originElem) {
originElem.innerHTML = originElem.innerHTML
.replaceAll('<abbr title="noun.>" data-value=">">></abbr>', '>')
.replaceAll(/<var>(<<[^<]*?>)<\/var>>/g, '<var class="PZvar">$1></var>')
.replaceAll('<i class="lf" <abbr="" title="noun.>" data-value=">">>>', '')
.replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>>>', '')
.replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>>', '');
}
// #endregion
// #region 修复 Ctrl 唤起菜单的<<>> fixTagSelect
const insertTag = debounce((tag) => {
const textarea = document.querySelector('textarea.translation');
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
const currentText = textarea.value;
const before = currentText.slice(0, startPos);
const after = currentText.slice(endPos);
mockInput(before.slice(0, Math.max(before.length - tag.length + 1, 0)) + tag + after);
textarea.selectionStart = startPos + 1;
textarea.selectionEnd = endPos + 1;
})
let activeTag = null;
let modifiedTags = [];
const tagSelectController = new AbortController();
const { tagSelectSignal } = tagSelectController;
function tagSelectHandler(event) {
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
activeTag &&= document.querySelector('.list-group-item.tag.active');
}
if (event.key === 'Enter') {
if (!activeTag) return;
if (!modifiedTags.includes(activeTag)) return;
event.preventDefault();
insertTag(activeTag?.textContent);
activeTag = null;
tagSelectController.abort();
}
}
function updFixedTags() {
const tags = document.querySelectorAll('.list-group-item.tag');
activeTag = document.querySelector('.list-group-item.tag.active');
modifiedTags = [];
for (const tag of tags) {
tag.innerHTML = tag.innerHTML.trim();
if (tag.innerHTML.startsWith('<<') && !tag.innerHTML.endsWith('>>')) {
tag.innerHTML += '>';
modifiedTags.push(tag);
}
}
document.addEventListener('keyup', tagSelectHandler, { tagSelectSignal });
}
// #endregion
// #region 将填充原文移到右边,增加填充原文并保存 tweakButtons
function tweakButtons() {
const copyButton = document.querySelector('button.btn-secondary:has(.fa-clone)');
const rightButtons = document.querySelector('.right .btn-group');
if (rightButtons) {
if (copyButton) {
rightButtons.insertBefore(copyButton, rightButtons.firstChild);
}
if (document.querySelector('#PZpaste')) return;
const pasteSave = document.createElement('button');
rightButtons.appendChild(pasteSave);
pasteSave.id = 'PZpaste';
pasteSave.type = 'button';
pasteSave.classList.add('btn', 'btn-secondary');
pasteSave.title = '填充原文并保存';
pasteSave.innerHTML = '<i aria-hidden="true" class="far fa-save"></i>';
pasteSave.addEventListener('click', async () => {
await mockInput(document.querySelector('.editor-core .original')?.textContent);
document.querySelector('.right .btn-primary')?.click();
});
}
}
// #endregion
// #region 缩略对比差异中过长无差异文本 extractDiff
function extractDiff() {
document.querySelectorAll('.diff-wrapper:not(.PZedited)').forEach(wrapper => {
wrapper.childNodes.forEach(node => {
if (node.nodeType !== Node.TEXT_NODE || node.length < 200) return;
const text = node.cloneNode();
const expand = document.createElement('span');
expand.textContent = `${node.textContent.slice(0, 100)} ... ${node.textContent.slice(-100)}`;
expand.style.cursor = 'pointer';
expand.style.background = 'linear-gradient(to right, transparent, #aaf6, transparent)';
expand.style.borderRadius = '2px';
let time = 0;
let isMoving = false;
const start = () => {
time = Date.now()
isMoving = false;
}
const end = () => {
if (isMoving || Date.now() - time > 500) return;
expand.replaceWith(text);
}
expand.addEventListener('mousedown', start);
expand.addEventListener('mouseup', end);
expand.addEventListener('mouseleave', () => time = 0);
expand.addEventListener('touchstart', start);
expand.addEventListener('touchend', end);
expand.addEventListener('touchcancel', () => time = 0);
expand.addEventListener('touchmove', () => isMoving = true);
node.replaceWith(expand);
});
wrapper.classList.add('PZedited');
});
}
// #endregion
// #region 点击对比差异绿色文字粘贴其中文本 clickDiff
function clickDiff() {
const addeds = document.querySelectorAll('.diff.added:not(.PZPedited)');
for (const added of addeds) {
added.classList.add('PZPedited');
const text = added.textContent.replaceAll('\\n', '\n');
added.style.cursor = 'pointer';
added.addEventListener('click', () => {
mockInsert(text);
});
}
}
// #endregion
// #region 快速搜索原文 copySearch
async function copySearch() {
if (document.querySelector('#PZsch')) return;
const originSch = document.querySelector('.btn-sm');
if (!originSch) return;
originSch.insertAdjacentHTML('beforebegin', '<button id="PZsch" type="button" class="btn btn-secondary btn-sm"><i aria-hidden="true" class="far fa-clone"></i></button>');
const newSch = document.querySelector('#PZsch');
newSch.addEventListener('click', async () => {
const original = document.querySelector('.editor-core .original')?.textContent;
let input = document.querySelector('.search-form.mt-3 input[type=search]');
if (!input) {
await (() => new Promise(resolve => resolve(originSch.click())))();
input = document.querySelector('.search-form.mt-3 input[type=search]');
}
input.value = original;
input.dispatchEvent(new Event('input', {
bubbles: true,
cancelable: true,
}));
});
}
// #endregion
// #region 搜索结果对比差异 searchDiff
function searchDiff() {
const strings = document.querySelectorAll('.original.mb-1 span:not(:has(+a)');
if (!strings[0]) return;
const original = document.querySelector('.editor-core .original')?.textContent;
const { $diff } = document.querySelector('main').__vue__;
for (const string of strings) {
const strHTML = string.innerHTML;
const showDiff = document.createElement('a');
showDiff.title = '查看差异';
showDiff.href = '#';
showDiff.target = '_self';
showDiff.classList.add('small');
showDiff.innerHTML = '<i aria-hidden="true" class="far fa-right-left-large"></i>';
string.after(' ', showDiff);
showDiff.addEventListener('click', function() {
string.innerHTML = this.isShown ? strHTML : $diff(string.textContent, original);
this.isShown = !this.isShown;
})
}
}
// #region 高级搜索空格变+修复 fixAdvSch
function fixAdvSch() {
const inputs = document.querySelectorAll('#advancedSearch table input');
if (!inputs[0]) return;
const params = new URLSearchParams(location.search);
const values = [...params.entries()].filter(([key, _]) => /(text|original|translation).?/.test(key)).map(([_, value]) => value.replaceAll(' ', '+'));
for (const input of inputs) {
if (values.includes(input.value)) {
input.value = input.value.replaceAll('+', ' ');
input.dispatchEvent(new Event('input', {
bubbles: true,
cancelable: true,
}));
}
}
}
// #region 自动保存全部相同词条 autoSaveAll
const autoSave = localStorage.getItem('pzdiffautosave');
function autoSaveAll() {
const button = document.querySelector('.modal-dialog .btn-primary');
if (autoSave && button.textContent === '保存全部') button.click();
}
// #region 初始化自动编辑 initAuto
async function initAuto() {
const avatars = await waitForElems('.nav-item.user-info');
avatars.forEach(async (avatar) => {
let harvesting = false;
let translationPattern, skipPattern, userTime;
avatar.insertAdjacentHTML('afterend', `<li class="nav-item"><a href="javascript:;" target="_self" class="PZpp nav-link" role="button">PP收割机</a></li>`);
document.querySelectorAll('.PZpp').forEach(btn => btn.addEventListener('click', async (e) => {
if (location.pathname.split('/')[3] !== 'strings') return;
harvesting = !harvesting;
if (harvesting) {
e.target.style.color = '#dc3545';
translationPattern = prompt(`请确认译文模板代码,字符串用'包裹;常用代码:
original(原文)
oldTrans(现有译文)
suggest(第1条翻译建议)
suggestSim(上者匹配度,最大100)`, 'original');
if (translationPattern === null) return cancel();
skipPattern = prompt(`请确认跳过条件代码,多个条件用逻辑运算符相连;常用代码:
original.match(/^(\s|\n|<<.*?>>|<.*?>)*/gm)[0] !== original(跳过并非只包含标签)
oldTrans(现有译文)
suggest(第1条翻译建议)
suggestSim(上者匹配度,最大100)
context(上下文内容)`, '');
if (skipPattern === null) return cancel();
if (skipPattern === '') skipPattern = 'false';
userTime = prompt('请确认生成译文后等待时间(单位:ms)', '500');
if (userTime === null) return cancel();
function cancel() {
harvesting = false;
e.target.style.color = '';
}
} else {
e.target.style.color = '';
return;
}
const hideAlert = document.createElement('style');
document.head.appendChild(hideAlert);
hideAlert.innerHTML = '.alert-success.alert-global{display:none}';
const checkboxs = [...document.querySelectorAll('.right .custom-checkbox')].slice(0, 2);
const checkboxValues = checkboxs.map(e => e.__vue__.$data.localChecked);
checkboxs.forEach(e => e.__vue__.$data.localChecked = true);
const print = {
waiting: () => console.log('%cWAITING...', 'background: #007BFF; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px'),
skip: () => console.log('%cSKIP', 'background: #FFC107; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px'),
click: () => console.log('%cCLICK', 'background: #20C997; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px'),
end: () => console.log('%cTHE END', 'background: #DE065B; color: white; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px'),
}
const INTERVAL = 100;
let interval = INTERVAL;
let lastInfo = null;
function prepareWait() {
print.waiting();
interval = INTERVAL;
lastInfo = null;
return true;
}
function skipOrFin(originElem, nextButton) {
if (nextString(nextButton)) return false;
print.skip();
interval = 50;
lastInfo = [
originElem,
location.search.match(/(?<=(\?|&)page=)\d+/g)[0]
];
return true;
}
function nextString(button) {
if (button.disabled) {
print.end();
harvesting = false;
e.target.style.color = '';
return true;
}
button.click();
return false;
}
try {
while (true) {
await sleep(interval);
if (lastInfo) {
const [ lastOrigin, lastPage ] = lastInfo;
// 已点击翻页,但原文未发生改变
const skipWaiting = location.search.match(/(?<=(\?|&)page=)\d+/g)[0] !== lastPage
&& document.querySelector('.editor-core .original') === lastOrigin;
if (skipWaiting && prepareWait()) continue;
}
const originElem = document.querySelector('.editor-core .original');
if (!originElem && prepareWait()) continue;
const nextButton = document.querySelectorAll('.navigation .btn-secondary')[1];
if (!nextButton && prepareWait()) continue;
const original = originElem.textContent;
const oldTrans = document.querySelector('textarea.translation').value;
let suggest = null, suggestSim = 0;
if (translationPattern.includes('suggest') || skipPattern.includes('suggest')) {
suggest = (await waitForElems('.translation-memory .translation, .empty-sign'))[0].textContent;
suggestSim = +(await waitForElems('.translation-memory header span span'))[0].textContent.split('\n')?.[2].trim().slice(0, -1);
}
const context = document.querySelector('.context')?.textContent;
if (eval(skipPattern)) {
if (skipOrFin(originElem, nextButton)) continue; else break;
}
const translation = eval(translationPattern);
if (!translation && prepareWait()) continue;
await mockInput(translation);
await sleep(userTime);
if (!harvesting) break; // 放在等待后,以便在等待间隔点击取消
const translateButton = document.querySelector('.right .btn-primary');
if (!translateButton) {
if (skipOrFin(originElem, nextButton)) continue; else break;
} else {
translateButton.click();
print.click();
interval = INTERVAL;
lastInfo = null;
continue;
}
}
} catch (e) {
console.error(e);
alert('出错了!');
} finally {
hideAlert.remove();
checkboxs.forEach((e, i) => { e.__vue__.$data.localChecked = checkboxValues[i] });
}
}));
});
}
// #endregion
// #endregion
addHotkeys();
initAuto();
let lastPath = location.pathname;
async function actByPath() {
lastPath = location.pathname;
if (location.pathname.split('/').pop() === 'strings') {
let original;
let lastOriginText = '';
let toObserve = document.body;
let observer = new MutationObserver((mutations) => {
const savedRange = saveSelection();
if (savedRange) restoreSelection(savedRange);
fixAdvSch();
original = document.querySelector('.editor-core .original');
if (!original) return;
const isOriginUpd = lastOriginText && original.textContent !== lastOriginText;
lastOriginText = original.textContent;
observer.disconnect();
initSkip();
markContext(original.textContent);
markSearchParams(isOriginUpd);
fixOrigin(original);
tweakButtons();
clickDiff();
extractDiff();
copySearch();
if (isOriginUpd) {
const input = document.querySelector('.search-form.mt-3 input[type=search]');
if (input) document.querySelectorAll('.btn-sm')[1]?.click();
}
for (const mutation of mutations) {
const { addedNodes, removedNodes } = mutation;
for (const node of addedNodes) {
// console.log('added', node);
if (node.matches?.('.list-group.tags')) updFixedTags();
if (node.matches?.('.string-item a.small')) node.remove();
if (node.matches?.('.modal-backdrop')) autoSaveAll();
}
for (const node of removedNodes) {
// console.log('removed ', node);
if (node.matches?.('.loading')) searchDiff();
}
}
observer.observe(toObserve, {
childList: true,
subtree: true,
});
});
observer.observe(toObserve, {
childList: true,
subtree: true,
});
return observer;
} else if (location.pathname.split('/').at(-2) === 'issues') {
waitForElems('.text-content p img').then((imgs) => {
imgs.forEach(mediumZoom);
});
} else if (location.pathname.split('/').pop() === 'history') {
let observer = new MutationObserver(() => {
observer.disconnect();
extractDiff();
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return observer;
}
}
let cancelAct = await actByPath();
(await waitForElems('main'))[0].__vue__.$router.afterHooks.push(async ()=>{
dropLastMark?.();
dropLastMark = updMark();
if (lastPath === location.pathname) return;
cancelAct?.disconnect();
console.debug('path changed');
cancelAct = await actByPath();
});
// #region utils
function waitForElems(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) {
return resolve(document.querySelectorAll(selector));
}
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
resolve(document.querySelectorAll(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
function mockInput(text) {
return new Promise((resolve) => {
const textarea = document.querySelector('textarea.translation');
if (!textarea) return;
textarea.value = text;
textarea.dispatchEvent(new Event('input', {
bubbles: true,
cancelable: true,
}));
return resolve(0);
})
}
function mockInsert(text) {
const textarea = document.querySelector('textarea.translation');
if (!textarea) return;
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
const currentText = textarea.value;
const before = currentText.slice(0, startPos);
const after = currentText.slice(endPos);
mockInput(before + text + after);
textarea.selectionStart = startPos + text.length;
textarea.selectionEnd = endPos + text.length;
}
function debounce(func, timeout = 300) {
let called = false;
return (...args) => {
if (!called) {
func.apply(this, args);
called = true;
setTimeout(() => {
called = false;
}, timeout);
}
};
}
function saveSelection() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
return selection.getRangeAt(0);
}
return null;
}
function restoreSelection(range) {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// #endregion
})();