// ==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">×</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);
});
});
})();