OpenImmersive Translator
| { | ||
| "extName": { | ||
| "message": "OpenImmersive Translator" | ||
| }, | ||
| "extDesc": { | ||
| "message": "Open-source, privacy-first bilingual web translation with PDF support (layout kept, translated text selectable). Free engines built in, BYOK supported, every request goes straight from your browser — no middle server, no data collection." | ||
| }, | ||
| "popupTranslatePage": { | ||
| "message": "Translate this page" | ||
| }, | ||
| "popupRestore": { | ||
| "message": "Show original" | ||
| }, | ||
| "popupTranslateTo": { | ||
| "message": "Translate into" | ||
| }, | ||
| "popupHover": { | ||
| "message": "Hover translation (hold Alt over a paragraph)" | ||
| }, | ||
| "popupAlwaysSite": { | ||
| "message": "Always translate this site" | ||
| }, | ||
| "popupFloatBall": { | ||
| "message": "Floating button on the page" | ||
| }, | ||
| "popupAlwaysLang": { | ||
| "message": "Always translate" | ||
| }, | ||
| "popupThisLanguage": { | ||
| "message": "this language" | ||
| }, | ||
| "popupShortcuts": { | ||
| "message": "Shortcuts: <kbd>Alt+T</kbd>/<kbd>Alt+A</kbd> translate or restore the page · <kbd>Alt+W</kbd> translate the whole page · <kbd>Alt+Enter</kbd> in a text field writes it in another language" | ||
| }, | ||
| "popupPdfLink": { | ||
| "message": "Translate a PDF file →" | ||
| }, | ||
| "popupMoreSettings": { | ||
| "message": "More settings" | ||
| }, | ||
| "popupUnsupportedPage": { | ||
| "message": "This page cannot be translated" | ||
| }, | ||
| "popupTranslating": { | ||
| "message": "Translating, one moment…" | ||
| }, | ||
| "popupTranslateFailed": { | ||
| "message": "Translation failed: " | ||
| }, | ||
| "popupNoConnection": { | ||
| "message": "Cannot reach the page" | ||
| }, | ||
| "popupProgress": { | ||
| "message": "Translating… $DONE$/$TOTAL$", | ||
| "placeholders": { | ||
| "done": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "popupDoneCount": { | ||
| "message": "$COUNT$ paragraphs translated", | ||
| "placeholders": { | ||
| "count": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "popupRestored": { | ||
| "message": "Original restored" | ||
| }, | ||
| "popupReady": { | ||
| "message": "Ready" | ||
| }, | ||
| "optTitle": { | ||
| "message": "OpenImmersive Translator — Settings" | ||
| }, | ||
| "optHeading": { | ||
| "message": "OpenImmersive Translator settings" | ||
| }, | ||
| "optTagline": { | ||
| "message": "Open source · privacy first · bilingual" | ||
| }, | ||
| "optAccount": { | ||
| "message": "OpenImmersive account" | ||
| }, | ||
| "optChecking": { | ||
| "message": "Checking…" | ||
| }, | ||
| "optSignIn": { | ||
| "message": "Log in / Sign up" | ||
| }, | ||
| "optRefresh": { | ||
| "message": "Refresh status" | ||
| }, | ||
| "optAccountHint": { | ||
| "message": "The AI tiers (Standard / Premium) draw on your account balance on the website; the free Google tier needs no account. Log in or out on the website, then press Refresh status — the extension never stores your password." | ||
| }, | ||
| "optCommon": { | ||
| "message": "Everyday settings" | ||
| }, | ||
| "optTargetLang": { | ||
| "message": "Translate into (target language)" | ||
| }, | ||
| "optTargetLangHint": { | ||
| "message": "199 languages, each one tested end to end. The most common ones also appear in the popup." | ||
| }, | ||
| "optDisplayMode": { | ||
| "message": "How translations are shown" | ||
| }, | ||
| "optBilingual": { | ||
| "message": "Side by side (original + translation)" | ||
| }, | ||
| "optReplace": { | ||
| "message": "Translation only" | ||
| }, | ||
| "optDisplayHint": { | ||
| "message": "In translation-only mode the original is merely hidden — \"Show original\" brings all of it back." | ||
| }, | ||
| "optHover": { | ||
| "message": "Hover translation" | ||
| }, | ||
| "optHoverDesc": { | ||
| "message": "Hold the trigger key and move the mouse over a paragraph to translate it" | ||
| }, | ||
| "optHoldAlt": { | ||
| "message": "Hold Alt" | ||
| }, | ||
| "optHoldCtrl": { | ||
| "message": "Hold Ctrl" | ||
| }, | ||
| "optHoldShift": { | ||
| "message": "Hold Shift" | ||
| }, | ||
| "optHoldOff": { | ||
| "message": "No trigger key" | ||
| }, | ||
| "optSelection": { | ||
| "message": "Selection translation" | ||
| }, | ||
| "optSelectionDesc": { | ||
| "message": "Select any text and a translation bubble appears" | ||
| }, | ||
| "optInput": { | ||
| "message": "Text-field translation" | ||
| }, | ||
| "optInputDesc": { | ||
| "message": "Press Alt+Enter in a text field to turn what you wrote into the language selected here" | ||
| }, | ||
| "optShortcutsHint": { | ||
| "message": "Shortcuts: <b>Alt+T</b> or <b>Alt+A</b> translate/restore the page · <b>Alt+W</b> translate the page again including new content · select text to translate it · <b>Alt+Enter</b> in a text field writes it in another language · hover the floating button for the quick menu" | ||
| }, | ||
| "optAdvanced": { | ||
| "message": "Advanced (engines, API keys, redaction)" | ||
| }, | ||
| "optEngine": { | ||
| "message": "Translation engine" | ||
| }, | ||
| "optEngineFree": { | ||
| "message": "Free engines (no API key)" | ||
| }, | ||
| "optEngineGoogle": { | ||
| "message": "Google Translate ⭐ recommended (default)" | ||
| }, | ||
| "optEngineMyMemory": { | ||
| "message": "MyMemory (backup)" | ||
| }, | ||
| "optEngineLibre": { | ||
| "message": "LibreTranslate (open source, needs an instance URL)" | ||
| }, | ||
| "optEngineAiGroup": { | ||
| "message": "AI tiers (uses your website balance, no key needed)" | ||
| }, | ||
| "optEngineStandard": { | ||
| "message": "Standard — context-aware AI translation" | ||
| }, | ||
| "optEnginePremium": { | ||
| "message": "Premium — top model, professional work" | ||
| }, | ||
| "optEngineKeyGroup": { | ||
| "message": "High-quality engines (bring your own API key)" | ||
| }, | ||
| "optEngineDeepL": { | ||
| "message": "DeepL (highest quality, 500K characters/month free)" | ||
| }, | ||
| "optEngineDeepSeek": { | ||
| "message": "DeepSeek (AI translation, very low cost)" | ||
| }, | ||
| "optEngineHint": { | ||
| "message": "Free Google is the default and is enough for everyday reading. The <strong>AI tiers</strong> spend credits from your <a href=\"https://openimmersive.ai\" target=\"_blank\" rel=\"noopener\">website account</a> — log in there in this browser and they work here (the extension stores no credentials). You can also bring your own API key for the engines below." | ||
| }, | ||
| "optDeepLKey": { | ||
| "message": "DeepL API key" | ||
| }, | ||
| "optDeepLFree": { | ||
| "message": "500K characters/month free" | ||
| }, | ||
| "optDeepLHint": { | ||
| "message": "Register free at <a href=\"https://www.deepl.com/pro-api\" target=\"_blank\" rel=\"noopener\">deepl.com</a>; a key ending in \":fx\" is a free one." | ||
| }, | ||
| "optDeepSeekKey": { | ||
| "message": "DeepSeek API key" | ||
| }, | ||
| "optDeepSeekHint": { | ||
| "message": "Get one at <a href=\"https://platform.deepseek.com\" target=\"_blank\" rel=\"noopener\">platform.deepseek.com</a> — around $0.001 per thousand characters." | ||
| }, | ||
| "optOpenAIKey": { | ||
| "message": "OpenAI API key" | ||
| }, | ||
| "optOpenAIHint": { | ||
| "message": "Get one at <a href=\"https://platform.openai.com\" target=\"_blank\" rel=\"noopener\">platform.openai.com</a>; uses gpt-4o-mini." | ||
| }, | ||
| "optLibreUrl": { | ||
| "message": "LibreTranslate instance URL" | ||
| }, | ||
| "optLibreOptional": { | ||
| "message": "(leave empty for the public instance)" | ||
| }, | ||
| "optLibreHint": { | ||
| "message": "Open source and self-hostable, the most private option. See <a href=\"https://github.com/LibreTranslate/LibreTranslate\" target=\"_blank\" rel=\"noopener\">LibreTranslate</a>." | ||
| }, | ||
| "optMask": { | ||
| "message": "Sensitive-data redaction (beta)" | ||
| }, | ||
| "optMaskDesc": { | ||
| "message": "Mask emails, phone numbers, card and ID numbers and API keys locally before sending, then restore them in the translation" | ||
| }, | ||
| "optMaskHint": { | ||
| "message": "Detection and restoration happen entirely in your browser; the sensitive original never reaches a translation provider. In rare cases an engine rewrites a placeholder and that one spot cannot be restored (nothing leaks) — switch this off if you hit that." | ||
| }, | ||
| "optPdf": { | ||
| "message": "PDF translation" | ||
| }, | ||
| "optPdfHint": { | ||
| "message": "Whole PDFs with the layout preserved (including academic formulas) are translated on the website: <a href=\"https://pdf.openimmersive.ai/\" target=\"_blank\" rel=\"noopener\">pdf.openimmersive.ai →</a>" | ||
| }, | ||
| "optFile": { | ||
| "message": "Translate a file" | ||
| }, | ||
| "optFileHint": { | ||
| "message": "Supports <strong>.srt .ass .html .txt .md</strong>: parsed locally, only the words are sent for translation, and timecodes, tags and code blocks are left exactly as they were. For PDF, Word and other layout-heavy documents use the <a href=\"https://pdf.openimmersive.ai\" target=\"_blank\" rel=\"noopener\">online document translator</a>." | ||
| }, | ||
| "optPrivacyTitle": { | ||
| "message": "🔒 Privacy" | ||
| }, | ||
| "optPrivacyBody": { | ||
| "message": "Every translation request goes straight from your browser to the translation provider — no middle server, no proxy, no data collection. API keys stay on this device. The code is open source and auditable." | ||
| }, | ||
| "optSave": { | ||
| "message": "Save settings" | ||
| }, | ||
| "optSaved": { | ||
| "message": "✅ Saved" | ||
| }, | ||
| "optFooter": { | ||
| "message": "OpenImmersive Translator · AGPL-3.0 ·" | ||
| }, | ||
| "optFooterTail": { | ||
| "message": "· no data collection, no telemetry" | ||
| }, | ||
| "optShow": { | ||
| "message": "Show" | ||
| }, | ||
| "optHide": { | ||
| "message": "Hide" | ||
| }, | ||
| "optInputLangLabel": { | ||
| "message": "Write in $LANG$", | ||
| "placeholders": { | ||
| "lang": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "optAcctSoon": { | ||
| "message": "The AI tiers are coming to the extension soon (the free Google tier is unaffected)" | ||
| }, | ||
| "optAcctSignedOut": { | ||
| "message": "Not signed in — press \"Log in / Sign up\", finish on the website, then come back and refresh" | ||
| }, | ||
| "optAcctSignedIn": { | ||
| "message": "Signed in: $EMAIL$ · plan $PLAN$ · balance $CREDITS$ credits", | ||
| "placeholders": { | ||
| "email": { | ||
| "content": "$1" | ||
| }, | ||
| "plan": { | ||
| "content": "$2" | ||
| }, | ||
| "credits": { | ||
| "content": "$3" | ||
| } | ||
| } | ||
| }, | ||
| "optAcctFailed": { | ||
| "message": "Could not check status: $ERROR$", | ||
| "placeholders": { | ||
| "error": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileUnsupported": { | ||
| "message": "$EXT$ is not supported; usable formats: $LIST$", | ||
| "placeholders": { | ||
| "ext": { | ||
| "content": "$1" | ||
| }, | ||
| "list": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "fileTranslating": { | ||
| "message": "Translating…" | ||
| }, | ||
| "fileProgress": { | ||
| "message": "Translating… $DONE$/$TOTAL$", | ||
| "placeholders": { | ||
| "done": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "fileFailed": { | ||
| "message": "Translation failed, no file produced: $ERROR$", | ||
| "placeholders": { | ||
| "error": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileNoTranslation": { | ||
| "message": "the engine returned no translation at all" | ||
| }, | ||
| "fileDownload": { | ||
| "message": "⬇ Download $NAME$", | ||
| "placeholders": { | ||
| "name": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileDone": { | ||
| "message": "Done" | ||
| }, | ||
| "filePartial": { | ||
| "message": "Done ($FAILED$/$TOTAL$ segments kept their original text: $ERROR$)", | ||
| "placeholders": { | ||
| "failed": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| }, | ||
| "error": { | ||
| "content": "$3" | ||
| } | ||
| } | ||
| }, | ||
| "fileEmptyResult": { | ||
| "message": "the engine returned an empty translation" | ||
| }, | ||
| "ctxTranslateSelection": { | ||
| "message": "Translate the selected text" | ||
| }, | ||
| "ctxTranslatePage": { | ||
| "message": "Translate this page" | ||
| }, | ||
| "bubbleCopy": { | ||
| "message": "Copy" | ||
| }, | ||
| "menuSettings": { | ||
| "message": "Settings" | ||
| }, | ||
| "ballTitle": { | ||
| "message": "OpenImmersive: translate this page (Alt+T / Alt+A)" | ||
| }, | ||
| "ballLabel": { | ||
| "message": "T" | ||
| }, | ||
| "commError": { | ||
| "message": "communication error" | ||
| }, | ||
| "errLoginRequired": { | ||
| "message": "Log in at $SITE$ to use the AI tiers (the free Google tier needs no account)", | ||
| "placeholders": { | ||
| "site": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errOutOfCredits": { | ||
| "message": "Out of credits — top up or subscribe at $SITE$", | ||
| "placeholders": { | ||
| "site": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errAiSoon": { | ||
| "message": "The AI tiers are not available in the extension yet — please use the free Google tier for now" | ||
| }, | ||
| "errSiteStatus": { | ||
| "message": "The website API answered $STATUS$", | ||
| "placeholders": { | ||
| "status": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errNeedDeepLKey": { | ||
| "message": "Add a DeepL API key in settings (the free tier covers 500K characters a month)" | ||
| }, | ||
| "errNeedOpenAIKey": { | ||
| "message": "Add an OpenAI API key in settings" | ||
| }, | ||
| "errNeedDeepSeekKey": { | ||
| "message": "Add a DeepSeek API key in settings" | ||
| }, | ||
| "welcomeTitle": { | ||
| "message": "Welcome to OpenImmersive Translator" | ||
| }, | ||
| "welcomeLead": { | ||
| "message": "Installed. Three steps and you know the daily workflow — privacy first, nothing routed through a middle server." | ||
| }, | ||
| "welcomeStep1": { | ||
| "message": "Bilingual whole page" | ||
| }, | ||
| "welcomeStep1Body": { | ||
| "message": "Open any page in another language and press <kbd>Alt+T</kbd>, or click \"Translate this page\" in the toolbar popup. The translation appears under each paragraph; press again to restore the original." | ||
| }, | ||
| "welcomeStep2": { | ||
| "message": "Translate one paragraph on hover" | ||
| }, | ||
| "welcomeStep2Body": { | ||
| "message": "Not the whole page? Hold <kbd>Alt</kbd> and move the mouse over a paragraph to translate just that one. The trigger key can be changed to Ctrl or Shift in settings." | ||
| }, | ||
| "welcomeStep3": { | ||
| "message": "Selection translation & writing in another language" | ||
| }, | ||
| "welcomeStep3Body": { | ||
| "message": "Select any text and a translation bubble pops up. Writing an email in another language? Type it in your own language in the text field and press <kbd>Alt+Enter</kbd> to translate it in place (target language is configurable in settings)." | ||
| }, | ||
| "welcomeTipPin": { | ||
| "message": "💡 Pin the extension to the toolbar (puzzle icon at the top right of the browser → pin) — it is much handier that way." | ||
| }, | ||
| "welcomeEngines": { | ||
| "message": "Free engines by default, zero configuration. For higher quality, configure DeepL and others under More settings → Advanced." | ||
| }, | ||
| "welcomeStart": { | ||
| "message": "Get started" | ||
| }, | ||
| "welcomeFooter": { | ||
| "message": "Open source AGPL-3.0 ·" | ||
| }, | ||
| "welcomeFooterPdf": { | ||
| "message": "· whole-PDF translation lives at" | ||
| }, | ||
| "extDescShort": { | ||
| "message": "Open-source, privacy-first bilingual translation: whole page, selection, hover and text fields, 199 languages. No middle server." | ||
| }, | ||
| "uiLocale": { | ||
| "message": "en", | ||
| "description": "Which language this catalogue is written in; language names follow it." | ||
| } | ||
| } |
| { | ||
| "extName": { | ||
| "message": "OpenImmersive Translator" | ||
| }, | ||
| "extDesc": { | ||
| "message": "开源、隐私优先的双语网页翻译插件,支持PDF翻译(保留排版、译文可复制)。预集成免费引擎,支持BYOK,所有请求直接从浏览器发出,无中间服务器,零数据收集。" | ||
| }, | ||
| "popupTranslatePage": { | ||
| "message": "翻译此页" | ||
| }, | ||
| "popupRestore": { | ||
| "message": "恢复原文" | ||
| }, | ||
| "popupTranslateTo": { | ||
| "message": "翻译成" | ||
| }, | ||
| "popupHover": { | ||
| "message": "悬停翻译(按住 Alt 划过段落)" | ||
| }, | ||
| "popupAlwaysSite": { | ||
| "message": "总是翻译此网站" | ||
| }, | ||
| "popupFloatBall": { | ||
| "message": "页面悬浮球" | ||
| }, | ||
| "popupAlwaysLang": { | ||
| "message": "总是翻译" | ||
| }, | ||
| "popupThisLanguage": { | ||
| "message": "该语言" | ||
| }, | ||
| "popupShortcuts": { | ||
| "message": "快捷键:<kbd>Alt+T</kbd>/<kbd>Alt+A</kbd> 整页翻译或恢复 · <kbd>Alt+W</kbd> 翻译整页 · 输入框 <kbd>Alt+Enter</kbd> 译成外语" | ||
| }, | ||
| "popupPdfLink": { | ||
| "message": "翻译 PDF 文件 →" | ||
| }, | ||
| "popupMoreSettings": { | ||
| "message": "更多设置" | ||
| }, | ||
| "popupUnsupportedPage": { | ||
| "message": "此页面不支持翻译" | ||
| }, | ||
| "popupTranslating": { | ||
| "message": "翻译中,请稍候…" | ||
| }, | ||
| "popupTranslateFailed": { | ||
| "message": "翻译失败: " | ||
| }, | ||
| "popupNoConnection": { | ||
| "message": "无法连接到页面" | ||
| }, | ||
| "popupProgress": { | ||
| "message": "翻译中… $DONE$/$TOTAL$", | ||
| "placeholders": { | ||
| "done": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "popupDoneCount": { | ||
| "message": "已翻译 $COUNT$ 个段落", | ||
| "placeholders": { | ||
| "count": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "popupRestored": { | ||
| "message": "已恢复原文" | ||
| }, | ||
| "popupReady": { | ||
| "message": "准备就绪" | ||
| }, | ||
| "optTitle": { | ||
| "message": "OpenImmersive Translator - 设置" | ||
| }, | ||
| "optHeading": { | ||
| "message": "OpenImmersive Translator 设置" | ||
| }, | ||
| "optTagline": { | ||
| "message": "开源 · 隐私优先 · 双语翻译" | ||
| }, | ||
| "optAccount": { | ||
| "message": "OpenImmersive 账号" | ||
| }, | ||
| "optChecking": { | ||
| "message": "查询中…" | ||
| }, | ||
| "optSignIn": { | ||
| "message": "登录 / 注册" | ||
| }, | ||
| "optRefresh": { | ||
| "message": "刷新状态" | ||
| }, | ||
| "optAccountHint": { | ||
| "message": "AI 翻译档(Standard / Premium)使用官网账号的额度;免费 Google 档无需账号。在官网登录或登出后点「刷新状态」即可生效——插件本身不保存你的密码。" | ||
| }, | ||
| "optCommon": { | ||
| "message": "常用设置" | ||
| }, | ||
| "optTargetLang": { | ||
| "message": "翻译成(目标语言)" | ||
| }, | ||
| "optTargetLangHint": { | ||
| "message": "共 199 种语言,均已实测可用。常用的几种也会出现在插件弹窗里。" | ||
| }, | ||
| "optDisplayMode": { | ||
| "message": "译文显示方式" | ||
| }, | ||
| "optBilingual": { | ||
| "message": "双语对照(原文 + 译文)" | ||
| }, | ||
| "optReplace": { | ||
| "message": "仅显示译文" | ||
| }, | ||
| "optDisplayHint": { | ||
| "message": "仅显示译文时原文只是被隐藏,「恢复原文」可完整还原。" | ||
| }, | ||
| "optHover": { | ||
| "message": "悬停翻译" | ||
| }, | ||
| "optHoverDesc": { | ||
| "message": "按住触发键,鼠标划过段落即逐段翻译" | ||
| }, | ||
| "optHoldAlt": { | ||
| "message": "按住 Alt" | ||
| }, | ||
| "optHoldCtrl": { | ||
| "message": "按住 Ctrl" | ||
| }, | ||
| "optHoldShift": { | ||
| "message": "按住 Shift" | ||
| }, | ||
| "optHoldOff": { | ||
| "message": "关闭触发键" | ||
| }, | ||
| "optSelection": { | ||
| "message": "划词翻译" | ||
| }, | ||
| "optSelectionDesc": { | ||
| "message": "选中任意文字,弹出译文气泡" | ||
| }, | ||
| "optInput": { | ||
| "message": "输入框翻译" | ||
| }, | ||
| "optInputDesc": { | ||
| "message": "在输入框按 Alt+Enter,把你写的内容译成下面选的语言(写外语用)" | ||
| }, | ||
| "optShortcutsHint": { | ||
| "message": "快捷键:<b>Alt+T</b> 或 <b>Alt+A</b> 翻译/恢复整页 · <b>Alt+W</b> 翻译整页(补翻新内容) · 选中文字即翻 · 输入框内 <b>Alt+Enter</b> 译成外语 · 悬停悬浮球展开快捷菜单" | ||
| }, | ||
| "optAdvanced": { | ||
| "message": "高级设置(引擎、API Key、脱敏)" | ||
| }, | ||
| "optEngine": { | ||
| "message": "翻译引擎" | ||
| }, | ||
| "optEngineFree": { | ||
| "message": "免费引擎(无需 API Key)" | ||
| }, | ||
| "optEngineGoogle": { | ||
| "message": "Google 翻译 ⭐ 推荐(默认)" | ||
| }, | ||
| "optEngineMyMemory": { | ||
| "message": "MyMemory(备用)" | ||
| }, | ||
| "optEngineLibre": { | ||
| "message": "LibreTranslate(开源,需实例地址)" | ||
| }, | ||
| "optEngineAiGroup": { | ||
| "message": "AI 档(用官网账号额度,无需自备 Key)" | ||
| }, | ||
| "optEngineStandard": { | ||
| "message": "Standard — 上下文更自然的 AI 翻译" | ||
| }, | ||
| "optEnginePremium": { | ||
| "message": "Premium — 顶级模型,专业场景" | ||
| }, | ||
| "optEngineKeyGroup": { | ||
| "message": "高质量引擎(自带 API Key)" | ||
| }, | ||
| "optEngineDeepL": { | ||
| "message": "DeepL(质量最高,免费 50 万字/月)" | ||
| }, | ||
| "optEngineDeepSeek": { | ||
| "message": "DeepSeek(AI 翻译,成本极低)" | ||
| }, | ||
| "optEngineHint": { | ||
| "message": "默认用免费的 Google,够日常使用。<strong>AI 档</strong>用 <a href=\"https://openimmersive.ai\" target=\"_blank\" rel=\"noopener\">官网账号</a>的 credits,在浏览器里登录官网即可生效(插件不保存任何登录凭据);也可以自备 API Key 走下面的引擎。" | ||
| }, | ||
| "optDeepLKey": { | ||
| "message": "DeepL API Key" | ||
| }, | ||
| "optDeepLFree": { | ||
| "message": "免费 50 万字/月" | ||
| }, | ||
| "optDeepLHint": { | ||
| "message": "在 <a href=\"https://www.deepl.com/pro-api\" target=\"_blank\" rel=\"noopener\">deepl.com</a> 免费注册获取,以 \":fx\" 结尾为免费版。" | ||
| }, | ||
| "optDeepSeekKey": { | ||
| "message": "DeepSeek API Key" | ||
| }, | ||
| "optDeepSeekHint": { | ||
| "message": "在 <a href=\"https://platform.deepseek.com\" target=\"_blank\" rel=\"noopener\">platform.deepseek.com</a> 获取,成本约 $0.001/千字。" | ||
| }, | ||
| "optOpenAIKey": { | ||
| "message": "OpenAI API Key" | ||
| }, | ||
| "optOpenAIHint": { | ||
| "message": "在 <a href=\"https://platform.openai.com\" target=\"_blank\" rel=\"noopener\">platform.openai.com</a> 获取,使用 gpt-4o-mini。" | ||
| }, | ||
| "optLibreUrl": { | ||
| "message": "LibreTranslate 实例地址" | ||
| }, | ||
| "optLibreOptional": { | ||
| "message": "(留空用公共实例)" | ||
| }, | ||
| "optLibreHint": { | ||
| "message": "开源、可自托管,隐私最高。见 <a href=\"https://github.com/LibreTranslate/LibreTranslate\" target=\"_blank\" rel=\"noopener\">LibreTranslate</a>。" | ||
| }, | ||
| "optMask": { | ||
| "message": "敏感信息脱敏(Beta)" | ||
| }, | ||
| "optMaskDesc": { | ||
| "message": "发送前本地打码邮箱/电话/卡号/证件/API Key,译后自动还原" | ||
| }, | ||
| "optMaskHint": { | ||
| "message": "检测与还原全部在浏览器本地完成,敏感原文不会发往翻译服务商。极少数情况引擎改写占位符会导致该处还原失败(不泄露信息),如遇异常可关闭。" | ||
| }, | ||
| "optPdf": { | ||
| "message": "PDF 翻译" | ||
| }, | ||
| "optPdfHint": { | ||
| "message": "整份 PDF 保留排版翻译(含学术论文公式)在网站上进行:<a href=\"https://pdf.openimmersive.ai/\" target=\"_blank\" rel=\"noopener\">pdf.openimmersive.ai →</a>" | ||
| }, | ||
| "optFile": { | ||
| "message": "翻译文件" | ||
| }, | ||
| "optFileHint": { | ||
| "message": "支持 <strong>.srt .ass .html .txt .md</strong>:在本地解析,只把文字送去翻译,时间轴/标签/代码块原样保留。需要保留排版的 PDF、Word 等复杂文档,请用 <a href=\"https://pdf.openimmersive.ai\" target=\"_blank\" rel=\"noopener\">在线文档翻译</a>。" | ||
| }, | ||
| "optPrivacyTitle": { | ||
| "message": "🔒 隐私保证" | ||
| }, | ||
| "optPrivacyBody": { | ||
| "message": "所有翻译请求直接从你的浏览器发往翻译服务商,无中间服务器、无代理、无数据收集。API Key 仅存本地。代码完全开源可审计。" | ||
| }, | ||
| "optSave": { | ||
| "message": "保存设置" | ||
| }, | ||
| "optSaved": { | ||
| "message": "✅ 已保存" | ||
| }, | ||
| "optFooter": { | ||
| "message": "OpenImmersive Translator · AGPL-3.0 开源协议 ·" | ||
| }, | ||
| "optFooterTail": { | ||
| "message": "· 无数据收集,无遥测" | ||
| }, | ||
| "optShow": { | ||
| "message": "显示" | ||
| }, | ||
| "optHide": { | ||
| "message": "隐藏" | ||
| }, | ||
| "optInputLangLabel": { | ||
| "message": "译成$LANG$", | ||
| "placeholders": { | ||
| "lang": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "optAcctSoon": { | ||
| "message": "AI 翻译档即将上线,敬请期待(免费 Google 档不受影响)" | ||
| }, | ||
| "optAcctSignedOut": { | ||
| "message": "未登录 —— 点「登录 / 注册」在官网完成登录后回来刷新" | ||
| }, | ||
| "optAcctSignedIn": { | ||
| "message": "已登录:$EMAIL$ · 套餐 $PLAN$ · 余额 $CREDITS$ credits", | ||
| "placeholders": { | ||
| "email": { | ||
| "content": "$1" | ||
| }, | ||
| "plan": { | ||
| "content": "$2" | ||
| }, | ||
| "credits": { | ||
| "content": "$3" | ||
| } | ||
| } | ||
| }, | ||
| "optAcctFailed": { | ||
| "message": "状态查询失败:$ERROR$", | ||
| "placeholders": { | ||
| "error": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileUnsupported": { | ||
| "message": "不支持 $EXT$;可用格式:$LIST$", | ||
| "placeholders": { | ||
| "ext": { | ||
| "content": "$1" | ||
| }, | ||
| "list": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "fileTranslating": { | ||
| "message": "翻译中…" | ||
| }, | ||
| "fileProgress": { | ||
| "message": "翻译中… $DONE$/$TOTAL$", | ||
| "placeholders": { | ||
| "done": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| } | ||
| } | ||
| }, | ||
| "fileFailed": { | ||
| "message": "翻译失败,未生成文件:$ERROR$", | ||
| "placeholders": { | ||
| "error": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileNoTranslation": { | ||
| "message": "引擎没有返回任何译文" | ||
| }, | ||
| "fileDownload": { | ||
| "message": "⬇ 下载 $NAME$", | ||
| "placeholders": { | ||
| "name": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "fileDone": { | ||
| "message": "完成" | ||
| }, | ||
| "filePartial": { | ||
| "message": "完成($FAILED$/$TOTAL$ 段未译出,保留原文:$ERROR$)", | ||
| "placeholders": { | ||
| "failed": { | ||
| "content": "$1" | ||
| }, | ||
| "total": { | ||
| "content": "$2" | ||
| }, | ||
| "error": { | ||
| "content": "$3" | ||
| } | ||
| } | ||
| }, | ||
| "fileEmptyResult": { | ||
| "message": "引擎返回空译文" | ||
| }, | ||
| "ctxTranslateSelection": { | ||
| "message": "翻译选中文字" | ||
| }, | ||
| "ctxTranslatePage": { | ||
| "message": "翻译此页" | ||
| }, | ||
| "bubbleCopy": { | ||
| "message": "复制" | ||
| }, | ||
| "menuSettings": { | ||
| "message": "设置" | ||
| }, | ||
| "ballTitle": { | ||
| "message": "OpenImmersive:翻译此页(Alt+T / Alt+A)" | ||
| }, | ||
| "ballLabel": { | ||
| "message": "译" | ||
| }, | ||
| "commError": { | ||
| "message": "通信错误" | ||
| }, | ||
| "errLoginRequired": { | ||
| "message": "需要登录 $SITE$ 才能使用 AI 翻译档(免费的 Google 档无需登录)", | ||
| "placeholders": { | ||
| "site": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errOutOfCredits": { | ||
| "message": "额度不足,请到 $SITE$ 充值或订阅", | ||
| "placeholders": { | ||
| "site": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errAiSoon": { | ||
| "message": "AI 翻译档即将上线,暂请使用免费的 Google 档" | ||
| }, | ||
| "errSiteStatus": { | ||
| "message": "官网接口返回 $STATUS$", | ||
| "placeholders": { | ||
| "status": { | ||
| "content": "$1" | ||
| } | ||
| } | ||
| }, | ||
| "errNeedDeepLKey": { | ||
| "message": "请在设置页配置 DeepL API Key(免费版每月50万字)" | ||
| }, | ||
| "errNeedOpenAIKey": { | ||
| "message": "请在设置页配置 OpenAI API Key" | ||
| }, | ||
| "errNeedDeepSeekKey": { | ||
| "message": "请在设置页配置 DeepSeek API Key" | ||
| }, | ||
| "welcomeTitle": { | ||
| "message": "欢迎使用 OpenImmersive Translator" | ||
| }, | ||
| "welcomeLead": { | ||
| "message": "装好了!三步学会日常使用,全程隐私优先、数据不经中间服务器。" | ||
| }, | ||
| "welcomeStep1": { | ||
| "message": "整页双语对照" | ||
| }, | ||
| "welcomeStep1Body": { | ||
| "message": "打开任意外文网页,点工具栏图标里的「翻译此页」,或直接按 <kbd>Alt+T</kbd>。译文显示在原文下方,再按一次恢复原文。" | ||
| }, | ||
| "welcomeStep2": { | ||
| "message": "悬停翻译单段" | ||
| }, | ||
| "welcomeStep2Body": { | ||
| "message": "不想整页翻?按住 <kbd>Alt</kbd>,鼠标划过哪段就翻哪段。触发键可在设置里改成 Ctrl / Shift。" | ||
| }, | ||
| "welcomeStep3": { | ||
| "message": "划词翻译 & 写外语" | ||
| }, | ||
| "welcomeStep3Body": { | ||
| "message": "选中任意文字,立刻弹出译文气泡。要写外语邮件?在输入框里打好中文,按 <kbd>Alt+Enter</kbd> 就地译成英语(可在设置改目标语言)。" | ||
| }, | ||
| "welcomeTipPin": { | ||
| "message": "💡 建议把插件固定到工具栏(点浏览器右上角拼图图标 → 图钉),用起来更顺手。" | ||
| }, | ||
| "welcomeEngines": { | ||
| "message": "默认用免费引擎、零配置即用;想要更高质量翻译,可在「更多设置 → 高级」里配置 DeepL 等。" | ||
| }, | ||
| "welcomeStart": { | ||
| "message": "开始使用" | ||
| }, | ||
| "welcomeFooter": { | ||
| "message": "开源 AGPL-3.0 ·" | ||
| }, | ||
| "welcomeFooterPdf": { | ||
| "message": "· 整份 PDF 翻译在" | ||
| }, | ||
| "extDescShort": { | ||
| "message": "开源、隐私优先的双语翻译插件:整页双语对照、划词/悬停/输入框翻译、199 种语言。翻译请求直连所选引擎,无中间服务器、零数据收集。" | ||
| }, | ||
| "uiLocale": { | ||
| "message": "zh", | ||
| "description": "Which language this catalogue is written in; language names follow it." | ||
| } | ||
| } |
+106
| 'use strict'; | ||
| // 界面语言:跟随浏览器 UI 语言(chrome.i18n),中文用户看中文,其余看英文。 | ||
| // The store listing ships in English and Chinese; the UI used to be Chinese | ||
| // only, so an English user installed it and could not read a single control. | ||
| // | ||
| // UMD:三个 HTML 页面直接用,jest 里 require。 | ||
| (function (root, factory) { | ||
| if (typeof module !== 'undefined' && module.exports) module.exports = factory(); | ||
| else root.YLI18n = factory(); | ||
| })(typeof globalThis !== 'undefined' ? globalThis : this, function () { | ||
| const api = typeof browser !== 'undefined' ? browser : typeof chrome !== 'undefined' ? chrome : null; | ||
| function t(key, subs) { | ||
| const msg = api && api.i18n ? api.i18n.getMessage(key, subs) : ''; | ||
| // A missing key must be loud in development and harmless in the store | ||
| // build, so fall back to the key itself rather than an empty label. | ||
| return msg || key; | ||
| } | ||
| /** True when the catalogue in use is the Chinese one — the only place | ||
| * language *names* differ, since the table carries both spellings. Asking | ||
| * the catalogue rather than getUILanguage() keeps names and labels in the | ||
| * same language even when the browser falls back to another locale. */ | ||
| function isZh() { | ||
| return t('uiLocale') === 'zh'; | ||
| } | ||
| /** Label for a row of YLLangs.ALL: ['zh-CN', 'Simplified Chinese', '简体中文'] */ | ||
| function langLabel(row) { | ||
| if (!row) return ''; | ||
| const [, en, zh] = row; | ||
| if (!isZh()) return en; | ||
| return zh === en ? en : `${zh}(${en})`; | ||
| } | ||
| // A few messages carry inline markup — a link, a <kbd>, a bold run. Build | ||
| // those as real nodes instead of assigning innerHTML: store reviewers treat | ||
| // any innerHTML in an extension as a red flag, and this file is the only | ||
| // place we would have needed one. | ||
| const RICH_TAGS = ['a', 'b', 'strong', 'kbd', 'code', 'em']; | ||
| const RICH = new RegExp( | ||
| `<(${RICH_TAGS.join('|')})((?:\\s+[a-zA-Z-]+="[^"]*")*)\\s*>([^<]*)</\\1>`, | ||
| 'g', | ||
| ); | ||
| function renderRich(text) { | ||
| const out = []; | ||
| let last = 0; | ||
| let m; | ||
| RICH.lastIndex = 0; | ||
| while ((m = RICH.exec(text)) !== null) { | ||
| if (m.index > last) out.push(document.createTextNode(text.slice(last, m.index))); | ||
| const el = document.createElement(m[1]); | ||
| el.textContent = m[3]; | ||
| if (m[1] === 'a') { | ||
| const href = /href="([^"]*)"/.exec(m[2]); | ||
| // Only absolute http(s) links, so a bad message can never smuggle in a | ||
| // javascript: URL. | ||
| if (href && /^https?:\/\//i.test(href[1])) { | ||
| el.setAttribute('href', href[1]); | ||
| el.setAttribute('target', '_blank'); | ||
| el.setAttribute('rel', 'noopener'); | ||
| } | ||
| } | ||
| out.push(el); | ||
| last = m.index + m[0].length; | ||
| } | ||
| if (last < text.length) out.push(document.createTextNode(text.slice(last))); | ||
| return out; | ||
| } | ||
| function apply(root) { | ||
| const scope = root || document; | ||
| scope.querySelectorAll('[data-i18n]').forEach((el) => { | ||
| el.textContent = t(el.getAttribute('data-i18n')); | ||
| }); | ||
| // Sentences that carry a link or a <kbd> inside them. | ||
| scope.querySelectorAll('[data-i18n-html]').forEach((el) => { | ||
| el.textContent = ''; | ||
| renderRich(t(el.getAttribute('data-i18n-html'))).forEach((n) => el.appendChild(n)); | ||
| }); | ||
| scope.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { | ||
| el.setAttribute('placeholder', t(el.getAttribute('data-i18n-placeholder'))); | ||
| }); | ||
| scope.querySelectorAll('[data-i18n-title]').forEach((el) => { | ||
| el.setAttribute('title', t(el.getAttribute('data-i18n-title'))); | ||
| }); | ||
| // <optgroup label> — the group headings inside the engine picker. | ||
| scope.querySelectorAll('[data-i18n-label]').forEach((el) => { | ||
| el.setAttribute('label', t(el.getAttribute('data-i18n-label'))); | ||
| }); | ||
| const title = scope.querySelector('title[data-i18n-doc]'); | ||
| if (title) document.title = t(title.getAttribute('data-i18n-doc')); | ||
| } | ||
| if (typeof document !== 'undefined') { | ||
| if (document.readyState === 'loading') { | ||
| document.addEventListener('DOMContentLoaded', () => apply()); | ||
| } else { | ||
| apply(); | ||
| } | ||
| } | ||
| return { t, apply, isZh, langLabel }; | ||
| }); |
+16
-9
| 'use strict'; | ||
| // Interface language follows the browser (chrome.i18n); a missing key falls | ||
| // back to the key name so nothing renders blank. | ||
| const T = (k, subs) => { | ||
| try { return (typeof browser !== 'undefined' ? browser : chrome).i18n.getMessage(k, subs) || k; } | ||
| catch { return k; } | ||
| }; | ||
| // 语灵灵 Background Script - 翻译引擎路由 | ||
@@ -50,4 +57,4 @@ // 所有API请求直接从浏览器发出,无中间服务器 | ||
| api.contextMenus.removeAll(() => { | ||
| api.contextMenus.create({ id: 'yll-translate-selection', title: '翻译选中文字', contexts: ['selection'] }); | ||
| api.contextMenus.create({ id: 'yll-translate-page', title: '翻译此页', contexts: ['page'] }); | ||
| api.contextMenus.create({ id: 'yll-translate-selection', title: T('ctxTranslateSelection'), contexts: ['selection'] }); | ||
| api.contextMenus.create({ id: 'yll-translate-page', title: T('ctxTranslatePage'), contexts: ['page'] }); | ||
| }); | ||
@@ -170,13 +177,13 @@ } | ||
| if (resp.status === 401) { | ||
| throw new Error(`需要登录 ${site} 才能使用 AI 翻译档(免费的 Google 档无需登录)`); | ||
| throw new Error(T('errLoginRequired', [site])); | ||
| } | ||
| if (resp.status === 402) { | ||
| throw new Error(`额度不足,请到 ${site} 充值或订阅`); | ||
| throw new Error(T('errOutOfCredits', [site])); | ||
| } | ||
| if (resp.status === 404) { | ||
| // 官网新版(带账号/额度)尚未切到正式域名时,接口整个不存在 | ||
| throw new Error('AI 翻译档即将上线,暂请使用免费的 Google 档'); | ||
| throw new Error(T('errAiSoon')); | ||
| } | ||
| if (!resp.ok) { | ||
| throw new Error((data.error && data.error.message) || `官网接口返回 ${resp.status}`); | ||
| throw new Error((data.error && data.error.message) || T('errSiteStatus', [String(resp.status)])); | ||
| } | ||
@@ -241,3 +248,3 @@ return data.translatedText || ''; | ||
| async function translateDeepL(text, targetLang, sourceLang, apiKey) { | ||
| if (!apiKey) throw new Error('请在设置页配置 DeepL API Key(免费版每月50万字)'); | ||
| if (!apiKey) throw new Error(T('errNeedDeepLKey')); | ||
@@ -284,3 +291,3 @@ const body = { | ||
| async function translateOpenAI(text, targetLang, sourceLang, apiKey) { | ||
| if (!apiKey) throw new Error('请在设置页配置 OpenAI API Key'); | ||
| if (!apiKey) throw new Error(T('errNeedOpenAIKey')); | ||
| return callOpenAICompat( | ||
@@ -298,3 +305,3 @@ 'https://api.openai.com/v1/chat/completions', | ||
| async function translateDeepSeek(text, targetLang, sourceLang, apiKey) { | ||
| if (!apiKey) throw new Error('请在设置页配置 DeepSeek API Key'); | ||
| if (!apiKey) throw new Error(T('errNeedDeepSeekKey')); | ||
| return callOpenAICompat( | ||
@@ -301,0 +308,0 @@ 'https://api.deepseek.com/v1/chat/completions', |
+114
-17
@@ -7,2 +7,8 @@ 'use strict'; | ||
| (function () { | ||
| // Interface language follows the browser (chrome.i18n). A missing key falls | ||
| // back to the key name rather than a blank control. | ||
| const T = (k) => { | ||
| try { return (typeof browser !== 'undefined' ? browser : chrome).i18n.getMessage(k) || k; } | ||
| catch { return k; } | ||
| }; | ||
| if (window.__yllLoaded) return; | ||
@@ -39,5 +45,21 @@ window.__yllLoaded = true; | ||
| const SKIP_ROLES = new Set([ | ||
| 'navigation', 'menubar', 'menu', 'toolbar', 'search', 'banner' | ||
| 'navigation', 'menubar', 'menu', 'toolbar', 'search', 'banner', | ||
| // 侧栏/页脚/标签条:都是界面而非正文(X 的「For you / Following」是 tablist) | ||
| 'complementary', 'contentinfo', 'tablist', 'tab' | ||
| ]); | ||
| // ===== 站点适配 ===== | ||
| // 通用规则在少数几个站上判不准,这里按站点补一条明确的「别碰这块」。 | ||
| // X:左侧导航、右栏(趋势/推荐关注/页脚链接)都是界面,沉浸式翻译两侧也都不翻; | ||
| // 我们此前是"长词条翻、短词条不翻",看起来像随机挑着翻。 | ||
| const SITE_SKIP = { | ||
| x: '[data-testid="sidebarColumn"], [data-testid="DMDrawer"], [data-testid="primaryColumn"] [role="tablist"]', | ||
| }; | ||
| function siteKey() { | ||
| return /(^|\.)(x|twitter)\.com$/.test(location.hostname) ? 'x' : ''; | ||
| } | ||
| const SITE = siteKey(); | ||
| // ===== 消息监听(来自popup)===== | ||
@@ -161,3 +183,3 @@ api.runtime.onMessage.addListener((msg, _sender, sendResponse) => { | ||
| const rect = range.getBoundingClientRect(); | ||
| showPopup(rect, '翻译中…', true); | ||
| showPopup(rect, T('popupTranslating'), true); | ||
@@ -177,6 +199,6 @@ try { | ||
| } else { | ||
| showPopup(rect, `翻译失败: ${resp ? resp.error : '通信错误'}`, false); | ||
| showPopup(rect, T('popupTranslateFailed') + (resp ? resp.error : T('commError')), false); | ||
| } | ||
| } catch (err) { | ||
| showPopup(rect, `翻译失败: ${err.message}`, false); | ||
| showPopup(rect, T('popupTranslateFailed') + err.message, false); | ||
| } | ||
@@ -199,3 +221,3 @@ } | ||
| spin.textContent = '⟳'; | ||
| selPopup.append(spin, ' 翻译中…'); | ||
| selPopup.append(spin, ' ' + T('popupTranslating')); | ||
| selPopup.className = 'yll-popup-loading'; | ||
@@ -209,3 +231,3 @@ } else { | ||
| copy.className = 'yll-copy'; | ||
| copy.textContent = '复制'; | ||
| copy.textContent = T('bubbleCopy'); | ||
| copy.addEventListener('click', (e) => { | ||
@@ -299,8 +321,8 @@ e.preventDefault(); | ||
| }; | ||
| const toggleItem = mkItem('翻译此页', () => { | ||
| const toggleItem = mkItem(T('popupTranslatePage'), () => { | ||
| if (state.isTranslated) removeTranslations(); | ||
| else translatePage(); | ||
| }); | ||
| mkItem('翻译选中文字', () => translateCurrentSelection()); | ||
| mkItem('设置', () => { | ||
| mkItem(T('ctxTranslateSelection'), () => translateCurrentSelection()); | ||
| mkItem(T('menuSettings'), () => { | ||
| api.runtime.sendMessage({ type: 'openOptions' }).catch(() => {}); | ||
@@ -310,3 +332,3 @@ }); | ||
| ballWrap.addEventListener('mouseenter', () => { | ||
| toggleItem.textContent = state.isTranslated ? '恢复原文' : '翻译此页'; | ||
| toggleItem.textContent = state.isTranslated ? T('popupRestore') : T('popupTranslatePage'); | ||
| }); | ||
@@ -316,4 +338,4 @@ | ||
| floatBall.id = 'yll-float-ball'; | ||
| floatBall.textContent = '译'; | ||
| floatBall.title = 'OpenImmersive:翻译此页(Alt+T / Alt+A)'; | ||
| floatBall.textContent = T('ballLabel'); | ||
| floatBall.title = T('ballTitle'); | ||
| floatBall.addEventListener('click', () => { | ||
@@ -596,2 +618,56 @@ if (state.isTranslated) removeTranslations(); | ||
| // X 把一条推文的正文全放进一个 div,段落之间只靠 <br> 分隔。整块送去翻译 | ||
| // 就得到一大坨贴在推文末尾(用户报的「整篇翻译」)。按 <br> 把正文切成段、 | ||
| // 每段包一层 span 单独成块,译文才跟在各自那段后面——沉浸式翻译在 X 上就是 | ||
| // 这个粒度。包一层而不是直接翻,是为了让下游的 translateBlock/injectTranslation/ | ||
| // removeTranslations 一行都不用改。 | ||
| function splitTweetParagraphs(root) { | ||
| if (root.dataset.yllSplit) { | ||
| return Array.from(root.children).filter(c => c.dataset && c.dataset.yllPara); | ||
| } | ||
| const groups = []; | ||
| let cur = []; | ||
| for (const node of Array.from(root.childNodes)) { | ||
| if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BR') { | ||
| if (cur.length) { groups.push(cur); cur = []; } | ||
| continue; // <br> 留在原地,段与段之间的空行不能丢 | ||
| } | ||
| cur.push(node); | ||
| } | ||
| if (cur.length) groups.push(cur); | ||
| root.dataset.yllSplit = '1'; | ||
| if (groups.length < 2) return []; // 单段推文没必要切,走原来的整块路径 | ||
| const wrappers = []; | ||
| for (const nodes of groups) { | ||
| const wrap = document.createElement('span'); | ||
| wrap.setAttribute('data-yll-para', '1'); | ||
| wrap.style.display = 'block'; | ||
| root.insertBefore(wrap, nodes[0]); | ||
| for (const n of nodes) wrap.appendChild(n); | ||
| wrappers.push(wrap); | ||
| } | ||
| return wrappers; | ||
| } | ||
| function collectTweetBlocks(seen, results) { | ||
| for (const tweet of document.querySelectorAll('[data-testid="tweetText"]')) { | ||
| if (!shouldTranslate(tweet)) continue; | ||
| const paras = splitTweetParagraphs(tweet); | ||
| // 切开后原容器本身不能再当块,否则兜底分支会把它当叶子整条再翻一遍 | ||
| if (paras.length) seen.add(tweet); | ||
| // 切不动(单段)就整块当一个块,行为和以前一样 | ||
| for (const el of paras.length ? paras : [tweet]) { | ||
| if (seen.has(el)) continue; | ||
| const text = extractText(el); | ||
| if (!text || text.trim().length < 2) continue; | ||
| if (!/\p{L}/u.test(text)) continue; | ||
| seen.add(el); | ||
| results.push(el); | ||
| } | ||
| } | ||
| } | ||
| function collectBlocks() { | ||
@@ -601,2 +677,6 @@ const results = []; | ||
| // X 的正文不是 p/li,通用选择器一个都命中不到,会落到兜底的"叶子块"分支去 | ||
| // 猜——推文这种结构明确的东西不该靠猜。个人简介之类仍交给后面的通用流程。 | ||
| if (SITE === 'x') collectTweetBlocks(seen, results); | ||
| // 优先选择语义化文本块 | ||
@@ -656,12 +736,19 @@ const selector = [ | ||
| // 站点级排除区(见 SITE_SKIP) | ||
| if (SITE && SITE_SKIP[SITE] && el.closest(SITE_SKIP[SITE])) return false; | ||
| // 跳过导航、页眉、页脚等。header/footer 只在页面级(不在 article/main 里) | ||
| // 才算页头页脚——新闻站的头条区常包在文章内的 <header> 里,连坐会把 | ||
| // 正文标题整个漏掉(Reuters 频道页头条就是这样丢的)。 | ||
| const container = el.closest('nav, header, footer, aside, [role]'); | ||
| if (container) { | ||
| const tag = container.tagName; | ||
| // | ||
| // 必须逐层往上看,不能只看最近的一个:closest() 命中的常常是里层一个 | ||
| // 无害的 [role="link"]/[role="button"],外面真正的 <nav> 就再也问不到了 | ||
| //(X 几乎每个元素都套着这类 role,导航栏因此整个漏进来被翻译)。 | ||
| for (let anc = el.closest('nav, header, footer, aside, [role]'); anc; ) { | ||
| const tag = anc.tagName; | ||
| if (['NAV', 'ASIDE'].includes(tag)) return false; | ||
| if (['HEADER', 'FOOTER'].includes(tag) && !container.closest('article, main')) return false; | ||
| const role = container.getAttribute('role'); | ||
| if (['HEADER', 'FOOTER'].includes(tag) && !anc.closest('article, main')) return false; | ||
| const role = anc.getAttribute('role'); | ||
| if (role && SKIP_ROLES.has(role)) return false; | ||
| anc = anc.parentElement ? anc.parentElement.closest('nav, header, footer, aside, [role]') : null; | ||
| } | ||
@@ -846,2 +933,11 @@ | ||
| document.querySelectorAll('[data-yll-done]').forEach(el => delete el.dataset.yllDone); | ||
| // X 推文按段包的那层 span:把子节点挪回去再删壳,推文恢复成原来的样子 | ||
| document.querySelectorAll('[data-yll-para]').forEach(wrap => { | ||
| const parent = wrap.parentNode; | ||
| if (parent) { | ||
| while (wrap.firstChild) parent.insertBefore(wrap.firstChild, wrap); | ||
| } | ||
| wrap.remove(); | ||
| }); | ||
| document.querySelectorAll('[data-yll-split]').forEach(el => delete el.dataset.yllSplit); | ||
| state.isTranslated = false; | ||
@@ -918,2 +1014,3 @@ state.translatedCount = 0; | ||
| minTextLen, | ||
| splitTweetParagraphs, | ||
| setDisplayMode: (m) => { state.displayMode = m; } | ||
@@ -920,0 +1017,0 @@ }; |
+22
-5
@@ -131,2 +131,7 @@ 'use strict'; | ||
| // | ||
| // 但"保留原文"必须可见:引擎整条链路挂掉时(没配 Key、AI 档没登录、 | ||
| // 官网接口 404),每个片段都失败、每个都退回原文,最后交出一份和输入 | ||
| // 一模一样的文件还显示"完成"。所以返回值带上 failed/firstError, | ||
| // 调用方能判断"部分没翻"和"根本没翻"。 | ||
| // | ||
| // 多个片段拼成一个请求(按 \n 对齐拆回):字幕文件动辄上千行,逐行请求 | ||
@@ -155,4 +160,10 @@ // 一部 90 分钟纪录片要 6 分多钟;50 行一批直接压到半分钟内。引擎返回的 | ||
| let done = 0; | ||
| let translated = 0; | ||
| let failed = 0; | ||
| let firstError = null; | ||
| const total = idxs.length; | ||
| const queue = batches.slice(); | ||
| const noteError = (err) => { | ||
| if (!firstError) firstError = (err && err.message) || String(err); | ||
| }; | ||
| const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => { | ||
@@ -169,7 +180,12 @@ for (;;) { | ||
| batch.forEach((i, k) => { | ||
| if (lines[k] && lines[k].trim()) out[i] = keepEdgeSpace(parts[i].text, lines[k]); | ||
| if (lines[k] && lines[k].trim()) { | ||
| out[i] = keepEdgeSpace(parts[i].text, lines[k]); | ||
| translated++; | ||
| } else { | ||
| failed++; | ||
| } | ||
| }); | ||
| ok = true; | ||
| } | ||
| } catch (_) { /* 落回逐行 */ } | ||
| } catch (err) { noteError(err); /* 落回逐行 */ } | ||
| } | ||
@@ -180,4 +196,5 @@ if (!ok) { | ||
| const t = await translateFn(parts[i].text); | ||
| if (t) out[i] = keepEdgeSpace(parts[i].text, t); | ||
| } catch (_) { /* 保留原文 */ } | ||
| if (t) { out[i] = keepEdgeSpace(parts[i].text, t); translated++; } | ||
| else failed++; | ||
| } catch (err) { noteError(err); failed++; /* 保留原文 */ } | ||
| } | ||
@@ -190,3 +207,3 @@ } | ||
| await Promise.all(workers); | ||
| return join(out); | ||
| return { text: join(out), total, translated, failed, firstError }; | ||
| } | ||
@@ -193,0 +210,0 @@ |
+5
-4
| { | ||
| "manifest_version": 3, | ||
| "name": "OpenImmersive Translator", | ||
| "version": "1.13.0", | ||
| "description": "\u5f00\u6e90\u3001\u9690\u79c1\u4f18\u5148\u7684\u53cc\u8bed\u7ffb\u8bd1\u63d2\u4ef6\uff1a\u6574\u9875\u53cc\u8bed\u5bf9\u7167\u3001\u5212\u8bcd/\u60ac\u505c/\u8f93\u5165\u6846\u7ffb\u8bd1\u3001199 \u79cd\u8bed\u8a00\u3002\u7ffb\u8bd1\u8bf7\u6c42\u76f4\u8fde\u6240\u9009\u5f15\u64ce\uff0c\u65e0\u4e2d\u95f4\u670d\u52a1\u5668\u3001\u96f6\u6570\u636e\u6536\u96c6\uff1b\u53ef\u9009\u654f\u611f\u4fe1\u606f\u672c\u5730\u8131\u654f\u3002", | ||
| "name": "__MSG_extName__", | ||
| "version": "1.15.0", | ||
| "description": "__MSG_extDescShort__", | ||
| "author": "OpenImmersive", | ||
@@ -22,3 +22,3 @@ "homepage_url": "https://github.com/OpenImmersive/Open-Immersive-Translator", | ||
| "default_popup": "popup.html", | ||
| "default_title": "OpenImmersive Translator" | ||
| "default_title": "__MSG_extName__" | ||
| }, | ||
@@ -69,2 +69,3 @@ "background": { | ||
| }, | ||
| "default_locale": "en", | ||
| "browser_specific_settings": { | ||
@@ -71,0 +72,0 @@ "gecko": { |
+64
-74
@@ -6,3 +6,3 @@ <!DOCTYPE html> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>OpenImmersive Translator - 设置</title> | ||
| <title data-i18n-doc="optTitle">OpenImmersive Translator — Settings</title> | ||
| <style> | ||
@@ -125,4 +125,4 @@ * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| <div> | ||
| <h1>OpenImmersive Translator 设置</h1> | ||
| <p>开源 · 隐私优先 · 双语翻译</p> | ||
| <h1 data-i18n="optHeading">OpenImmersive Translator settings</h1> | ||
| <p data-i18n="optTagline">Open source · privacy first · bilingual</p> | ||
| </div> | ||
@@ -136,11 +136,11 @@ </div> | ||
| <div class="card"> | ||
| <div class="card-header">OpenImmersive 账号</div> | ||
| <div class="card-header" data-i18n="optAccount">OpenImmersive account</div> | ||
| <div class="card-body"> | ||
| <div class="form-group"> | ||
| <div id="oiAcctStatus" class="hint" style="font-size:14px;color:#333">查询中…</div> | ||
| <div id="oiAcctStatus" class="hint" style="font-size:14px;color:#333" data-i18n="optChecking">Checking…</div> | ||
| <div style="margin-top:10px;display:flex;gap:10px"> | ||
| <button type="button" id="btnOiLogin" class="btn-show">登录 / 注册</button> | ||
| <button type="button" id="btnOiRefresh" class="btn-show">刷新状态</button> | ||
| <button type="button" id="btnOiLogin" class="btn-show" data-i18n="optSignIn">Log in / Sign up</button> | ||
| <button type="button" id="btnOiRefresh" class="btn-show" data-i18n="optRefresh">Refresh status</button> | ||
| </div> | ||
| <p class="hint">AI 翻译档(Standard / Premium)使用官网账号的额度;免费 Google 档无需账号。在官网登录或登出后点「刷新状态」即可生效——插件本身不保存你的密码。</p> | ||
| <p class="hint" data-i18n="optAccountHint">The AI tiers (Standard / Premium) draw on your account balance on the website; the free Google tier needs no account. Log in or out on the website, then press Refresh status — the extension never stores your password.</p> | ||
| </div> | ||
@@ -152,17 +152,17 @@ </div> | ||
| <div class="card"> | ||
| <div class="card-header">常用设置</div> | ||
| <div class="card-header" data-i18n="optCommon">Everyday settings</div> | ||
| <div class="card-body"> | ||
| <div class="form-group"> | ||
| <label>翻译成(目标语言)</label> | ||
| <label data-i18n="optTargetLang">Translate into (target language)</label> | ||
| <select id="defLang"></select> | ||
| <p class="hint">共 199 种语言,均已实测可用。常用的几种也会出现在插件弹窗里。</p> | ||
| <p class="hint" data-i18n="optTargetLangHint">199 languages, each one tested end to end. The most common ones also appear in the popup.</p> | ||
| </div> | ||
| <div class="form-group"> | ||
| <label>译文显示方式</label> | ||
| <label data-i18n="optDisplayMode">How translations are shown</label> | ||
| <select id="selDisplayMode"> | ||
| <option value="bilingual">双语对照(原文 + 译文)</option> | ||
| <option value="replace">仅显示译文</option> | ||
| <option value="bilingual" data-i18n="optBilingual">Side by side (original + translation)</option> | ||
| <option value="replace" data-i18n="optReplace">Translation only</option> | ||
| </select> | ||
| <p class="hint">仅显示译文时原文只是被隐藏,「恢复原文」可完整还原。</p> | ||
| <p class="hint" data-i18n="optDisplayHint">In translation-only mode the original is merely hidden — "Show original" brings all of it back.</p> | ||
| </div> | ||
@@ -172,11 +172,11 @@ | ||
| <div> | ||
| <div class="t-label">悬停翻译</div> | ||
| <div class="t-desc">按住触发键,鼠标划过段落即逐段翻译</div> | ||
| <div class="t-label" data-i18n="optHover">Hover translation</div> | ||
| <div class="t-desc" data-i18n="optHoverDesc">Hold the trigger key and move the mouse over a paragraph to translate it</div> | ||
| </div> | ||
| <div class="t-right"> | ||
| <select id="selHoverKey"> | ||
| <option value="Alt">按住 Alt</option> | ||
| <option value="Control">按住 Ctrl</option> | ||
| <option value="Shift">按住 Shift</option> | ||
| <option value="off">关闭触发键</option> | ||
| <option value="Alt" data-i18n="optHoldAlt">Hold Alt</option> | ||
| <option value="Control" data-i18n="optHoldCtrl">Hold Ctrl</option> | ||
| <option value="Shift" data-i18n="optHoldShift">Hold Shift</option> | ||
| <option value="off" data-i18n="optHoldOff">No trigger key</option> | ||
| </select> | ||
@@ -189,4 +189,4 @@ <label class="switch"><input type="checkbox" id="swHover" /><span class="slider"></span></label> | ||
| <div> | ||
| <div class="t-label">划词翻译</div> | ||
| <div class="t-desc">选中任意文字,弹出译文气泡</div> | ||
| <div class="t-label" data-i18n="optSelection">Selection translation</div> | ||
| <div class="t-desc" data-i18n="optSelectionDesc">Select any text and a translation bubble appears</div> | ||
| </div> | ||
@@ -200,4 +200,4 @@ <div class="t-right"> | ||
| <div> | ||
| <div class="t-label">输入框翻译</div> | ||
| <div class="t-desc">在输入框按 Alt+Enter,把你写的内容译成下面选的语言(写外语用)</div> | ||
| <div class="t-label" data-i18n="optInput">Text-field translation</div> | ||
| <div class="t-desc" data-i18n="optInputDesc">Press Alt+Enter in a text field to turn what you wrote into the language selected here</div> | ||
| </div> | ||
@@ -210,5 +210,3 @@ <div class="t-right"> | ||
| <p class="hint" style="margin-top:14px"> | ||
| 快捷键:<b>Alt+T</b> 或 <b>Alt+A</b> 翻译/恢复整页 · <b>Alt+W</b> 翻译整页(补翻新内容) · 选中文字即翻 · 输入框内 <b>Alt+Enter</b> 译成外语 · 悬停悬浮球展开快捷菜单 | ||
| </p> | ||
| <p class="hint" style="margin-top:14px" data-i18n-html="optShortcutsHint"></p> | ||
| </div> | ||
@@ -219,61 +217,57 @@ </div> | ||
| <details class="card"> | ||
| <summary>高级设置(引擎、API Key、脱敏)</summary> | ||
| <summary data-i18n="optAdvanced">Advanced (engines, API keys, redaction)</summary> | ||
| <div class="card-body"> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">翻译引擎</div> | ||
| <div class="sub-title" data-i18n="optEngine">Translation engine</div> | ||
| <select id="defEngine"> | ||
| <optgroup label="免费引擎(无需 API Key)"> | ||
| <option value="google">Google 翻译 ⭐ 推荐(默认)</option> | ||
| <option value="mymemory">MyMemory(备用)</option> | ||
| <option value="libretranslate">LibreTranslate(开源,需实例地址)</option> | ||
| <optgroup data-i18n-label="optEngineFree" label="免费引擎(无需 API Key)"> | ||
| <option value="google" data-i18n="optEngineGoogle">Google Translate ⭐ recommended (default)</option> | ||
| <option value="mymemory" data-i18n="optEngineMyMemory">MyMemory (backup)</option> | ||
| <option value="libretranslate" data-i18n="optEngineLibre">LibreTranslate (open source, needs an instance URL)</option> | ||
| </optgroup> | ||
| <optgroup label="AI 档(用官网账号额度,无需自备 Key)"> | ||
| <option value="oi-standard">Standard — 上下文更自然的 AI 翻译</option> | ||
| <option value="oi-premium">Premium — 顶级模型,专业场景</option> | ||
| <optgroup data-i18n-label="optEngineAiGroup" label="AI 档(用官网账号额度,无需自备 Key)"> | ||
| <option value="oi-standard" data-i18n="optEngineStandard">Standard — context-aware AI translation</option> | ||
| <option value="oi-premium" data-i18n="optEnginePremium">Premium — top model, professional work</option> | ||
| </optgroup> | ||
| <optgroup label="高质量引擎(自带 API Key)"> | ||
| <option value="deepl">DeepL(质量最高,免费 50 万字/月)</option> | ||
| <option value="deepseek">DeepSeek(AI 翻译,成本极低)</option> | ||
| <optgroup data-i18n-label="optEngineKeyGroup" label="高质量引擎(自带 API Key)"> | ||
| <option value="deepl" data-i18n="optEngineDeepL">DeepL (highest quality, 500K characters/month free)</option> | ||
| <option value="deepseek" data-i18n="optEngineDeepSeek">DeepSeek (AI translation, very low cost)</option> | ||
| <option value="openai">OpenAI GPT-4o-mini</option> | ||
| </optgroup> | ||
| </select> | ||
| <p class="hint"> | ||
| 默认用免费的 Google,够日常使用。<strong>AI 档</strong>用 | ||
| <a href="https://openimmersive.ai" target="_blank" rel="noopener">官网账号</a>的 credits, | ||
| 在浏览器里登录官网即可生效(插件不保存任何登录凭据);也可以自备 API Key 走下面的引擎。 | ||
| </p> | ||
| <p class="hint" data-i18n-html="optEngineHint"></p> | ||
| </div> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">DeepL API Key <span class="label-hint">免费 50 万字/月</span></div> | ||
| <div class="sub-title"><span data-i18n="optDeepLKey">DeepL API key</span> <span class="label-hint" data-i18n="optDeepLFree">500K characters/month free</span></div> | ||
| <div class="input-group"> | ||
| <input type="password" id="keyDeepL" placeholder="xxxxxxxx-xxxx-...:fx" /> | ||
| <button class="btn-show" data-target="keyDeepL">显示</button> | ||
| <button class="btn-show" data-i18n="optShow" data-target="keyDeepL">Show</button> | ||
| </div> | ||
| <p class="hint">在 <a href="https://www.deepl.com/pro-api" target="_blank" rel="noopener">deepl.com</a> 免费注册获取,以 ":fx" 结尾为免费版。</p> | ||
| <p class="hint" data-i18n-html="optDeepLHint"></p> | ||
| </div> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">DeepSeek API Key</div> | ||
| <div class="sub-title" data-i18n="optDeepSeekKey">DeepSeek API key</div> | ||
| <div class="input-group"> | ||
| <input type="password" id="keyDeepSeek" placeholder="sk-..." /> | ||
| <button class="btn-show" data-target="keyDeepSeek">显示</button> | ||
| <button class="btn-show" data-i18n="optShow" data-target="keyDeepSeek">Show</button> | ||
| </div> | ||
| <p class="hint">在 <a href="https://platform.deepseek.com" target="_blank" rel="noopener">platform.deepseek.com</a> 获取,成本约 $0.001/千字。</p> | ||
| <p class="hint" data-i18n-html="optDeepSeekHint"></p> | ||
| </div> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">OpenAI API Key</div> | ||
| <div class="sub-title" data-i18n="optOpenAIKey">OpenAI API key</div> | ||
| <div class="input-group"> | ||
| <input type="password" id="keyOpenAI" placeholder="sk-..." /> | ||
| <button class="btn-show" data-target="keyOpenAI">显示</button> | ||
| <button class="btn-show" data-i18n="optShow" data-target="keyOpenAI">Show</button> | ||
| </div> | ||
| <p class="hint">在 <a href="https://platform.openai.com" target="_blank" rel="noopener">platform.openai.com</a> 获取,使用 gpt-4o-mini。</p> | ||
| <p class="hint" data-i18n-html="optOpenAIHint"></p> | ||
| </div> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">LibreTranslate 实例地址 <span class="label-hint">(留空用公共实例)</span></div> | ||
| <div class="sub-title"><span data-i18n="optLibreUrl">LibreTranslate instance URL</span> <span class="label-hint" data-i18n="optLibreOptional">(leave empty for the public instance)</span></div> | ||
| <input type="text" id="libreUrl" placeholder="https://libretranslate.com" /> | ||
| <p class="hint">开源、可自托管,隐私最高。见 <a href="https://github.com/LibreTranslate/LibreTranslate" target="_blank" rel="noopener">LibreTranslate</a>。</p> | ||
| <p class="hint" data-i18n-html="optLibreHint"></p> | ||
| </div> | ||
@@ -284,4 +278,4 @@ | ||
| <div> | ||
| <div class="t-label">敏感信息脱敏(Beta)</div> | ||
| <div class="t-desc">发送前本地打码邮箱/电话/卡号/证件/API Key,译后自动还原</div> | ||
| <div class="t-label" data-i18n="optMask">Sensitive-data redaction (beta)</div> | ||
| <div class="t-desc" data-i18n="optMaskDesc">Mask emails, phone numbers, card and ID numbers and API keys locally before sending, then restore them in the translation</div> | ||
| </div> | ||
@@ -292,8 +286,8 @@ <div class="t-right"> | ||
| </div> | ||
| <p class="hint">检测与还原全部在浏览器本地完成,敏感原文不会发往翻译服务商。极少数情况引擎改写占位符会导致该处还原失败(不泄露信息),如遇异常可关闭。</p> | ||
| <p class="hint" data-i18n="optMaskHint">Detection and restoration happen entirely in your browser; the sensitive original never reaches a translation provider. In rare cases an engine rewrites a placeholder and that one spot cannot be restored (nothing leaks) — switch this off if you hit that.</p> | ||
| </div> | ||
| <div class="sub-block"> | ||
| <div class="sub-title">PDF 翻译</div> | ||
| <p class="hint">整份 PDF 保留排版翻译(含学术论文公式)在网站上进行:<a href="https://pdf.openimmersive.ai/" target="_blank" rel="noopener">pdf.openimmersive.ai →</a></p> | ||
| <div class="sub-title" data-i18n="optPdf">PDF translation</div> | ||
| <p class="hint" data-i18n-html="optPdfHint"></p> | ||
| </div> | ||
@@ -306,9 +300,5 @@ | ||
| <div class="card"> | ||
| <div class="card-header">翻译文件</div> | ||
| <div class="card-header" data-i18n="optFile">Translate a file</div> | ||
| <div class="card-body"> | ||
| <p class="hint" style="margin-top:0"> | ||
| 支持 <strong>.srt .ass .html .txt .md</strong>:在本地解析,只把文字送去翻译, | ||
| 时间轴/标签/代码块原样保留。需要保留排版的 PDF、Word 等复杂文档, | ||
| 请用 <a href="https://pdf.openimmersive.ai" target="_blank" rel="noopener">在线文档翻译</a>。 | ||
| </p> | ||
| <p class="hint" style="margin-top:0" data-i18n-html="optFileHint"></p> | ||
| <input type="file" id="fileInput" accept=".srt,.ass,.html,.txt,.md" /> | ||
@@ -324,5 +314,4 @@ <div id="fileStatus" class="hint" style="margin-top:8px"></div> | ||
| <div class="privacy-info"> | ||
| <strong>🔒 隐私保证</strong> | ||
| 所有翻译请求直接从你的浏览器发往翻译服务商,无中间服务器、无代理、无数据收集。API Key 仅存本地。代码完全开源可审计。 | ||
| </div> | ||
| <strong data-i18n="optPrivacyTitle">🔒 Privacy</strong> | ||
| <span data-i18n="optPrivacyBody">Every translation request goes straight from your browser to the translation provider — no middle server, no proxy, no data collection. API keys stay on this device. The code is open source and auditable.</span></div> | ||
| </div> | ||
@@ -335,4 +324,4 @@ </div> | ||
| <div class="container"> | ||
| <button class="btn-save" id="btnSave">保存设置</button> | ||
| <span class="save-msg" id="saveMsg">✅ 已保存</span> | ||
| <button class="btn-save" id="btnSave" data-i18n="optSave">Save settings</button> | ||
| <span class="save-msg" id="saveMsg" data-i18n="optSaved">✅ Saved</span> | ||
| </div> | ||
@@ -342,7 +331,8 @@ </div> | ||
| <div class="page-footer"> | ||
| OpenImmersive Translator · AGPL-3.0 开源协议 · | ||
| <span data-i18n="optFooter">OpenImmersive Translator · AGPL-3.0 ·</span> | ||
| <a href="https://github.com/OpenImmersive/Open-Immersive-Translator" target="_blank" rel="noopener">GitHub</a> | ||
| · 无数据收集,无遥测 | ||
| <span data-i18n="optFooterTail">· no data collection, no telemetry</span> | ||
| </div> | ||
| <script src="i18n.js"></script> | ||
| <script src="languages.js"></script> | ||
@@ -349,0 +339,0 @@ <script src="file-translate.js"></script> |
+30
-18
| 'use strict'; | ||
| const api = typeof browser !== 'undefined' ? browser : chrome; | ||
| const t = (k, ...subs) => YLI18n.t(k, subs.length ? subs.map(String) : undefined); | ||
@@ -16,4 +17,4 @@ // ===== 加载设置 ===== | ||
| o.value = code; | ||
| const label = zh === en ? en : `${zh}(${en})`; | ||
| o.textContent = toInput ? `译成${zh === en ? en : zh}` : label; | ||
| const label = YLI18n.langLabel([code, en, zh]); | ||
| o.textContent = toInput ? t('optInputLangLabel', YLI18n.isZh() ? zh : en) : label; | ||
| sel.appendChild(o); | ||
@@ -86,6 +87,6 @@ } | ||
| input.type = 'text'; | ||
| btn.textContent = '隐藏'; | ||
| btn.textContent = t('optHide'); | ||
| } else { | ||
| input.type = 'password'; | ||
| btn.textContent = '显示'; | ||
| btn.textContent = t('optShow'); | ||
| } | ||
@@ -112,19 +113,19 @@ }); | ||
| const el = document.getElementById('oiAcctStatus'); | ||
| el.textContent = '查询中…'; | ||
| el.textContent = t('optChecking'); | ||
| try { | ||
| const resp = await api.runtime.sendMessage({ type: 'oiAuthStatus' }); | ||
| if (!resp || !resp.success) throw new Error(resp ? resp.error : '通信错误'); | ||
| if (!resp || !resp.success) throw new Error(resp ? resp.error : t('commError')); | ||
| if (resp.site) oiSiteUrl = resp.site; | ||
| if (!resp.available) { | ||
| el.textContent = 'AI 翻译档即将上线,敬请期待(免费 Google 档不受影响)'; | ||
| el.textContent = t('optAcctSoon'); | ||
| return; | ||
| } | ||
| if (!resp.user) { | ||
| el.textContent = '未登录 —— 点「登录 / 注册」在官网完成登录后回来刷新'; | ||
| el.textContent = t('optAcctSignedOut'); | ||
| return; | ||
| } | ||
| const u = resp.user; | ||
| el.textContent = `已登录:${u.email} · 套餐 ${u.plan || 'free'} · 余额 ${u.credits ?? 0} credits`; | ||
| el.textContent = t('optAcctSignedIn', u.email, u.plan || 'free', u.credits ?? 0); | ||
| } catch (err) { | ||
| el.textContent = `状态查询失败:${err.message}`; | ||
| el.textContent = t('optAcctFailed', err.message); | ||
| } | ||
@@ -157,3 +158,3 @@ } | ||
| if (!YLFile.FORMATS.includes(format)) { | ||
| fileStatus.textContent = `不支持 .${format};可用格式:${YLFile.FORMATS.join(' / ')}`; | ||
| fileStatus.textContent = t('fileUnsupported', `.${format}`, YLFile.FORMATS.join(' / ')); | ||
| return; | ||
@@ -166,9 +167,9 @@ } | ||
| fileStatus.textContent = '翻译中…'; | ||
| fileStatus.textContent = t('fileTranslating'); | ||
| fileDownload.style.display = 'none'; | ||
| const translated = await YLFile.translateFile({ | ||
| const result = await YLFile.translateFile({ | ||
| format, | ||
| text, | ||
| onProgress: (done, total) => { fileStatus.textContent = `翻译中… ${done}/${total}`; }, | ||
| onProgress: (done, total) => { fileStatus.textContent = t('fileProgress', done, total); }, | ||
| translateFn: async (segment) => { | ||
@@ -178,3 +179,3 @@ const resp = await api.runtime.sendMessage({ | ||
| }); | ||
| if (!resp || !resp.success) throw new Error(resp ? resp.error : '通信错误'); | ||
| if (!resp || !resp.success) throw new Error(resp ? resp.error : t('commError')); | ||
| return resp.translation; | ||
@@ -184,11 +185,22 @@ }, | ||
| const blob = new Blob([translated], { type: 'text/plain;charset=utf-8' }); | ||
| // 一段都没译成 = 引擎那条链路整个是坏的(没配 Key、AI 档没登录、接口 404)。 | ||
| // 这时给下载只会让用户拿到一份和原文一样的文件还以为翻过了,所以直接报错, | ||
| // 并把引擎的原话带出来——那句话通常就写着该去做什么。 | ||
| if (result.total > 0 && result.translated === 0) { | ||
| fileStatus.textContent = t('fileFailed', result.firstError || t('fileNoTranslation')); | ||
| fileInput.value = ''; | ||
| return; | ||
| } | ||
| const blob = new Blob([result.text], { type: 'text/plain;charset=utf-8' }); | ||
| const base = file.name.replace(/\.[^.]+$/, ''); | ||
| fileDownload.href = URL.createObjectURL(blob); | ||
| fileDownload.download = `${base}.${targetLang}.${format}`; | ||
| fileDownload.textContent = `⬇ 下载 ${fileDownload.download}`; | ||
| fileDownload.textContent = t('fileDownload', fileDownload.download); | ||
| fileDownload.style.display = 'inline-block'; | ||
| fileStatus.textContent = '完成'; | ||
| fileStatus.textContent = result.failed > 0 | ||
| ? t('filePartial', result.failed, result.total, result.firstError || t('fileEmptyResult')) | ||
| : t('fileDone'); | ||
| fileInput.value = ''; | ||
| }); | ||
| } |
+12
-11
@@ -6,3 +6,3 @@ <!DOCTYPE html> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>OpenImmersive Translator</title> | ||
| <title data-i18n-doc="extName">OpenImmersive Translator</title> | ||
| <style> | ||
@@ -115,6 +115,6 @@ * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| <img src="icons/icon-48.png" alt="" /> | ||
| <span class="header-title">OpenImmersive Translator</span> | ||
| <span class="header-title" data-i18n="extName">OpenImmersive Translator</span> | ||
| </div> | ||
| <button class="main-btn" id="btnTranslate">翻译此页</button> | ||
| <button class="main-btn" id="btnTranslate" data-i18n="popupTranslatePage">Translate this page</button> | ||
@@ -128,3 +128,3 @@ <div class="status" id="status"> | ||
| <div class="row"> | ||
| <span class="row-label">翻译成</span> | ||
| <span class="row-label" data-i18n="popupTranslateTo">Translate into</span> | ||
| <!-- 常用语言在此,全部 199 种在设置页 --> | ||
@@ -134,15 +134,15 @@ <select id="selLang"></select> | ||
| <div class="row"> | ||
| <span class="row-label">悬停翻译(按住 Alt 划过段落)</span> | ||
| <span class="row-label" data-i18n="popupHover">Hover translation (hold Alt over a paragraph)</span> | ||
| <label class="switch"><input type="checkbox" id="swHover" /><span class="slider"></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <span class="row-label">总是翻译此网站</span> | ||
| <span class="row-label" data-i18n="popupAlwaysSite">Always translate this site</span> | ||
| <label class="switch"><input type="checkbox" id="swAutoSite" /><span class="slider"></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <span class="row-label">页面悬浮球</span> | ||
| <span class="row-label" data-i18n="popupFloatBall">Floating button on the page</span> | ||
| <label class="switch"><input type="checkbox" id="swFloatBall" /><span class="slider"></span></label> | ||
| </div> | ||
| <div class="row"> | ||
| <span class="row-label">总是翻译<span id="autoLangName">该语言</span></span> | ||
| <span class="row-label"><span data-i18n="popupAlwaysLang">Always translate</span> <span id="autoLangName">this language</span></span> | ||
| <label class="switch"><input type="checkbox" id="swAutoLang" /><span class="slider"></span></label> | ||
@@ -152,3 +152,3 @@ </div> | ||
| <div class="hint">快捷键:<kbd>Alt+T</kbd>/<kbd>Alt+A</kbd> 整页翻译或恢复 · <kbd>Alt+W</kbd> 翻译整页 · 输入框 <kbd>Alt+Enter</kbd> 译成外语</div> | ||
| <div class="hint" data-i18n-html="popupShortcuts"></div> | ||
@@ -158,6 +158,7 @@ <hr /> | ||
| <div class="footer"> | ||
| <a id="btnPdf" href="#">翻译 PDF 文件 →</a> | ||
| <a id="btnOptions" href="#">更多设置</a> | ||
| <a id="btnPdf" href="#" data-i18n="popupPdfLink">Translate a PDF file →</a> | ||
| <a id="btnOptions" href="#" data-i18n="popupMoreSettings">More settings</a> | ||
| </div> | ||
| <script src="i18n.js"></script> | ||
| <script src="languages.js"></script> | ||
@@ -164,0 +165,0 @@ <script src="popup.js"></script> |
+19
-14
| 'use strict'; | ||
| const api = typeof browser !== 'undefined' ? browser : chrome; | ||
| const t = (k, ...subs) => YLI18n.t(k, subs.length ? subs.map(String) : undefined); | ||
@@ -27,3 +28,3 @@ const btnTranslate = document.getElementById('btnTranslate'); | ||
| o.value = code; | ||
| o.textContent = row[2] === row[1] ? row[1] : `${row[2]}(${row[1]})`; | ||
| o.textContent = YLI18n.langLabel(row); | ||
| selLang.appendChild(o); | ||
@@ -35,3 +36,7 @@ }; | ||
| const LANG_LABELS = { zh: '中文', ja: '日语', ko: '韩语', en: '英语', ru: '俄语', ar: '阿拉伯语', he: '希伯来语', el: '希腊语', th: '泰语', hi: '印地语', fr: '法语', de: '德语', es: '西班牙语', pt: '葡萄牙语', it: '意大利语' }; | ||
| // 「总是翻译该语言」里那个语种名:查语言表拿当前界面语言下的写法。 | ||
| function langName(code) { | ||
| const row = YLLangs.ALL.find(r => r[0] === code || r[0].split('-')[0] === code); | ||
| return row ? (YLI18n.isZh() ? row[2] : row[1]) : code; | ||
| } | ||
| let engine = 'google'; // 引擎收进「更多设置」,popup 只读取、不展示 | ||
@@ -62,3 +67,3 @@ | ||
| } catch { currentPageLang = ''; } | ||
| autoLangName.textContent = currentPageLang ? (LANG_LABELS[currentPageLang] || currentPageLang) : '该语言'; | ||
| autoLangName.textContent = currentPageLang ? langName(currentPageLang) : t('popupThisLanguage'); | ||
| swAutoLang.checked = !!currentPageLang && langs.includes(currentPageLang); | ||
@@ -71,3 +76,3 @@ swAutoLang.disabled = !currentPageLang; | ||
| } catch { | ||
| setStatus('dot-off', '此页面不支持翻译'); | ||
| setStatus('dot-off', t('popupUnsupportedPage')); | ||
| btnTranslate.disabled = true; | ||
@@ -85,3 +90,3 @@ } | ||
| btnTranslate.disabled = true; | ||
| setStatus('dot-loading', '翻译中,请稍候…'); | ||
| setStatus('dot-loading', t('popupTranslating')); | ||
| try { | ||
@@ -91,3 +96,3 @@ const resp = await api.tabs.sendMessage(currentTab.id, { type: 'translatePage', targetLang, engine }); | ||
| } catch (err) { | ||
| setStatus('dot-off', '翻译失败: ' + err.message); | ||
| setStatus('dot-off', t('popupTranslateFailed') + err.message); | ||
| } finally { | ||
@@ -151,6 +156,6 @@ btnTranslate.disabled = false; | ||
| function updateUI(resp) { | ||
| if (!resp) { setStatus('dot-off', '无法连接到页面'); return; } | ||
| if (!resp) { setStatus('dot-off', t('popupNoConnection')); return; } | ||
| if (resp.isTranslating) { | ||
| setStatus('dot-loading', `翻译中… ${resp.translatedCount || 0}/${resp.totalCount || 0}`); | ||
| setStatus('dot-loading', t('popupProgress', resp.translatedCount || 0, resp.totalCount || 0)); | ||
| btnTranslate.disabled = true; | ||
@@ -161,4 +166,4 @@ return; | ||
| const count = resp.count || resp.translatedCount || 0; | ||
| setStatus('dot-on', `已翻译 ${count} 个段落`); | ||
| btnTranslate.textContent = '恢复原文'; | ||
| setStatus('dot-on', t('popupDoneCount', count)); | ||
| btnTranslate.textContent = t('popupRestore'); | ||
| btnTranslate.classList.add('active'); | ||
@@ -168,9 +173,9 @@ return; | ||
| if (resp.status === 'removed') { | ||
| setStatus('dot-off', '已恢复原文'); | ||
| btnTranslate.textContent = '翻译此页'; | ||
| setStatus('dot-off', t('popupRestored')); | ||
| btnTranslate.textContent = t('popupTranslatePage'); | ||
| btnTranslate.classList.remove('active'); | ||
| return; | ||
| } | ||
| setStatus('dot-off', '准备就绪'); | ||
| btnTranslate.textContent = '翻译此页'; | ||
| setStatus('dot-off', t('popupReady')); | ||
| btnTranslate.textContent = t('popupTranslatePage'); | ||
| btnTranslate.classList.remove('active'); | ||
@@ -177,0 +182,0 @@ } |
+13
-12
@@ -6,3 +6,3 @@ <!DOCTYPE html> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>欢迎使用 OpenImmersive Translator</title> | ||
| <title data-i18n-doc="welcomeTitle">Welcome to OpenImmersive Translator</title> | ||
| <style> | ||
@@ -56,3 +56,3 @@ * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| <h1>OpenImmersive Translator</h1> | ||
| <p>装好了!三步学会日常使用,全程隐私优先、数据不经中间服务器。</p> | ||
| <p data-i18n="welcomeLead">Installed. Three steps and you know the daily workflow — privacy first, nothing routed through a middle server.</p> | ||
| </div> | ||
@@ -64,4 +64,4 @@ | ||
| <div> | ||
| <h3>整页双语对照</h3> | ||
| <p>打开任意外文网页,点工具栏图标里的「翻译此页」,或直接按 <kbd>Alt</kbd>+<kbd>T</kbd>。译文显示在原文下方,再按一次恢复原文。</p> | ||
| <h3 data-i18n="welcomeStep1">Bilingual whole page</h3> | ||
| <p data-i18n-html="welcomeStep1Body"></p> | ||
| </div> | ||
@@ -72,4 +72,4 @@ </div> | ||
| <div> | ||
| <h3>悬停翻译单段</h3> | ||
| <p>不想整页翻?<b>按住 <kbd>Alt</kbd></b>,鼠标划过哪段就翻哪段。触发键可在设置里改成 Ctrl / Shift。</p> | ||
| <h3 data-i18n="welcomeStep2">Translate one paragraph on hover</h3> | ||
| <p data-i18n-html="welcomeStep2Body"></p> | ||
| </div> | ||
@@ -80,4 +80,4 @@ </div> | ||
| <div> | ||
| <h3>划词翻译 & 写外语</h3> | ||
| <p>选中任意文字,立刻弹出译文气泡。要写外语邮件?在输入框里打好中文,按 <kbd>Alt</kbd>+<kbd>Enter</kbd> 就地译成英语(可在设置改目标语言)。</p> | ||
| <h3 data-i18n="welcomeStep3">Selection translation & writing in another language</h3> | ||
| <p data-i18n-html="welcomeStep3Body"></p> | ||
| </div> | ||
@@ -88,17 +88,18 @@ </div> | ||
| <div class="tip"> | ||
| 💡 建议把插件<b>固定到工具栏</b>(点浏览器右上角拼图图标 → 图钉),用起来更顺手。<br /> | ||
| 默认用免费引擎、零配置即用;想要更高质量翻译,可在「更多设置 → 高级」里配置 DeepL 等。 | ||
| <span data-i18n="welcomeTipPin">💡 Pin the extension to the toolbar (puzzle icon at the top right of the browser → pin) — it is much handier that way.</span><br /> | ||
| <span data-i18n="welcomeEngines">Free engines by default, zero configuration. For higher quality, configure DeepL and others under More settings → Advanced.</span> | ||
| </div> | ||
| <div class="cta"> | ||
| <button id="btnStart">开始使用</button> | ||
| <button id="btnStart" data-i18n="welcomeStart">Get started</button> | ||
| </div> | ||
| <div class="foot"> | ||
| 开源 AGPL-3.0 · <a href="https://github.com/OpenImmersive/Open-Immersive-Translator" target="_blank" rel="noopener">GitHub</a> · 整份 PDF 翻译在 <a href="https://pdf.openimmersive.ai/" target="_blank" rel="noopener">pdf.openimmersive.ai</a> | ||
| <span data-i18n="welcomeFooter">Open source AGPL-3.0 ·</span> <a href="https://github.com/OpenImmersive/Open-Immersive-Translator" target="_blank" rel="noopener">GitHub</a> <span data-i18n="welcomeFooterPdf">· whole-PDF translation lives at</span> <a href="https://pdf.openimmersive.ai/" target="_blank" rel="noopener">pdf.openimmersive.ai</a> | ||
| </div> | ||
| </div> | ||
| <script src="i18n.js"></script> | ||
| <script src="welcome.js"></script> | ||
| </body> | ||
| </html> |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet