// ==UserScript==
// @name ParaTranz-AI
// @namespace http://tampermonkey.net/
// @version 1.2
// @description ParaTranz文本替换和AI翻译功能拓展。
// @author HCPTangHY
// @license WTFPL
// @match https://paratranz.cn/*
// @icon https://paratranz.cn/favicon.png
// @grant none
// ==/UserScript==
// fork from HeliumOctahelide https://greasyfork.org/zh-CN/scripts/503063-paratranz-tools
(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 StringReplaceCard extends Card {
constructor(parentSelector) {
const headingId = 'headingOne';
const contentHTML = `
<div id="manageReplacePage">
<div id="replaceListContainer"></div>
<div class="replace-item mb-3 p-2" style="border: 1px solid #ccc; border-radius: 8px;">
<input type="text" placeholder="查找文本" id="newFindText" class="form-control mb-2"/>
<input type="text" placeholder="替换为" id="newReplacementText" class="form-control mb-2"/>
<button class="btn btn-secondary" id="addReplaceRuleButton">
<i class="far fa-plus-circle"></i> 添加替换规则
</button>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="exportReplaceRulesButton">导出替换规则</button>
<input type="file" id="importReplaceRuleInput" class="d-none"/>
<button class="btn btn-primary" id="importReplaceRuleButton">导入替换规则</button>
</div>
</div>
`;
super('#collapseOne', parentSelector, headingId, '文本替换', contentHTML);
}
insert() {
super.insert();
if (!document.querySelector('#collapseOne')) {
return;
}
document.getElementById('addReplaceRuleButton').addEventListener('click', this.addReplaceRule);
document.getElementById('exportReplaceRulesButton').addEventListener('click', this.exportReplaceRules);
document.getElementById('importReplaceRuleButton').addEventListener('click', () => {
document.getElementById('importReplaceRuleInput').click();
});
document.getElementById('importReplaceRuleInput').addEventListener('change', this.importReplaceRules);
this.loadReplaceList();
}
addReplaceRule = () => {
const findText = document.getElementById('newFindText').value;
const replacementText = document.getElementById('newReplacementText').value;
if (findText) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.push({ findText, replacementText, disabled: false });
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
document.getElementById('newFindText').value = '';
document.getElementById('newReplacementText').value = '';
}
};
updateRuleText(index, type, value) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
if (replaceList[index]) {
if (type === 'findText') {
replaceList[index].findText = value;
} else if (type === 'replacementText') {
replaceList[index].replacementText = value;
}
localStorage.setItem('replaceList', JSON.stringify(replaceList));
}
}
loadReplaceList() {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const replaceListDiv = document.getElementById('replaceListContainer');
replaceListDiv.innerHTML = '';
replaceList.forEach((rule, index) => {
const ruleDiv = document.createElement('div');
ruleDiv.className = 'replace-item mb-3 p-2';
ruleDiv.style.border = '1px solid #ccc';
ruleDiv.style.borderRadius = '8px';
ruleDiv.style.transition = 'transform 0.3s';
ruleDiv.style.backgroundColor = rule.disabled ? '#f2dede' : '#fff';
const inputsDiv = document.createElement('div');
inputsDiv.className = 'mb-2';
const findInput = document.createElement('input');
findInput.type = 'text';
findInput.className = 'form-control mb-1';
findInput.value = rule.findText;
findInput.placeholder = '查找文本';
findInput.dataset.index = index;
findInput.addEventListener('change', (event) => this.updateRuleText(index, 'findText', event.target.value));
inputsDiv.appendChild(findInput);
const replInput = document.createElement('input');
replInput.type = 'text';
replInput.className = 'form-control';
replInput.value = rule.replacementText;
replInput.placeholder = '替换为';
replInput.dataset.index = index;
replInput.addEventListener('change', (event) => this.updateRuleText(index, 'replacementText', event.target.value));
inputsDiv.appendChild(replInput);
ruleDiv.appendChild(inputsDiv);
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'd-flex justify-content-between';
const leftButtonGroup = document.createElement('div');
leftButtonGroup.className = 'btn-group';
leftButtonGroup.setAttribute('role', 'group');
const moveUpButton = this.createButton('上移', 'fas fa-arrow-up', () => this.moveReplaceRule(index, -1));
const moveDownButton = this.createButton('下移', 'fas fa-arrow-down', () => this.moveReplaceRule(index, 1));
const toggleButton = this.createButton('禁用/启用', rule.disabled ? 'fas fa-toggle-off' : 'fas fa-toggle-on', () => this.toggleReplaceRule(index));
const applyButton = this.createButton('应用此规则', 'fas fa-play', () => this.applySingleReplaceRule(index));
leftButtonGroup.append(moveUpButton, moveDownButton, toggleButton, applyButton);
const rightButtonGroup = document.createElement('div');
rightButtonGroup.className = 'btn-group';
rightButtonGroup.setAttribute('role', 'group');
const deleteButton = this.createButton('删除', 'far fa-trash-alt', () => this.deleteReplaceRule(index), 'btn-danger');
rightButtonGroup.appendChild(deleteButton);
buttonsDiv.append(leftButtonGroup, rightButtonGroup);
ruleDiv.appendChild(buttonsDiv);
replaceListDiv.appendChild(ruleDiv);
});
replaceListDiv.style.display = 'none';
replaceListDiv.offsetHeight;
replaceListDiv.style.display = '';
}
createButton(title, iconClass, onClick, btnClass = 'btn-secondary') {
const button = document.createElement('button');
button.className = `btn ${btnClass}`;
button.title = title;
button.innerHTML = `<i class="${iconClass}"></i>`;
button.addEventListener('click', onClick);
return button;
}
deleteReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.splice(index, 1);
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
}
toggleReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList[index].disabled = !replaceList[index].disabled;
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceList();
}
applySingleReplaceRule(index) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const rule = replaceList[index];
if (rule.disabled || !rule.findText) return;
const textareas = document.querySelectorAll('textarea.translation.form-control');
textareas.forEach(textarea => {
let text = textarea.value;
text = text.replaceAll(rule.findText, rule.replacementText);
this.simulateInputChange(textarea, text);
});
}
moveReplaceRule(index, direction) {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const newIndex = index + direction;
if (newIndex >= 0 && newIndex < replaceList.length) {
const [movedItem] = replaceList.splice(index, 1);
replaceList.splice(newIndex, 0, movedItem);
localStorage.setItem('replaceList', JSON.stringify(replaceList));
this.loadReplaceListWithAnimation(index, newIndex);
}
}
loadReplaceListWithAnimation(oldIndex, newIndex) {
const replaceListDiv = document.getElementById('replaceListContainer');
const items = replaceListDiv.querySelectorAll('.replace-item');
if (items[oldIndex] && items[newIndex]) {
items[oldIndex].style.transform = `translateY(${(newIndex - oldIndex) * 100}%)`;
items[newIndex].style.transform = `translateY(${(oldIndex - newIndex) * 100}%)`;
}
setTimeout(() => {
this.loadReplaceList();
}, 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);
}
exportReplaceRules() {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const json = JSON.stringify(replaceList, 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 = 'replaceList.json';
a.click();
URL.revokeObjectURL(url);
}
importReplaceRules(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
const content = e.target.result;
const importedList = JSON.parse(content);
if (Array.isArray(importedList) && importedList.every(item => typeof item.findText === 'string' && typeof item.replacementText === 'string')) {
localStorage.setItem('replaceList', JSON.stringify(importedList));
this.loadReplaceList();
} else {
alert('导入的文件格式不正确。');
}
} catch (error) {
console.error('Error importing rules:', error);
alert('导入失败,文件可能已损坏或格式不正确。');
}
};
reader.readAsText(file);
event.target.value = null;
}
}
// 定义具体的机器翻译卡片
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 py-2">
<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 p-2" style="max-height: 70vh; overflow-y: auto;">
<form id="translationConfigForm">
<div class="form-group">
<label for="baseUrl">Base URL</label>
<div class="input-group">
<input type="text" class="form-control" id="baseUrl" placeholder="Enter base URL">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" title="OpenAI API" id="openaiButton">
<img src="https://paratranz.cn/media/f2014e0647283fcff54e3a8f4edaa488.png!webp160" style="width: 16px; height: 16px;">
</button>
<button class="btn btn-outline-secondary" type="button" title="DeepSeek API" id="deepseekButton">
<img src="https://paratranz.cn/media/0bfd294f99b9141e3432c0ffbf3d8e78.png!webp160" style="width: 16px; height: 16px;">
</button>
</div>
</div>
<small id="fullUrlPreview" class="form-text text-muted mt-1" style="word-break: break-all;"></small>
</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="prompt">Prompt</label>
<textarea class="form-control" id="prompt" rows="3" placeholder="Enter prompt or use default prompt"></textarea>
</div>
<div class="form-group">
<label for="promptLibrarySelect">Prompt 库</label>
<div class="input-group">
<select class="custom-select" id="promptLibrarySelect">
<option value="" selected>从库中选择或管理...</option>
</select>
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="saveToPromptLibraryButton" title="保存当前Prompt到库"><i class="fas fa-save"></i></button>
<button class="btn btn-outline-danger" type="button" id="deleteFromPromptLibraryButton" title="从库中删除选定Prompt"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
</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>
<div class="form-group">
<label>自动化选项</label>
<div class="d-flex justify-content-between">
<div class="custom-control custom-switch mr-2">
<input type="checkbox" class="custom-control-input" id="autoTranslateToggle">
<label class="custom-control-label" for="autoTranslateToggle">自动翻译</label>
</div>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input" id="autoPasteToggle">
<label class="custom-control-label" for="autoPasteToggle">自动粘贴</label>
</div>
</div>
<small class="form-text text-muted">自动翻译:进入新条目时自动翻译 / 自动粘贴:翻译完成后自动填充到翻译框</small>
</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');
const autoTranslateToggle = document.getElementById('autoTranslateToggle');
const promptLibrarySelect = document.getElementById('promptLibrarySelect');
const saveToPromptLibraryButton = document.getElementById('saveToPromptLibraryButton');
const deleteFromPromptLibraryButton = document.getElementById('deleteFromPromptLibraryButton');
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') || '';
autoTranslateToggle.checked = localStorage.getItem('autoTranslateEnabled') === 'true';
baseUrlInput.addEventListener('input', () => {
const value = baseUrlInput.value;
localStorage.setItem('baseUrl', value);
updateFullUrlPreview(value);
});
document.getElementById('openaiButton').addEventListener('click', () => {
baseUrlInput.value = 'https://api.openai.com/v1';
localStorage.setItem('baseUrl', baseUrlInput.value);
updateFullUrlPreview(baseUrlInput.value);
});
document.getElementById('deepseekButton').addEventListener('click', () => {
baseUrlInput.value = 'https://api.deepseek.com';
localStorage.setItem('baseUrl', baseUrlInput.value);
updateFullUrlPreview(baseUrlInput.value);
});
function updateFullUrlPreview(baseUrl) {
const fullUrlPreview = document.getElementById('fullUrlPreview');
if (baseUrl) {
const fullUrl = `${baseUrl}${baseUrl.endsWith('/') ? '' : '/'}chat/completions`;
fullUrlPreview.textContent = `完整URL: ${fullUrl}`;
} else {
fullUrlPreview.textContent = '';
}
}
updateFullUrlPreview(baseUrlInput.value);
apiKeyInput.addEventListener('input', () => localStorage.setItem('apiKey', apiKeyInput.value));
modelSelect.addEventListener('input', () => localStorage.setItem('model', modelSelect.value));
promptInput.addEventListener('input', () => localStorage.setItem('prompt', promptInput.value));
temperatureInput.addEventListener('input', () => localStorage.setItem('temperature', temperatureInput.value));
autoTranslateToggle.addEventListener('change', () => localStorage.setItem('autoTranslateEnabled', autoTranslateToggle.checked));
const autoPasteToggle = document.getElementById('autoPasteToggle');
autoPasteToggle.checked = localStorage.getItem('autoPasteEnabled') === 'true';
autoPasteToggle.addEventListener('change', () => localStorage.setItem('autoPasteEnabled', autoPasteToggle.checked));
const PROMPT_LIBRARY_KEY = 'promptLibrary';
function getPromptLibrary() {
return JSON.parse(localStorage.getItem(PROMPT_LIBRARY_KEY)) || [];
}
function savePromptLibrary(library) {
localStorage.setItem(PROMPT_LIBRARY_KEY, JSON.stringify(library));
}
function populatePromptLibrarySelect() {
const library = getPromptLibrary();
promptLibrarySelect.innerHTML = '<option value="" selected>从库中选择或管理...</option>';
library.forEach((promptText) => {
const option = document.createElement('option');
option.value = promptText;
option.textContent = promptText.substring(0, 50) + (promptText.length > 50 ? '...' : '');
option.dataset.fulltext = promptText;
promptLibrarySelect.appendChild(option);
});
}
promptLibrarySelect.addEventListener('change', function() {
if (this.value) {
promptInput.value = this.value;
localStorage.setItem('prompt', this.value);
}
});
saveToPromptLibraryButton.addEventListener('click', function() {
const currentPrompt = promptInput.value.trim();
if (currentPrompt) {
let library = getPromptLibrary();
if (!library.includes(currentPrompt)) {
library.push(currentPrompt);
savePromptLibrary(library);
populatePromptLibrarySelect();
promptLibrarySelect.value = currentPrompt;
} else {
alert('此 Prompt 已存在于库中。');
}
} else {
alert('Prompt 内容不能为空。');
}
});
deleteFromPromptLibraryButton.addEventListener('click', function() {
const selectedPromptValue = promptLibrarySelect.value;
if (selectedPromptValue) {
let library = getPromptLibrary();
const indexToRemove = library.indexOf(selectedPromptValue);
if (indexToRemove > -1) {
library.splice(indexToRemove, 1);
savePromptLibrary(library);
populatePromptLibrarySelect();
if (promptInput.value === selectedPromptValue) {
promptInput.value = '';
localStorage.setItem('prompt', '');
}
}
} else {
alert('请先从库中选择一个 Prompt 进行删除。');
}
});
populatePromptLibrarySelect();
temperatureInput.addEventListener('input', function() {
localStorage.setItem('temperature', temperatureInput.value);
});
autoTranslateToggle.addEventListener('change', function() {
localStorage.setItem('autoTranslateEnabled', autoTranslateToggle.checked);
});
this.setupTranslation();
}
setupTranslation() {
const translationCache = {};
const translationsInProgress = {};
async function getCurrentStringId() {
const pathParts = window.location.pathname.split('/');
let stringId = null;
const stringsIndex = pathParts.indexOf('strings');
if (stringsIndex !== -1 && pathParts.length > stringsIndex + 1) {
const idFromPath = pathParts[stringsIndex + 1];
if (!isNaN(parseInt(idFromPath, 10))) {
stringId = idFromPath;
}
}
if (!stringId) {
const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]');
if (copyLinkButton) {
const href = copyLinkButton.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
stringId = urlParams.get('id');
} else {
const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]');
if (settingsLink) {
const href = settingsLink.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
stringId = urlParams.get('id');
}
}
}
return stringId && !isNaN(parseInt(stringId, 10)) ? stringId : null;
}
function updateTranslationUI(text, modelName, stringIdForUI) {
document.getElementById('translatedText').value = text;
if (localStorage.getItem('autoPasteEnabled') === 'true') {
const targetTextarea = document.querySelector('textarea.translation.form-control');
// 修复:仅当翻译框为空时才自动粘贴
if (targetTextarea && targetTextarea.value.trim() === '') {
simulateInputChange(targetTextarea, text);
}
}
let translationMemoryDiv = document.querySelector('.translation-memory');
let mtListContainer;
if (!translationMemoryDiv) {
const tabs = document.querySelector('.sidebar-right .tabs');
if (!tabs) {
console.error('找不到.sidebar-right .tabs元素');
return;
}
translationMemoryDiv = document.createElement('div');
translationMemoryDiv.className = 'translation-memory';
translationMemoryDiv.style.display = 'block';
const header = document.createElement('header');
header.className = 'mb-3';
const headerContent = document.createElement('div');
headerContent.className = 'row medium align-items-center';
headerContent.innerHTML = `
<div class="col-auto">
<button title="Ctrl + Shift + F" type="button" class="btn btn-secondary btn-sm">
<i class="far fa-search"></i> 搜索历史翻译
</button>
</div>
<div class="col text-right">
<span class="text-muted">共 0 条建议</span>
<button type="button" class="btn btn-secondary btn-sm"><i class="far fa-cog fa-fw"></i></button>
</div>`;
header.appendChild(headerContent);
translationMemoryDiv.appendChild(header);
mtListContainer = document.createElement('div');
mtListContainer.className = 'list mt-list';
translationMemoryDiv.appendChild(mtListContainer);
tabs.insertBefore(translationMemoryDiv, tabs.firstChild);
} else {
mtListContainer = translationMemoryDiv.querySelector('.list.mt-list');
if (!mtListContainer) {
mtListContainer = document.createElement('div');
mtListContainer.className = 'list mt-list';
const header = translationMemoryDiv.querySelector('header');
if (header) header.insertAdjacentElement('afterend', mtListContainer);
else translationMemoryDiv.appendChild(mtListContainer);
}
}
const existingAiReferences = mtListContainer.querySelectorAll('.mt-reference.paratranz-ai-reference');
existingAiReferences.forEach(ref => ref.remove());
if (mtListContainer) {
const newReferenceDiv = document.createElement('div');
newReferenceDiv.className = 'mt-reference paratranz-ai-reference';
newReferenceDiv.dataset.stringId = stringIdForUI;
const header = document.createElement('header');
header.className = 'medium mb-2 text-muted';
const icon = document.createElement('i');
icon.className = 'far fa-language';
header.appendChild(icon);
header.appendChild(document.createTextNode(' 机器翻译参考'));
newReferenceDiv.appendChild(header);
const bodyRow = document.createElement('div');
bodyRow.className = 'row align-items-center';
const colAuto = document.createElement('div');
colAuto.className = 'col-auto pr-0';
const copyButton = document.createElement('button');
copyButton.title = '复制当前文本至翻译框';
copyButton.type = 'button';
copyButton.className = 'btn btn-link';
const copyIcon = document.createElement('i');
copyIcon.className = 'far fa-clone';
copyButton.appendChild(copyIcon);
copyButton.addEventListener('click', function() {
simulateInputChange(document.querySelector('textarea.translation.form-control'), text);
});
colAuto.appendChild(copyButton);
bodyRow.appendChild(colAuto);
const colText = document.createElement('div');
colText.className = 'col';
const translationSpan = document.createElement('span');
translationSpan.className = 'translation notranslate';
translationSpan.textContent = text;
colText.appendChild(translationSpan);
bodyRow.appendChild(colText);
newReferenceDiv.appendChild(bodyRow);
const footer = document.createElement('footer');
footer.className = 'medium mt-2 text-muted';
const leftText = document.createElement('span');
leftText.textContent = 'Paratranz-AI';
const rightText = document.createElement('div');
rightText.className = 'float-right';
rightText.textContent = modelName || 'N/A';
footer.appendChild(leftText);
footer.appendChild(rightText);
newReferenceDiv.appendChild(footer);
mtListContainer.prepend(newReferenceDiv);
}
}
async function processTranslationRequest(stringIdToProcess, textToTranslate) {
const translateButtonElement = document.getElementById('translateButton');
if (!stringIdToProcess) {
console.warn('processTranslationRequest called with no stringId.');
return;
}
if (translationsInProgress[stringIdToProcess]) {
console.log(`Translation for ${stringIdToProcess} is already in progress. Ignoring new request.`);
return;
}
translationsInProgress[stringIdToProcess] = true;
if (translateButtonElement) translateButtonElement.disabled = true;
document.getElementById('translatedText').value = '翻译中...';
let translatedTextOutput = '';
try {
console.log(`Processing translation for stringId ${stringIdToProcess}:`, textToTranslate);
const model = localStorage.getItem('model') || 'gpt-4o-mini';
const promptStr = localStorage.getItem('prompt') || `You are a translator, you will translate all the message I send to you.\n\nSource Language: en\nTarget Language: zh-cn\n\nOutput result and thought with zh-cn, and keep the result pure text\nwithout any markdown syntax and any thought or references.\n\nInstructions:\n - Accuracy: Ensure the translation accurately conveys the original meaning.\n - Context: Adapt to cultural nuances and specific context to avoid misinterpretation.\n - Tone: Match the tone (formal, informal, technical) of the source text.\n - Grammar: Use correct grammar and sentence structure in the target language.\n - Readability: Ensure the translation is clear and easy to understand.\n - Keep Tags: Maintain the original tags intact, do not translate tags themselves!\n - Keep or remove the spaces around the tags based on the language manners (in CJK, usually the spaces will be removed).\n\nTags are matching the following regular expressions (one per line):\n/{\w+}/\n/%[ds]?\d/\n/\\s#\d{1,2}/\n/<[^>]+?>/\n/%{\d}[a-z]/\n/@[a-zA-Z.]+?@/`;
const temperature = parseFloat(localStorage.getItem('temperature')) || 0;
translatedTextOutput = await translateText(textToTranslate, model, promptStr, temperature);
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
replaceList.forEach(rule => {
if (!rule.disabled && rule.findText) {
translatedTextOutput = translatedTextOutput.replaceAll(rule.findText, rule.replacementText);
}
});
translationCache[stringIdToProcess] = translatedTextOutput;
const currentPageId = await getCurrentStringId();
if (currentPageId === stringIdToProcess) {
updateTranslationUI(translatedTextOutput, model, stringIdToProcess);
} else {
console.log(`Translated stringId ${stringIdToProcess}, but page is now ${currentPageId}. Reference UI not updated for ${stringIdToProcess}.`);
document.getElementById('translatedText').value = translatedTextOutput;
}
} catch (error) {
console.error(`Error during translation processing for stringId ${stringIdToProcess}:`, error);
const translatedTextArea = document.getElementById('translatedText');
if (translatedTextArea) {
translatedTextArea.value = `翻译出错 (ID: ${stringIdToProcess}): ${error.message}`;
}
} finally {
delete translationsInProgress[stringIdToProcess];
if (translateButtonElement) translateButtonElement.disabled = false;
console.log(`Translation processing for stringId ${stringIdToProcess} finished, flags reset.`);
}
}
async function updateOriginalTextAndTranslateIfNeeded() {
const currentStringId = await getCurrentStringId();
if (!currentStringId) {
return;
}
const originalDiv = document.querySelector('.original.well');
if (originalDiv) {
const originalText = originalDiv.innerText;
document.getElementById('originalText').value = originalText;
const existingAiReference = document.querySelector('.mt-reference.paratranz-ai-reference');
if (translationCache[currentStringId]) {
console.log(`Using cached translation for stringId: ${currentStringId}`);
const model = localStorage.getItem('model') || 'gpt-4o-mini';
if (existingAiReference && existingAiReference.dataset.stringId !== currentStringId) {
existingAiReference.remove();
}
updateTranslationUI(translationCache[currentStringId], model, currentStringId);
return;
} else {
if (existingAiReference) {
existingAiReference.remove();
}
}
if (localStorage.getItem('autoTranslateEnabled') === 'true' && originalText.trim() !== '' && !translationsInProgress[currentStringId]) {
console.log(`Auto-translating for stringId: ${currentStringId}`);
await processTranslationRequest(currentStringId, originalText);
} else if (translationsInProgress[currentStringId]) {
console.log(`Translation already in progress for stringId: ${currentStringId} (checked in updateOriginalText)`);
}
}
}
let debounceTimer = null;
const observer = new MutationObserver(async () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
console.log('Observer triggered, updating original text and checking translation.');
await updateOriginalTextAndTranslateIfNeeded();
}, 200);
});
const config = { childList: true, subtree: true, characterData: true };
const originalDivTarget = document.querySelector('.original.well');
if (originalDivTarget) {
observer.observe(originalDivTarget, config);
updateOriginalTextAndTranslateIfNeeded();
} else {
console.warn("Original text container (.original.well) not found at observer setup.");
}
document.getElementById('copyOriginalButton').addEventListener('click', async () => {
await updateOriginalTextAndTranslateIfNeeded();
});
document.getElementById('translateButton').addEventListener('click', async function() {
const currentStringId = await getCurrentStringId();
const originalText = document.getElementById('originalText').value;
if (!currentStringId) {
console.error('Cannot translate: No valid stringId found for manual trigger.');
return;
}
await processTranslationRequest(currentStringId, originalText);
});
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 getTermsData() {
const terms = [];
const pathParts = window.location.pathname.split('/');
let projectId = null;
let stringId = null;
const projectIndex = pathParts.indexOf('projects');
if (projectIndex !== -1 && pathParts.length > projectIndex + 1) {
projectId = pathParts[projectIndex + 1];
}
const stringsIndex = pathParts.indexOf('strings');
if (stringsIndex !== -1 && pathParts.length > stringsIndex + 1) {
const idFromPath = pathParts[stringsIndex + 1];
if (!isNaN(parseInt(idFromPath, 10))) {
stringId = idFromPath;
}
}
if (!stringId) {
const copyLinkButton = document.querySelector('.string-editor a.float-right.no-select[href*="/strings?id="]');
if (copyLinkButton) {
const href = copyLinkButton.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
const idFromHref = urlParams.get('id');
if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
stringId = idFromHref;
// console.log(`从页面 context-tab 的“复制链接”按钮获取到 stringId: ${stringId}`);
const hrefPathParts = new URL(href, window.location.origin).pathname.split('/');
const projectIdx = hrefPathParts.indexOf('projects');
if (projectIdx !== -1 && hrefPathParts.length > projectIdx + 1) {
const pidFromHref = hrefPathParts[projectIdx + 1];
if (pidFromHref && projectId !== pidFromHref) {
// console.log(`从“复制链接”的 href 中更新 projectId 从 ${projectId} 到 ${pidFromHref}`);
projectId = pidFromHref;
}
}
}
} else {
const settingsLink = document.querySelector('.string-editor .tab.context-tab a[href*="/settings/strings?id="]');
if (settingsLink) {
const href = settingsLink.getAttribute('href');
const urlParams = new URLSearchParams(href.split('?')[1]);
const idFromHref = urlParams.get('id');
if (idFromHref && !isNaN(parseInt(idFromHref, 10))) {
stringId = idFromHref;
// console.log(`从页面 context-tab 的“设置”链接获取到 stringId: ${stringId}`);
}
}
}
}
if (!projectId) {
console.warn('无法从 URL 中解析项目 ID。URL:', window.location.pathname);
return terms;
}
if (!stringId || isNaN(parseInt(stringId, 10))) {
// console.warn(`无法从 URL 或页面元素中解析有效的字符串 ID "${stringId}",跳过术语获取。`);
return terms;
}
const apiUrl = `https://paratranz.cn/api/projects/${projectId}/strings/${stringId}/terms`;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const response = await fetch(apiUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
console.error(`获取术语 API 失败: ${response.status} ${response.statusText}`);
return terms;
}
const apiResult = await response.json();
apiResult.forEach(item => {
if (item.term && item.translation) {
terms.push({
source: item.term,
target: item.translation,
note: item.note || ''
});
}
});
// console.log(`通过 API 获取到 ${terms.length} 条术语。`);
} catch (error) {
if (error.name === 'AbortError') {
console.error('获取术语 API 超时。');
} else {
console.error('调用术语 API 时发生错误:', error);
}
}
return terms;
}
async function buildTermsSystemMessageWithRetry() {
let terms = await getTermsData();
if (!terms.length) {
// console.log('第一次通过 API 获取术语表失败或为空,等待100ms后重试...');
await new Promise(resolve => setTimeout(resolve, 100));
terms = await getTermsData();
if (!terms.length) {
// console.log('第二次通过 API 获取术语表仍然失败或为空。');
return null;
}
// console.log(`第二次尝试通过 API 获取到 ${terms.length} 条术语。`);
} else {
// console.log(`第一次尝试通过 API 获取到 ${terms.length} 条术语。`);
}
const termsContext = terms.map(term => {
let termString = `${term.source} → ${term.target}`;
if (term.note) {
termString += ` (${term.note})`;
}
return termString;
}).join('\n');
return {
role: "system",
content: `翻译时请参考以下术语表:\n${termsContext}`
};
}
class PromptTagProcessor {
constructor() {
this.tagProcessors = new Map();
this.setupDefaultTags();
}
setupDefaultTags() {
this.registerTag('original', (text) => text);
this.registerTag('context', async () => {
const contextDiv = document.querySelector('.string-editor .tab.context-tab .context');
if (!contextDiv) return '';
const contextItems = Array.from(contextDiv.querySelectorAll('.context-item')).map(item => {
const textElement = item.querySelector('.text');
return textElement ? textElement.textContent.trim() : '';
});
return contextItems.filter(text => text).join('\n');
});
this.registerTag('terms', async () => {
const terms = await getTermsData();
if (!terms.length) return '';
return terms.map(term => {
let termString = `${term.source} → ${term.target}`;
if (term.note) termString += ` (${term.note})`;
return termString;
}).join('\n');
});
}
registerTag(tagName, processor) {
this.tagProcessors.set(tagName, processor);
}
async processPrompt(prompt, originalText) {
let processedPrompt = prompt;
for (const [tagName, processor] of this.tagProcessors) {
const tagPattern = new RegExp(`{{${tagName}}}`, 'g');
if (tagPattern.test(processedPrompt)) {
let replacement;
try {
replacement = (tagName === 'original') ? originalText : await processor();
processedPrompt = processedPrompt.replace(tagPattern, replacement || '');
// console.log(`替换标签 {{${tagName}}} 成功`);
} catch (error) {
console.error(`处理标签 {{${tagName}}} 时出错:`, error);
}
}
}
// console.log('处理后的prompt:', processedPrompt);
return processedPrompt;
}
}
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 tagProcessor = new PromptTagProcessor();
const processedPrompt = await tagProcessor.processPrompt(prompt, query);
const messages = [{ role: "system", content: processedPrompt }];
// console.log('准备获取术语表信息...');
const termsMessage = await buildTermsSystemMessageWithRetry();
if (termsMessage && termsMessage.content) {
// console.log('成功获取术语表信息,添加到请求中。');
messages.push(termsMessage);
} else {
// console.log('未获取到术语表信息或术语表为空,翻译请求将不包含术语表。');
}
messages.push({ role: "user", content: query });
const requestBody = { model, temperature, messages };
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25000); // 25秒超时
const response = await fetch(`${BASE_URL}${BASE_URL.endsWith('/') ? '' : '/'}chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_SECRET_KEY}` },
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
let errorData;
try { errorData = await response.json(); } catch (e) { /* ignore */ }
console.error('API Error:', errorData || response.statusText);
return `API 翻译失败: ${response.status} - ${errorData?.error?.message || errorData?.message || response.statusText}`;
}
const data = await response.json();
if (data.choices && data.choices[0]?.message?.content) {
return data.choices[0].message.content;
} else {
console.error('Invalid API response structure:', data);
return '翻译失败: API响应格式无效';
}
} catch (error) {
if (error.name === 'AbortError') {
console.error('API translation request timed out.');
return '翻译请求超时。';
}
console.error('Translation Fetch/Network Error:', error);
return `翻译请求失败: ${error.message || error.toString()}`;
}
}
function simulateInputChange(element, newValue) {
if (element.value.trim() !== '') {
// return; // Allowing overwrite now based on typical user expectation for paste
}
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 stringReplaceCard = new StringReplaceCard('#accordionExample');
const machineTranslationCard = new MachineTranslationCard('#accordionExample');
accordion.addCard(stringReplaceCard);
accordion.addCard(machineTranslationCard);
const runAllReplacementsButton = new Button(
'.btn.btn-secondary.apply-all-rules-button',
'.toolbar .right .btn-group',
'<i class="fas fa-cogs"></i> 应用全部替换',
function() {
const replaceList = JSON.parse(localStorage.getItem('replaceList')) || [];
const textareas = document.querySelectorAll('textarea.translation.form-control');
textareas.forEach(textarea => {
let text = textarea.value;
replaceList.forEach(rule => {
if (!rule.disabled && rule.findText) {
text = text.replaceAll(rule.findText, rule.replacementText);
}
});
simulateInputChange(textarea, text);
});
}
);
})();