ParaTranz diff

ParaTranz enhanced

目前為 2024-12-24 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ParaTranz diff
// @namespace    https://paratranz.cn/users/44232
// @version      0.7.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() {
        waitForElems('.string-list .empty-sign').then(() => {
            if (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 = () => console.log('PZdiff: no search');
    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 = () => {
                markNorm('.editor-core .original', text);
                return markEditing(text);
            }
        } else if (original) {
            markSearchParams = () => {
                markNorm('.editor-core .original', original);
            }
        } else if (translation) {
            markSearchParams = () => {
                return markEditing(translation);
            }
        } else if (context) {
            markSearchParams = () => {
                markNorm('.context', context);
            }
        } else {
            markSearchParams = () => console.log('PZdiff: no search');
        }
    }
    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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').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, '&lt;$1$2&gt;');
        originTxt = originTxt.replaceAll('<br>', '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
        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=">">&gt;</abbr>', '&gt;')
        .replaceAll(/<var>(&lt;&lt;[^<]*?&gt;)<\/var>&gt;/g, '<var class="PZvar">$1&gt;</var>')
        .replaceAll('<i class="lf" <abbr="" title="noun.>" data-value=">">&gt;&gt;', '')
        .replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>&gt;&gt;', '')
        .replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>&gt;', '');
    }
    // #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;
    })

    function fixTagSelect() {
        const tags = document.querySelectorAll('.list-group-item.tag');
        let activeTag;
        const modifiedTags = [];
        if (tags[0]) {
            for (const tag of tags) {
                tag.innerHTML = tag.innerHTML.trim();
                if (tag.innerHTML.startsWith('&lt;&lt;') && !tag.innerHTML.endsWith('&gt;&gt;')) {
                    tag.innerHTML += '&gt;';
                    modifiedTags.push(tag);
                }
            }
            activeTag = document.querySelector('.list-group-item.tag.active');
            document.addEventListener('keyup', handler);
        }

        function handler(event) {
            if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
                activeTag = document.querySelector('.list-group-item.tag.active');
            }
            if (event.key === 'Enter') {
                event.preventDefault();
                if (!activeTag) return;
                if (!modifiedTags.includes(activeTag)) return;
                insertTag(activeTag?.textContent);
                document.removeEventListener('keyup', handler);
            }
        }
    }
    // #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.after(text);
                    expand.remove();
                }

                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.after(expand);
                node.remove();
            });
            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 初始化自动编辑 initAuto
    function initAuto() {
        waitForElems('.nav-item.user-info').then((bannerL) => {
            const banner = bannerL[0];
            let harvesting = false;
            let translationPattern, skipPattern, interval;
            banner.insertAdjacentHTML('afterend', `<li class="nav-item"><a id="PZpp" href="javascript:;" target="_self" class="nav-link" role="button">PP收割机</a></li>`);
            document.querySelector('#PZpp').addEventListener('click', async (e) => {
                if (location.pathname.split('/')[3] !== 'strings') return;
                harvesting = !harvesting;
                if (harvesting) {
                    e.target.style.color = '#dc3545';
                    translationPattern = prompt(`请确认译文模板代码,字符串用'包裹;常用代码:
    original(原文)
    document.querySelector('textarea.translation')?.value(现有译文)
    document.querySelectorAll('.translation-memory .translation')?.[0].textContent(第1条翻译建议)`, 'original');
                    if (translationPattern === null) return cancel();
                    skipPattern = prompt(`请确认跳过条件代码,多个条件用逻辑运算符相连;常用代码:
    original.match(/^(\s|\n|<<.*?>>|<.*?>)*/gm)[0] !== original(跳过并非只包含标签)
    document.querySelector('textarea.translation')?.value(现有译文)
    document.querySelector('.context').textContent(上下文内容)`, '');
                    if (skipPattern === null) return cancel();
                    if (skipPattern === '') skipPattern = 'false';
                    interval = prompt('请确认每次操作时间间隔(单位:ms)', '100');
                    if (interval === null) return cancel();
                    function cancel() {
                        harvesting = false;
                        e.target.style.color = '';
                    }
                } else {
                    e.target.style.color = '';
                    return 0;
                }

                const hideAlert = document.createElement('style');
                document.head.appendChild(hideAlert);
                hideAlert.innerHTML = '.alert-success.alert-global{display:none}';
                const checkboxs = Array.from(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);

                await (function harvest(time, skipInfo) {
                    return new Promise(async (resolve) => {
                        await sleep(time);
                        if (!harvesting) return resolve(0);
                        if (skipInfo) {
                            const skipWaiting = location.search.match(/(?<=(\?|&)page=)\d+/g) !== skipInfo[1]
                                       && document.querySelector('.editor-core .original') === skipInfo[0];
                            if (skipWaiting) {
                                return resolve(harvest(time, skipInfo));
                            }
                        }
                        const original = document.querySelector('.editor-core .original')?.textContent;
                        const nextButton = document.querySelectorAll('.navigation .btn-secondary')[1];
                        if (!original || !nextButton) {
                            console.log('%cWAITING...', 'background: #007BFF; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            return resolve(harvest(interval));
                        }

                        const translation = eval(translationPattern);
                        if (!translation) {
                            console.log('%cWAITING...', 'background: #007BFF; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            return resolve(harvest(interval));
                        }
                        if (eval(skipPattern)) {
                            console.log('%cSKIP!', 'background: #ffc107; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            if (nextString()) return resolve(0);
                            return resolve(harvest(interval/2, [
                                document.querySelector('.editor-core .original'),
                                location.search.match(/(?<=(\?|&)page=)\d+/g)
                            ]));
                        }

                        await mockInput(translation);
                        const translateButton = document.querySelector('.right .btn-primary');
                        if (!translateButton) {
                            if (nextButton) {
                                console.log('%cSKIP!', 'background: #ffc107; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                                if (nextString()) return resolve(0);
                                console.log(original)
                                return resolve(harvest(interval/2, [
                                    document.querySelector('.editor-core .original'),
                                    location.search.match(/(?<=(\?|&)page=)\d+/g)
                                ]));
                            }
                        } else {
                            console.log('%cCLICK!', 'background: #20C997; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            translateButton.click();
                            return resolve(harvest(interval));
                        }

                        function nextString() {
                            if (nextButton.disabled) {
                                console.log('%cTHE END!', 'background: #DE065B; color: white; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                                harvesting = false;
                                e.target.style.color = '';
                                return true;
                            }
                            nextButton.click();
                            return false;
                        }
                    });
                })(interval);

                hideAlert.remove();
                checkboxs.forEach((e, i) => { e.__vue__.$data.localChecked = checkboxValues[i] });

            });
        });
    }
    // #endregion

    // #endregion

    addHotkeys();
    initAuto();

    let lastPath = location.pathname;
    function actByPath() {
        lastPath = location.pathname;
        if (location.pathname.split('/').pop() === 'strings') {

            initSkip();
            let original;

            let observer = new MutationObserver(() => {

                original = document.querySelector('.editor-core .original');
                if (!original) return;

                observer.disconnect();
                markContext(original.textContent);
                fixOrigin(original);
                tweakButtons();
                fixTagSelect();
                markSearchParams();
                clickDiff();
                extractDiff();

                observer.observe(document.getElementsByTagName('body')[0], {
                    childList: true,
                    subtree: true,
                });
            });

            observer.observe(document.getElementsByTagName('body')[0], {
                childList: true,
                subtree: true,
            });

        } 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.getElementsByTagName('body')[0], {
                    childList: true,
                    subtree: true,
                });
            });
            observer.observe(document.getElementsByTagName('body')[0], {
                childList: true,
                subtree: true,
            });
        }
    }
    actByPath();
    document.querySelector('main').__vue__.$router.afterHooks.push(()=>{
        dropLastMark?.();
        dropLastMark = updMark();
        if (lastPath === location.pathname) return;
        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);
          }
        };
    }
    // #endregion

})();