ParaTranz Tools

为 ParaTranz 添加正则表达式管理和机器翻译功能。

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ParaTranz Tools
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  为 ParaTranz 添加正则表达式管理和机器翻译功能。
// @author       HeliumOctahelide
// @license      WTFPL
// @match        https://paratranz.cn/projects/*/strings*
// @icon         https://paratranz.cn/favicon.png
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // 基类定义
    class BaseComponent {
        constructor(selector) {
            this.selector = selector;
            this.init();
        }

        init() {
            this.checkExistence();
        }

        checkExistence() {
            const element = document.querySelector(this.selector);
            if (!element) {
                this.insert();
            }
            setTimeout(() => this.checkExistence(), 1000);
        }

        insert() {
            // 留空,子类实现具体插入逻辑
        }
    }

    // 按钮类定义,继承自BaseComponent
    class Button extends BaseComponent {
        constructor(selector, toolbarSelector, htmlContent, callback) {
            super(selector);
            this.toolbarSelector = toolbarSelector;
            this.htmlContent = htmlContent;
            this.callback = callback;
        }

        insert() {
            const toolbar = document.querySelector(this.toolbarSelector);
            if (!toolbar) {
                console.log(`Toolbar not found: ${this.toolbarSelector}`);
                return;
            }
            if (toolbar && !document.querySelector(this.selector)) {
                const button = document.createElement('button');
                button.className = this.selector.split('.').join(' ');
                button.innerHTML = this.htmlContent;
                button.type = 'button';
                button.addEventListener('click', this.callback);
                toolbar.insertAdjacentElement('afterbegin', button);
                console.log(`Button inserted: ${this.selector}`);
            }
        }
    }

    // 手风琴类定义,继承自BaseComponent
    class Accordion extends BaseComponent {
        constructor(selector, parentSelector) {
            super(selector);
            this.parentSelector = parentSelector;
        }

        insert() {
            const parentElement = document.querySelector(this.parentSelector);
            if (!parentElement) {
                console.log(`Parent element not found: ${this.parentSelector}`);
                return;
            }
            if (parentElement && !document.querySelector(this.selector)) {
                const accordionHTML = `
                    <div class="accordion" id="accordionExample"></div>
                    <hr>
                `;
                parentElement.insertAdjacentHTML('afterbegin', accordionHTML);
            }
        }

        addCard(card) {
            card.insert();
        }
    }

    // 卡片类定义,继承自BaseComponent
    class Card extends BaseComponent {
        constructor(selector, parentSelector, headingId, title, contentHTML) {
            super(selector);
            this.parentSelector = parentSelector;
            this.headingId = headingId;
            this.title = title;
            this.contentHTML = contentHTML;
        }

        insert() {
            const parentElement = document.querySelector(this.parentSelector);
            if (!parentElement) {
                console.log(`Parent element not found: ${this.parentSelector}`);
                return;
            }
            if (parentElement && !document.querySelector(this.selector)) {
                const cardHTML = `
                    <div class="card m-0">
                        <div class="card-header p-0" id="${this.headingId}">
                            <h2 class="mb-0">
                                <button class="btn btn-link" type="button" aria-expanded="false" aria-controls="${this.selector.substring(1)}">
                                    ${this.title}
                                </button>
                            </h2>
                        </div>
                        <div id="${this.selector.substring(1)}" class="collapse" aria-labelledby="${this.headingId}" data-parent="#accordionExample" style="max-height: 70vh; overflow-y: auto;">
                            <div class="card-body">
                                ${this.contentHTML}
                            </div>
                        </div>
                    </div>
                `;
                parentElement.insertAdjacentHTML('beforeend', cardHTML);

                const toggleButton = document.querySelector(`#${this.headingId} button`);
                const collapseDiv = document.querySelector(this.selector);
                toggleButton.addEventListener('click', function() {
                    if (collapseDiv.style.maxHeight === '0px' || !collapseDiv.style.maxHeight) {
                        collapseDiv.style.display = 'block';
                        requestAnimationFrame(() => {
                            collapseDiv.style.maxHeight = collapseDiv.scrollHeight + 'px';
                        });
                        toggleButton.setAttribute('aria-expanded', 'true');
                    } else {
                        collapseDiv.style.maxHeight = '0px';
                        toggleButton.setAttribute('aria-expanded', 'false');
                        collapseDiv.addEventListener('transitionend', () => {
                            if (collapseDiv.style.maxHeight === '0px') {
                                collapseDiv.style.display = 'none';
                            }
                        }, { once: true });
                    }
                });

                collapseDiv.style.maxHeight = '0px';
                collapseDiv.style.overflow = 'hidden';
                collapseDiv.style.transition = 'max-height 0.3s ease';
            }
        }
    }

    // 定义具体的正则管理卡片
    class RegexCard extends Card {
        constructor(parentSelector) {
            const headingId = 'headingOne';
            const contentHTML = `
                <div id="managePage">
                    <div id="regexList"></div>
                    <div class="regex-item mb-3 p-2" style="border: 1px solid #ccc; border-radius: 8px;">
                        <input type="text" placeholder="Pattern" id="newPattern" class="form-control mb-2"/>
                        <input type="text" placeholder="Replacement" id="newRepl" class="form-control mb-2"/>
                        <button class="btn btn-secondary" id="addRegexButton">
                            <i class="far fa-plus-circle"></i> 添加正则表达式
                        </button>
                    </div>
                    <div class="mt-3">
                        <button class="btn btn-primary" id="exportRegexButton">导出正则表达式</button>
                        <input type="file" id="importRegexInput" class="d-none"/>
                        <button class="btn btn-primary" id="importRegexButton">导入正则表达式</button>
                    </div>
                </div>
            `;
            super('#collapseOne', parentSelector, headingId, '正则管理', contentHTML);
        }

        insert() {
            super.insert();
            // 如果尚未插入则先略过
            if (!document.querySelector('#collapseOne')) {
                return;
            }
            document.getElementById('addRegexButton').addEventListener('click', this.addRegex);
            document.getElementById('exportRegexButton').addEventListener('click', this.exportRegex);
            document.getElementById('importRegexButton').addEventListener('click', () => {
                document.getElementById('importRegexInput').click();
            });
            document.getElementById('importRegexInput').addEventListener('change', this.importRegex);
            this.loadRegexList();
        }

        addRegex = () => {
            const pattern = document.getElementById('newPattern').value;
            const repl = document.getElementById('newRepl').value;

            if (pattern && repl) {
                // 获取当前存储的正则列表
                const regexList = JSON.parse(localStorage.getItem('regexList')) || [];

                // 添加新的正则表达式
                regexList.push({ pattern, repl });

                // 保存到 localStorage
                localStorage.setItem('regexList', JSON.stringify(regexList));

                // 立即调用 loadRegexList 刷新页面
                this.loadRegexList();

                // 清空输入框
                document.getElementById('newPattern').value = '';
                document.getElementById('newRepl').value = '';
            }
        };

        loadRegexList() {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            const regexListDiv = document.getElementById('regexList');
            regexListDiv.innerHTML = '';
            regexList.forEach((regex, index) => {
                const regexDiv = document.createElement('div');
                regexDiv.className = 'regex-item mb-3 p-2';
                regexDiv.style.border = '1px solid #ccc';
                regexDiv.style.borderRadius = '8px';
                regexDiv.style.transition = 'transform 0.3s';
                regexDiv.style.backgroundColor = regex.disabled ? '#f2dede' : '#fff';
                regexDiv.innerHTML = `
                    <div class="mb-2">
                        <input type="text" class="form-control mb-1" value="${regex.pattern}" data-index="${index}" data-type="pattern"/>
                        <input type="text" class="form-control" value="${regex.repl}" data-index="${index}" data-type="repl"/>
                    </div>
                    <div class="d-flex justify-content-between">
                        <div role="group" class="btn-group">
                            <button class="btn btn-secondary moveUpButton" data-index="${index}" title="上移">
                                <i class="fas fa-arrow-up"></i>
                            </button>
                            <button class="btn btn-secondary moveDownButton" data-index="${index}" title="下移">
                                <i class="fas fa-arrow-down"></i>
                            </button>
                            <button class="btn btn-secondary toggleRegexButton" data-index="${index}" title="禁用/启用">
                                <i class="${regex.disabled ? 'fas fa-toggle-off' : 'fas fa-toggle-on'}"></i>
                            </button>
                            <button class="btn btn-secondary matchRegexButton" data-index="${index}" title="匹配">
                                <i class="fas fa-play"></i>
                            </button>
                        </div>
                        <div role="group" class="btn-group">
                            <button class="btn btn-success saveRegexButton" data-index="${index}" title="保存">
                                <i class="far fa-save"></i>
                            </button>
                            <button class="btn btn-danger deleteRegexButton" data-index="${index}" title="删除">
                                <i class="far fa-trash-alt"></i>
                            </button>
                        </div>
                    </div>
                `;
                regexListDiv.appendChild(regexDiv);
            });

            // 强制触发容器的重绘
            regexListDiv.style.display = 'none';  // 设置为不可见状态
            regexListDiv.offsetHeight;            // 读取元素的高度,强制重绘
            regexListDiv.style.display = '';      // 重新设置为可见状态

            document.querySelectorAll('.saveRegexButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = button.getAttribute('data-index');
                    this.saveRegex(index);
                });
            });

            document.querySelectorAll('.deleteRegexButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = button.getAttribute('data-index');
                    this.deleteRegex(index);
                });
            });

            document.querySelectorAll('.toggleRegexButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = button.getAttribute('data-index');
                    this.toggleRegex(index);
                });
            });

            document.querySelectorAll('.matchRegexButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = button.getAttribute('data-index');
                    this.matchRegex(index);
                });
            });

            document.querySelectorAll('.moveUpButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = parseInt(button.getAttribute('data-index'));
                    this.moveRegex(index, -1);
                });
            });

            document.querySelectorAll('.moveDownButton').forEach(button => {
                button.addEventListener('click', () => {
                    const index = parseInt(button.getAttribute('data-index'));
                    this.moveRegex(index, 1);
                });
            });
        }

        saveRegex() {
            const regexItems = document.querySelectorAll('.regex-item');
            const updatedRegexList = [];

            regexItems.forEach(item => {
                const patternInput = item.querySelector('input[data-type="pattern"]');
                const replInput = item.querySelector('input[data-type="repl"]');
                const disabled = item.style.backgroundColor === '#f2dede';

                if (patternInput && replInput) {
                    updatedRegexList.push({
                        pattern: patternInput.value,
                        repl: replInput.value,
                        disabled: disabled
                    });
                }
            });

            localStorage.setItem('regexList', JSON.stringify(updatedRegexList));
            this.loadRegexList();
        }

        deleteRegex(index) {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            regexList.splice(index, 1);
            localStorage.setItem('regexList', JSON.stringify(regexList));
            this.loadRegexList();
        }

        toggleRegex(index) {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            regexList[index].disabled = !regexList[index].disabled;
            localStorage.setItem('regexList', JSON.stringify(regexList));
            this.loadRegexList();
        }

        matchRegex(index) {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            const regex = regexList[index];
            const textareas = document.querySelectorAll('textarea.translation.form-control');

            textareas.forEach(textarea => {
                let text = textarea.value;
                const pattern = new RegExp(regex.pattern, 'g');
                text = text.replace(pattern, regex.repl);
                this.simulateInputChange(textarea, text);
            });
        }

        moveRegex(index, direction) {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            const newIndex = index + direction;
            if (newIndex >= 0 && newIndex < regexList.length) {
                const [movedItem] = regexList.splice(index, 1);
                regexList.splice(newIndex, 0, movedItem);
                localStorage.setItem('regexList', JSON.stringify(regexList));
                this.loadRegexListWithAnimation(index, newIndex);
            }
        }

        loadRegexListWithAnimation(oldIndex, newIndex) {
            const regexListDiv = document.getElementById('regexList');
            const items = regexListDiv.querySelectorAll('.regex-item');
            const oldItem = items[oldIndex];
            const newItem = items[newIndex];

            oldItem.style.transform = `translateY(${(newIndex - oldIndex) * 100}%)`;
            newItem.style.transform = `translateY(${(oldIndex - newIndex) * 100}%)`;

            setTimeout(() => {
                this.loadRegexList();
            }, 300);
        }

        simulateInputChange(element, newValue) {
            const inputEvent = new Event('input', { bubbles: true });
            const originalValue = element.value;
            element.value = newValue;

            const tracker = element._valueTracker;
            if (tracker) {
                tracker.setValue(originalValue);
            }

            element.dispatchEvent(inputEvent);
        }

        exportRegex() {
            const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
            const json = JSON.stringify(regexList, null, 2);
            const blob = new Blob([json], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = 'regexList.json';
            a.click();
            URL.revokeObjectURL(url);
        }

        importRegex(event) {
            const file = event.target.files[0];
            const reader = new FileReader();
            reader.onload = event => {
                const content = event.target.result;
                const regexList = JSON.parse(content);
                localStorage.setItem('regexList', JSON.stringify(regexList));
                this.loadRegexList();
            };
            reader.readAsText(file);
        }
    }

    // 定义具体的机器翻译卡片
    class MachineTranslationCard extends Card {
        constructor(parentSelector) {
            const headingId = 'headingTwo';
            const contentHTML = `
                <button class="btn btn-primary" id="openTranslationConfigButton">配置翻译</button>
                <div class="mt-3">
                    <div class="d-flex">
                        <textarea id="originalText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
                        <div class="d-flex flex-column ml-2">
                            <button class="btn btn-secondary mb-2" id="copyOriginalButton">
                                <i class="fas fa-copy"></i>
                            </button>
                            <button class="btn btn-secondary" id="translateButton">
                                <i class="fas fa-globe"></i>
                            </button>
                        </div>
                    </div>
                    <div class="d-flex mt-2">
                        <textarea id="translatedText" class="form-control" style="width: 100%; height: 25vh;"></textarea>
                        <div class="d-flex flex-column ml-2">
                            <button class="btn btn-secondary mb-2" id="pasteTranslationButton">
                                <i class="fas fa-arrow-alt-left"></i>
                            </button>
                            <button class="btn btn-secondary" id="copyTranslationButton">
                                <i class="fas fa-copy"></i>
                            </button>
                        </div>
                    </div>
                </div>

                <!-- Translation Configuration Modal -->
                <div class="modal" id="translationConfigModal" tabindex="-1" role="dialog" style="display: none;">
                    <div class="modal-dialog" role="document">
                        <div class="modal-content">
                            <div class="modal-header">
                                <h5 class="modal-title">翻译配置</h5>
                                <button type="button" class="close" id="closeTranslationConfigModal" aria-label="Close">
                                    <span aria-hidden="true">&times;</span>
                                </button>
                            </div>
                            <div class="modal-body">
                                <form id="translationConfigForm">
                                    <div class="form-group">
                                        <label for="baseUrl">Base URL</label>
                                        <input type="text" class="form-control" id="baseUrl" placeholder="Enter base URL">
                                    </div>
                                    <div class="form-group">
                                        <label for="apiKey">API Key</label>
                                        <input type="text" class="form-control" id="apiKey" placeholder="Enter API key">
                                    </div>
                                    <div class="form-group">
                                        <label for="model">Model</label>
                                        <input type="text" class="form-control" id="model" placeholder="Enter model">
                                    </div>
                                    <div class="form-group">
                                        <label for="temperature">Prompt</label>
                                        <input type="text" class="form-control" id="prompt" placeholder="Enter prompt or use default prompt">
                                    </div>
                                    <div class="form-group">
                                        <label for="temperature">Temperature</label>
                                        <input type="number" step="0.1" class="form-control" id="temperature" placeholder="Enter temperature">
                                    </div>
                                </form>
                            </div>
                            <div class="modal-footer">
                                <button type="button" class="btn btn-secondary" id="closeTranslationConfigModalButton">关闭</button>
                            </div>
                        </div>
                    </div>
                </div>
            `;
            super('#collapseTwo', parentSelector, headingId, '机器翻译', contentHTML);
        }

        insert() {
            super.insert();
            if (!document.querySelector('#collapseTwo')) {
                return;
            }
            const translationConfigModal = document.getElementById('translationConfigModal');
            document.getElementById('openTranslationConfigButton').addEventListener('click', function() {
                translationConfigModal.style.display = 'block';
            });

            function closeModal() {
                translationConfigModal.style.display = 'none';
            }

            document.getElementById('closeTranslationConfigModal').addEventListener('click', closeModal);
            document.getElementById('closeTranslationConfigModalButton').addEventListener('click', closeModal);

            const baseUrlInput = document.getElementById('baseUrl');
            const apiKeyInput = document.getElementById('apiKey');
            const modelSelect = document.getElementById('model');
            const promptInput = document.getElementById('prompt');
            const temperatureInput = document.getElementById('temperature');

            baseUrlInput.value = localStorage.getItem('baseUrl') || '';
            apiKeyInput.value = localStorage.getItem('apiKey') || '';
            modelSelect.value = localStorage.getItem('model') || 'gpt-4o-mini';
            promptInput.value = localStorage.getItem('prompt') || '';
            temperatureInput.value = localStorage.getItem('temperature') || '';

            baseUrlInput.addEventListener('input', function() {
                localStorage.setItem('baseUrl', baseUrlInput.value);
            });

            apiKeyInput.addEventListener('input', function() {
                localStorage.setItem('apiKey', apiKeyInput.value);
            });

            modelSelect.addEventListener('input', function() {
                localStorage.setItem('model', modelSelect.value);
            });

            promptInput.addEventListener('input', function() {
                localStorage.setItem('prompt', promptInput.value);
            });

            temperatureInput.addEventListener('input', function() {
                localStorage.setItem('temperature', temperatureInput.value);
            });

            this.setupTranslation();
        }

        setupTranslation() {
            // 更新Original Text
            function updateOriginalText() {
                const originalDiv = document.querySelector('.original.well');
                if (originalDiv) {
                    const originalText = originalDiv.innerText;
                    document.getElementById('originalText').value = originalText;
                }
            }

            // 监控Original Text变化
            const observer = new MutationObserver(updateOriginalText);
            const config = { childList: true, subtree: true };
            const originalDiv = document.querySelector('.original.well');
            if (originalDiv) {
                observer.observe(originalDiv, config);
            }

            document.getElementById('copyOriginalButton').addEventListener('click', updateOriginalText);

            // 翻译功能
            document.getElementById('translateButton').addEventListener('click', async function() {
                const originalText = document.getElementById('originalText').value;
                console.log('Translating:', originalText);

                const model = localStorage.getItem('model') || 'gpt-4o-mini';
                const prompt = localStorage.getItem('prompt') || 'You are a professional translator focusing on translating Magic: The Gathering cards from English to Chinese. You are given a card\'s original text in English. Translate it into Chinese.';
                const temperature = parseFloat(localStorage.getItem('temperature')) || 0;

                document.getElementById('translatedText').value = '翻译中...';
                let translatedText = await translateText(originalText, model, prompt, temperature);
                // 正则替换
                const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
                regexList.forEach(regex => {
                    if (!regex.disabled) {
                        const pattern = new RegExp(regex.pattern, 'g');
                        translatedText = translatedText.replace(pattern, regex.repl);
                    }
                });
                document.getElementById('translatedText').value = translatedText;
            });

            // 复制译文到剪切板
            document.getElementById('copyTranslationButton').addEventListener('click', function() {
                const translatedText = document.getElementById('translatedText').value;
                navigator.clipboard.writeText(translatedText).then(() => {
                    console.log('Translated text copied to clipboard');
                }).catch(err => {
                    console.error('Failed to copy text: ', err);
                });
            });

            // 粘贴译文
            document.getElementById('pasteTranslationButton').addEventListener('click', function() {
                const translatedText = document.getElementById('translatedText').value;
                simulateInputChange(document.querySelector('textarea.translation.form-control'), translatedText);
            });
        }
    }

    // 翻译函数定义
    async function translateText(query, model, prompt, temperature) {
        const API_SECRET_KEY = localStorage.getItem('apiKey');
        const BASE_URL = localStorage.getItem('baseUrl');
        if (!prompt) {
            prompt = "You are a professional translator focusing on translating Magic: The Gathering cards from English to Chinese. You are given a card's original text in English. Translate it into Chinese.";
        }

        const requestBody = {
            model: model,
            temperature: temperature,
            messages: [
                { role: "system", content: prompt },
                { role: "user", content: query }
            ]
        };

        try {
            const response = await fetch(`${BASE_URL}chat/completions`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${API_SECRET_KEY}`
                },
                body: JSON.stringify(requestBody)
            });
            const data = await response.json();
            return data.choices[0].message.content;
        } catch (error) {
            console.error('Error:', error);
            return "翻译失败,请检查配置和网络连接。";
        }
    }

    function simulateInputChange(element, newValue) {
        const inputEvent = new Event('input', { bubbles: true });
        const originalValue = element.value;
        element.value = newValue;

        const tracker = element._valueTracker;
        if (tracker) {
            tracker.setValue(originalValue);
        }

        element.dispatchEvent(inputEvent);
    }

    // 初始化组件
    const accordion = new Accordion('#accordionExample', '.sidebar-right');
    const regexCard = new RegexCard('#accordionExample');
    const machineTranslationCard = new MachineTranslationCard('#accordionExample');

    accordion.addCard(regexCard);
    accordion.addCard(machineTranslationCard);

    const runButton = new Button('.btn.btn-secondary.match-button', '.toolbar .right .btn-group', '<i class="fas fa-play"></i> 匹配', function() {
        const regexList = JSON.parse(localStorage.getItem('regexList')) || [];
        const textareas = document.querySelectorAll('textarea.translation.form-control');

        textareas.forEach(textarea => {
            let text = textarea.value;
            regexList.forEach(regex => {
                if (!regex.disabled) {
                    const pattern = new RegExp(regex.pattern, 'g');
                    text = text.replace(pattern, regex.repl);
                }
            });
            simulateInputChange(textarea, text);
        });
    });
})();