From c430585df409576894daa1f460fce191fb2a2d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BD=D0=BE=D1=84=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=81?= Date: Tue, 4 Aug 2026 00:27:17 +0300 Subject: [PATCH] feat(i18n): move all UI strings and prompts to chrome.i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension had two parallel translation mechanisms: an inline dictionary in i18n.js and _locales/*/messages.json. Only the manifest and the context menu read _locales, so most of its keys were dead, the two catalogues had already drifted apart, and everything the extension drew on the page was hard-coded Chinese. Everything now reads from _locales via chrome.i18n.getMessage: - i18n.js becomes a thin wrapper over chrome.i18n and keeps the data-i18n attribute API; i18nGet now supports substitutions and returns the key when a message is missing, so gaps stay visible - localize content.js, pdf-content.js, popup.js, prompt-manager.js, popup.html, viewer.js, translator.js and background.js - localize the system prompts, so the prompt sent to the model follows the extension's UI language - grow the catalogue from 51 to 143 keys, identical across en, zh_CN, ja and ko Fixes found along the way: - popup.js and prompt-manager.js are classic scripts in one document, so a top-level `const t` in both would have thrown a redeclaration SyntaxError and broken the popup; they call i18nGet directly - viewer.js derived its toggle state from the button label, which the localization itself would have broken; use an explicit flag - viewer.html was missing the Русский option that getDefaultTargetLang can return, leaving the select empty on a ru locale - align aboutTitle on "Easy Translator" and split shortcutHint, which had drifted between the two catalogues Language option values stay as they are: they are endonyms and double as the language name sent to the model, so changing them would break stored settings. Co-Authored-By: Claude Opus 5 --- _locales/en/messages.json | 220 +++++++++++++++++++++++++++++++++-- _locales/ja/messages.json | 220 +++++++++++++++++++++++++++++++++-- _locales/ko/messages.json | 220 +++++++++++++++++++++++++++++++++-- _locales/zh_CN/messages.json | 218 ++++++++++++++++++++++++++++++++-- background.js | 4 +- content/content.js | 76 ++++++------ content/pdf-content.js | 29 +++-- i18n.js | 135 +++------------------ lib/translator.js | 40 ++++--- popup/popup.html | 30 ++--- popup/popup.js | 30 ++--- popup/prompt-manager.js | 22 ++-- viewer/viewer.html | 2 + viewer/viewer.js | 51 ++++---- 14 files changed, 1011 insertions(+), 286 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 75116cc..8c43028 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,9 +1,11 @@ { "extName": { "message": "Easy Translator" }, "extDesc": { "message": "Translate web pages, PDF, PPT, DOCX files with custom LLM backends" }, + "tabTranslate": { "message": "Translate" }, "tabSettings": { "message": "Settings" }, "tabAbout": { "message": "About" }, + "translatePage": { "message": "Translate Entire Page" }, "translatePageDesc": { "message": "Replace all page text with translations" }, "translateFile": { "message": "Translate Current File" }, @@ -13,41 +15,235 @@ "autoDetect": { "message": "Auto Detect" }, "translateBtn": { "message": "Translate" }, "swapLang": { "message": "Swap languages" }, + + "modelConfigTitle": { "message": "Model Configuration" }, "providerLabel": { "message": "API Provider" }, + "providerOpenAI": { "message": "OpenAI-compatible (OpenAI / DeepSeek / Qwen / Zhipu, etc.)" }, + "providerOllama": { "message": "Ollama (local models)" }, + "ollamaHintPre": { "message": "Start Ollama with the environment variable" }, + "ollamaHintPost": { "message": "set, otherwise requests are blocked by CORS" }, "apiUrlLabel": { "message": "API URL" }, "apiKeyLabel": { "message": "API Key" }, + "showHide": { "message": "Show/Hide" }, "modelLabel": { "message": "Model Name" }, + "refreshModels": { "message": "Refresh Models" }, "styleLabel": { "message": "Translation Style" }, "styleNatural": { "message": "Natural & Fluent" }, "styleFormal": { "message": "Formal & Professional" }, + + "customPromptLabel": { "message": "Custom Prompt" }, + "promptDefaultOption": { "message": "Default (generated from translation style)" }, + "promptPlaceholder": { "message": "You are a professional translator..." }, + "promptDefaultPlaceholder": { "message": "Uses the default prompt (generated from \"Translation Style\")" }, + "promptSaveNew": { "message": "Save as new template" }, + "promptUpdate": { "message": "Update current template" }, + "promptDelete": { "message": "Delete" }, + "promptVarsLabel": { "message": "Variables:" }, + "promptVarSrc": { "message": "source language" }, + "promptVarTgt": { "message": "target language" }, + "promptBatchNote": { "message": "The [#N] marker instruction is appended automatically for batch translation" }, + "promptNamePrompt": { "message": "Template name:" }, + "promptDefaultName": { + "message": "Custom $N$", + "placeholders": { "n": { "content": "$1" } } + }, + "promptConfirmDelete": { "message": "Delete this template?" }, + "statusPromptEmpty": { "message": "Enter prompt content first" }, + "statusPromptSaved": { "message": "✓ New template saved" }, + "statusPromptUpdated": { "message": "✓ Template updated" }, + "statusPromptDeleted": { "message": "✓ Template deleted" }, + "pageTranslateLabel": { "message": "Page Translation" }, - "shortcutHint": { "message": "Select text and press Alt+T for quick translation" }, + "shortcutLabel": { "message": "Shortcut" }, + "shortcutHint": { "message": "Select text and press" }, + "shortcutHint2": { "message": "for quick translation" }, "displayModeLabel": { "message": "Display Mode" }, "displayModeTooltip": { "message": "Floating Tooltip" }, "displayModeInline": { "message": "Replace Inline" }, "displayModeSidebar": { "message": "Sidebar" }, "testBtn": { "message": "Test Connection" }, "saveBtn": { "message": "Save Settings" }, - "aboutTitle": { "message": "Universal Translator v1.0.0" }, + + "aboutTitle": { "message": "Easy Translator v1.0.0" }, "aboutDesc": { "message": "Translate web pages, PDF, PPT, DOCX and more." }, "aboutModels": { "message": "Supports multiple LLM backends:" }, + "aboutModelOpenAI": { "message": "OpenAI GPT family" }, + "aboutModelAnthropic": { "message": "Anthropic Claude family" }, + "aboutModelGemini": { "message": "Google Gemini family" }, + "aboutModelOthers": { "message": "DeepSeek, Qwen, Zhipu and others" }, "aboutUsage": { "message": "How to use:" }, "aboutUsage1": { "message": "Select text → Right-click → Translate" }, "aboutUsage2": { "message": "Select text → Press Alt+T" }, "aboutUsage3": { "message": "Click extension icon to translate input text" }, "aboutPDF": { "message": "PDF: Opens automatically in the translation viewer" }, - "modelConfigTitle": { "message": "Model Configuration" }, - "showHide": { "message": "Show/Hide" }, - "refreshModels": { "message": "Refresh Models" }, - "shortcutLabel": { "message": "Shortcut" }, + + "statusFetchingModels": { "message": "Fetching model list..." }, + "statusModelsUpdated": { "message": "Model list updated" }, + "statusModelsDefault": { "message": "Loaded the default model list" }, + "statusNeedApiKey": { "message": "Please enter an API Key" }, + "statusTesting": { "message": "Testing connection..." }, + "statusTestOk": { + "message": "✓ Connected! Test translation: $RESULT$", + "placeholders": { "result": { "content": "$1" } } + }, + "statusTestFail": { + "message": "✗ Connection failed: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "statusSettingsSaved": { "message": "✓ Settings saved" }, + "statusInjectFailed": { "message": "Could not inject the translation script" }, + "statusNotAFile": { "message": "The current page is not a PDF or Office file" }, + "btnTranslating": { "message": "Translating..." }, + "btnTesting": { "message": "Testing..." }, + "contextMenuSelection": { "message": "Translate Selected Text" }, "contextMenuPage": { "message": "Translate Entire Page" }, + + "confirmReload": { "message": "The translator extension has been updated. Reload the page to continue?" }, + "floatBtnTranslate": { "message": "Translate" }, + "tooltipTitle": { "message": "Translation" }, + "loading": { "message": "Translating..." }, + "actionCopy": { "message": "Copy" }, + "actionSwitch": { "message": "Switch language" }, + "translateFailed": { + "message": "Translation failed: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "sidebarOriginal": { "message": "Original" }, + "sidebarTranslation": { "message": "Translation" }, + "alertNeedApiKey": { "message": "Please configure an API Key first (click the extension icon → Settings)" }, + + "bannerPreparing": { "message": "Preparing..." }, + "bannerScanning": { "message": "Scanning page text..." }, + "bannerNoText": { "message": "No translatable text found" }, + "bannerCacheLoaded": { "message": "Cached translation loaded" }, + "bannerCacheLoadedCount": { + "message": "Cached translation loaded ($COUNT$ segments)", + "placeholders": { "count": { "content": "$1" } } + }, + "bannerProgressStart": { + "message": "Translating... 0/$TOTAL$ (cached $CACHED$, pending $TOTAL2$)", + "placeholders": { + "total": { "content": "$1" }, + "cached": { "content": "$2" }, + "total2": { "content": "$3" } + } + }, + "bannerProgress": { + "message": "Translating... $DONE$/$TOTAL$ ($CHUNK$/$CHUNKS$ blocks)", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" }, + "chunk": { "content": "$3" }, + "chunks": { "content": "$4" } + } + }, + "bannerDone": { + "message": "Translation complete! $DONE$/$TOTAL$ segments", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "bannerStopped": { "message": "Translation stopped" }, + "bannerReverted": { "message": "Original text restored" }, + "bannerShowingTranslation": { "message": "Showing translation" }, + "titleClose": { "message": "Close" }, + "btnStop": { "message": "Stop" }, + "titleStop": { "message": "Stop translation" }, + "btnShowTranslation": { "message": "Show translation" }, + "btnShowOriginal": { "message": "Original" }, + + "pdfBtnTranslate": { "message": "Translate PDF" }, + "pdfBtnTranslateThis": { "message": "Translate this PDF" }, + "pdfNoText": { "message": "Could not extract text from this PDF. It may be a scan or an encrypted document." }, + "pdfFailedMark": { "message": "[translation failed]" }, + "pdfPanelTitle": { "message": "PDF Translation" }, + "pdfPreparing": { "message": "Preparing translation..." }, + "pdfProgress": { + "message": "Progress: $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "pdfDone": { "message": "✅ Translation complete" }, + "viewerTitle": { "message": "PDF Translator" }, "viewerTranslate": { "message": "Translate" }, - "viewerToggleOrig": { "message": "Show Original" }, - "viewerToggleTrans": { "message": "Show Translation" }, - "viewerSourceLang": { "message": "Source" }, - "viewerTargetLang": { "message": "Target" }, - "viewerPageOf": { "message": "of" }, - "viewerPages": { "message": "pages" } + "viewerToggleOrig": { "message": "Original" }, + "viewerToggleTrans": { "message": "Translation" }, + "viewerMissingParam": { "message": "Missing file parameter" }, + "viewerNoPdfData": { "message": "Could not load PDF data" }, + "viewerParseFailed": { + "message": "Failed to parse PDF: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "viewerRendering": { + "message": "Rendering... $PAGE$/$TOTAL$", + "placeholders": { + "page": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTranslatingProgress": { + "message": "Translating... $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTransFailed": { "message": "(translation failed)" }, + "viewerLoadingFile": { "message": "Loading file..." }, + "viewerNoFileData": { "message": "Could not load file data" }, + "viewerEmptyFile": { "message": "The file is empty" }, + "viewerSegments": { + "message": "$COUNT$ segments", + "placeholders": { "count": { "content": "$1" } } + }, + "viewerPagesCount": { + "message": "$COUNT$ pages", + "placeholders": { "count": { "content": "$1" } } + }, + + "promptSrcAuto": { "message": "source text" }, + "promptBatch": { + "message": "You are a professional translator. Translate the following $SRC$ into $TGT$. Every paragraph in the text starts with a \"[#number]\" marker. Translate each paragraph one by one and keep the \"[#number]\" markers and the paragraph order exactly as they are. Do not merge or skip any paragraph. Return only the translation, without any extra content.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptFormal": { + "message": "You are a professional translator. Translate the following $SRC$ into $TGT$. Use a formal, professional tone. Return only the translation, without any extra content.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptNatural": { + "message": "You are a professional translator. Translate the following $SRC$ into $TGT$. Use natural, fluent everyday language. Return only the translation, without any extra content.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptBatchSuffix": { "message": "Every paragraph in the text starts with a \"[#number]\" marker. Translate each paragraph one by one and keep the \"[#number]\" markers and the paragraph order exactly as they are. Do not merge or skip any paragraph." }, + + "errNoApiKey": { "message": "Please configure an API Key first" }, + "errNoText": { "message": "No text to translate" }, + "errApi": { + "message": "API error ($STATUS$): $BODY$", + "placeholders": { + "status": { "content": "$1" }, + "body": { "content": "$2" } + } + }, + "errOllamaConnect": { "message": "Cannot reach Ollama — make sure it is running (ollama serve)" }, + "errOllamaCors": { "message": "Ollama rejected the extension request (CORS). Start Ollama with the environment variable OLLAMA_ORIGINS=* set" }, + "errPdfNoText": { "message": "Could not extract text from the PDF, or the PDF is a scan" }, + "errPdfFailed": { + "message": "PDF processing failed: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + } } diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 8b562f5..a47a543 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -1,9 +1,11 @@ { "extName": { "message": "Easy Translator" }, "extDesc": { "message": "ウェブページ、PDF、PPT、DOCXファイルをカスタムLLMで翻訳" }, + "tabTranslate": { "message": "翻訳" }, "tabSettings": { "message": "設定" }, "tabAbout": { "message": "概要" }, + "translatePage": { "message": "ページ全体を翻訳" }, "translatePageDesc": { "message": "ページ上のすべてのテキストを翻訳に置き換え" }, "translateFile": { "message": "現在のファイルを翻訳" }, @@ -13,41 +15,235 @@ "autoDetect": { "message": "自動検出" }, "translateBtn": { "message": "翻訳" }, "swapLang": { "message": "言語を入れ替え" }, + + "modelConfigTitle": { "message": "モデル設定" }, "providerLabel": { "message": "API プロバイダ" }, + "providerOpenAI": { "message": "OpenAI 互換 (OpenAI / DeepSeek / Qwen / Zhipu など)" }, + "providerOllama": { "message": "Ollama (ローカルモデル)" }, + "ollamaHintPre": { "message": "環境変数" }, + "ollamaHintPost": { "message": "を設定してから Ollama を起動してください。設定しないと CORS でブロックされます" }, "apiUrlLabel": { "message": "API URL" }, "apiKeyLabel": { "message": "API キー" }, + "showHide": { "message": "表示/非表示" }, "modelLabel": { "message": "モデル名" }, + "refreshModels": { "message": "モデル更新" }, "styleLabel": { "message": "翻訳スタイル" }, "styleNatural": { "message": "自然で流暢" }, "styleFormal": { "message": "フォーマル・専門的" }, + + "customPromptLabel": { "message": "カスタムプロンプト" }, + "promptDefaultOption": { "message": "デフォルト (翻訳スタイルから自動生成)" }, + "promptPlaceholder": { "message": "あなたはプロの翻訳者です..." }, + "promptDefaultPlaceholder": { "message": "デフォルトのプロンプトを使用(「翻訳スタイル」から自動生成)" }, + "promptSaveNew": { "message": "新しいテンプレートとして保存" }, + "promptUpdate": { "message": "現在のテンプレートを更新" }, + "promptDelete": { "message": "削除" }, + "promptVarsLabel": { "message": "変数:" }, + "promptVarSrc": { "message": "翻訳元の言語" }, + "promptVarTgt": { "message": "翻訳先の言語" }, + "promptBatchNote": { "message": "一括翻訳時には [#番号] マーカーの説明が自動的に追加されます" }, + "promptNamePrompt": { "message": "テンプレート名:" }, + "promptDefaultName": { + "message": "カスタム $N$", + "placeholders": { "n": { "content": "$1" } } + }, + "promptConfirmDelete": { "message": "このテンプレートを削除しますか?" }, + "statusPromptEmpty": { "message": "先にプロンプトの内容を入力してください" }, + "statusPromptSaved": { "message": "✓ 新しいテンプレートを保存しました" }, + "statusPromptUpdated": { "message": "✓ テンプレートを更新しました" }, + "statusPromptDeleted": { "message": "✓ テンプレートを削除しました" }, + "pageTranslateLabel": { "message": "ページ翻訳" }, - "shortcutHint": { "message": "テキストを選択して Alt+T でクイック翻訳" }, + "shortcutLabel": { "message": "ショートカット" }, + "shortcutHint": { "message": "テキストを選択して" }, + "shortcutHint2": { "message": "でクイック翻訳" }, "displayModeLabel": { "message": "表示モード" }, "displayModeTooltip": { "message": "フローティングツールチップ" }, "displayModeInline": { "message": "インライン置換" }, "displayModeSidebar": { "message": "サイドバー" }, "testBtn": { "message": "接続テスト" }, "saveBtn": { "message": "設定を保存" }, - "aboutTitle": { "message": "万能翻訳機 v1.0.0" }, + + "aboutTitle": { "message": "Easy Translator v1.0.0" }, "aboutDesc": { "message": "ウェブページ、PDF、PPT、DOCXなどを翻訳します。" }, "aboutModels": { "message": "複数のLLMバックエンドに対応:" }, + "aboutModelOpenAI": { "message": "OpenAI GPT シリーズ" }, + "aboutModelAnthropic": { "message": "Anthropic Claude シリーズ" }, + "aboutModelGemini": { "message": "Google Gemini シリーズ" }, + "aboutModelOthers": { "message": "DeepSeek、Qwen、Zhipu など" }, "aboutUsage": { "message": "使い方:" }, "aboutUsage1": { "message": "テキストを選択 → 右クリック → 翻訳" }, "aboutUsage2": { "message": "テキストを選択 → Alt+T を押す" }, "aboutUsage3": { "message": "拡張機能アイコンをクリックしてテキストを入力" }, "aboutPDF": { "message": "PDF:自動的に翻訳ビューアーで開きます" }, - "modelConfigTitle": { "message": "モデル設定" }, - "showHide": { "message": "表示/非表示" }, - "refreshModels": { "message": "モデル更新" }, - "shortcutLabel": { "message": "ショートカット" }, + + "statusFetchingModels": { "message": "モデル一覧を取得中..." }, + "statusModelsUpdated": { "message": "モデル一覧を更新しました" }, + "statusModelsDefault": { "message": "デフォルトのモデル一覧を読み込みました" }, + "statusNeedApiKey": { "message": "API キーを入力してください" }, + "statusTesting": { "message": "接続をテスト中..." }, + "statusTestOk": { + "message": "✓ 接続成功!テスト翻訳: $RESULT$", + "placeholders": { "result": { "content": "$1" } } + }, + "statusTestFail": { + "message": "✗ 接続失敗: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "statusSettingsSaved": { "message": "✓ 設定を保存しました" }, + "statusInjectFailed": { "message": "翻訳スクリプトを挿入できません" }, + "statusNotAFile": { "message": "現在のページは PDF または Office ファイルではありません" }, + "btnTranslating": { "message": "翻訳中..." }, + "btnTesting": { "message": "テスト中..." }, + "contextMenuSelection": { "message": "選択テキストを翻訳" }, "contextMenuPage": { "message": "ページ全体を翻訳" }, + + "confirmReload": { "message": "翻訳拡張機能が更新されました。続けて使うにはページを再読み込みする必要があります。再読み込みしますか?" }, + "floatBtnTranslate": { "message": "翻訳" }, + "tooltipTitle": { "message": "翻訳結果" }, + "loading": { "message": "翻訳中..." }, + "actionCopy": { "message": "コピー" }, + "actionSwitch": { "message": "言語を切り替え" }, + "translateFailed": { + "message": "翻訳に失敗しました: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "sidebarOriginal": { "message": "原文" }, + "sidebarTranslation": { "message": "訳文" }, + "alertNeedApiKey": { "message": "先に API キーを設定してください(拡張機能アイコン → 設定)" }, + + "bannerPreparing": { "message": "準備中..." }, + "bannerScanning": { "message": "ページのテキストをスキャン中..." }, + "bannerNoText": { "message": "翻訳できるテキストが見つかりません" }, + "bannerCacheLoaded": { "message": "キャッシュされた翻訳を読み込みました" }, + "bannerCacheLoadedCount": { + "message": "キャッシュされた翻訳を読み込みました ($COUNT$ 段落)", + "placeholders": { "count": { "content": "$1" } } + }, + "bannerProgressStart": { + "message": "翻訳中... 0/$TOTAL$ (キャッシュ $CACHED$, 未翻訳 $TOTAL2$)", + "placeholders": { + "total": { "content": "$1" }, + "cached": { "content": "$2" }, + "total2": { "content": "$3" } + } + }, + "bannerProgress": { + "message": "翻訳中... $DONE$/$TOTAL$ ($CHUNK$/$CHUNKS$ ブロック)", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" }, + "chunk": { "content": "$3" }, + "chunks": { "content": "$4" } + } + }, + "bannerDone": { + "message": "翻訳完了!$DONE$/$TOTAL$ 段落", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "bannerStopped": { "message": "翻訳を停止しました" }, + "bannerReverted": { "message": "原文に戻しました" }, + "bannerShowingTranslation": { "message": "訳文を表示中" }, + "titleClose": { "message": "閉じる" }, + "btnStop": { "message": "停止" }, + "titleStop": { "message": "翻訳を停止" }, + "btnShowTranslation": { "message": "訳文を表示" }, + "btnShowOriginal": { "message": "原文" }, + + "pdfBtnTranslate": { "message": "PDFを翻訳" }, + "pdfBtnTranslateThis": { "message": "このPDFを翻訳" }, + "pdfNoText": { "message": "この PDF からテキストを抽出できません。スキャン文書または暗号化された文書の可能性があります。" }, + "pdfFailedMark": { "message": "[翻訳失敗]" }, + "pdfPanelTitle": { "message": "PDF 翻訳" }, + "pdfPreparing": { "message": "翻訳の準備中..." }, + "pdfProgress": { + "message": "翻訳の進捗: $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "pdfDone": { "message": "✅ 翻訳完了" }, + "viewerTitle": { "message": "PDF 翻訳" }, "viewerTranslate": { "message": "翻訳" }, - "viewerToggleOrig": { "message": "原文表示" }, - "viewerToggleTrans": { "message": "訳文表示" }, - "viewerSourceLang": { "message": "翻訳元" }, - "viewerTargetLang": { "message": "翻訳先" }, - "viewerPageOf": { "message": "全" }, - "viewerPages": { "message": "ページ" } + "viewerToggleOrig": { "message": "原文" }, + "viewerToggleTrans": { "message": "訳文" }, + "viewerMissingParam": { "message": "ファイルパラメータがありません" }, + "viewerNoPdfData": { "message": "PDF データを読み込めません" }, + "viewerParseFailed": { + "message": "PDF の解析に失敗しました: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "viewerRendering": { + "message": "レンダリング中... $PAGE$/$TOTAL$ ページ", + "placeholders": { + "page": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTranslatingProgress": { + "message": "翻訳中... $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTransFailed": { "message": "(翻訳失敗)" }, + "viewerLoadingFile": { "message": "ファイルを読み込み中..." }, + "viewerNoFileData": { "message": "ファイルデータを読み込めません" }, + "viewerEmptyFile": { "message": "ファイルの内容が空です" }, + "viewerSegments": { + "message": "全 $COUNT$ 段落", + "placeholders": { "count": { "content": "$1" } } + }, + "viewerPagesCount": { + "message": "全 $COUNT$ ページ", + "placeholders": { "count": { "content": "$1" } } + }, + + "promptSrcAuto": { "message": "原文" }, + "promptBatch": { + "message": "あなたはプロの翻訳者です。以下の$SRC$を$TGT$に翻訳してください。テキストの各段落は「[#番号]」マーカーで始まります。各段落を一つずつ翻訳し、「[#番号]」マーカーと段落の順序は厳密にそのまま保ってください。段落を結合したり省略したりしないでください。翻訳結果のみを返し、余計な内容は含めないでください。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptFormal": { + "message": "あなたはプロの翻訳者です。以下の$SRC$を$TGT$に翻訳してください。フォーマルで専門的な文体を使用してください。翻訳結果のみを返し、余計な内容は含めないでください。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptNatural": { + "message": "あなたはプロの翻訳者です。以下の$SRC$を$TGT$に翻訳してください。自然で流暢な口語表現を使用してください。翻訳結果のみを返し、余計な内容は含めないでください。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptBatchSuffix": { "message": "テキストの各段落は「[#番号]」マーカーで始まります。各段落を一つずつ翻訳し、「[#番号]」マーカーと段落の順序は厳密にそのまま保ってください。段落を結合したり省略したりしないでください。" }, + + "errNoApiKey": { "message": "先に API キーを設定してください" }, + "errNoText": { "message": "翻訳するテキストがありません" }, + "errApi": { + "message": "API エラー ($STATUS$): $BODY$", + "placeholders": { + "status": { "content": "$1" }, + "body": { "content": "$2" } + } + }, + "errOllamaConnect": { "message": "Ollama に接続できません。Ollama が起動しているか確認してください(ollama serve)" }, + "errOllamaCors": { "message": "Ollama が拡張機能のリクエストを拒否しました(CORS)。Ollama の起動時に環境変数 OLLAMA_ORIGINS=* を設定してください" }, + "errPdfNoText": { "message": "PDF からテキストを抽出できません。またはスキャン文書です" }, + "errPdfFailed": { + "message": "PDF の処理に失敗しました: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + } } diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 87ec839..11c2f18 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -1,9 +1,11 @@ { "extName": { "message": "Easy Translator" }, "extDesc": { "message": "웹페이지, PDF, PPT, DOCX 파일을 커스텀 LLM으로 번역" }, + "tabTranslate": { "message": "번역" }, "tabSettings": { "message": "설정" }, "tabAbout": { "message": "정보" }, + "translatePage": { "message": "전체 페이지 번역" }, "translatePageDesc": { "message": "페이지의 모든 텍스트를 번역문으로 교체" }, "translateFile": { "message": "현재 파일 번역" }, @@ -13,41 +15,235 @@ "autoDetect": { "message": "자동 감지" }, "translateBtn": { "message": "번역" }, "swapLang": { "message": "언어 교환" }, + + "modelConfigTitle": { "message": "모델 설정" }, "providerLabel": { "message": "API 제공자" }, + "providerOpenAI": { "message": "OpenAI 호환 (OpenAI / DeepSeek / Qwen / Zhipu 등)" }, + "providerOllama": { "message": "Ollama (로컬 모델)" }, + "ollamaHintPre": { "message": "환경 변수" }, + "ollamaHintPost": { "message": "를 설정한 뒤 Ollama를 실행하세요. 그렇지 않으면 CORS로 차단됩니다" }, "apiUrlLabel": { "message": "API URL" }, "apiKeyLabel": { "message": "API 키" }, + "showHide": { "message": "표시/숨기기" }, "modelLabel": { "message": "모델 이름" }, + "refreshModels": { "message": "모델 새로고침" }, "styleLabel": { "message": "번역 스타일" }, "styleNatural": { "message": "자연스럽고 유창하게" }, "styleFormal": { "message": "격식 있고 전문적으로" }, + + "customPromptLabel": { "message": "사용자 지정 프롬프트" }, + "promptDefaultOption": { "message": "기본값 (번역 스타일에서 자동 생성)" }, + "promptPlaceholder": { "message": "당신은 전문 번역가입니다..." }, + "promptDefaultPlaceholder": { "message": "기본 프롬프트 사용(\"번역 스타일\"에서 자동 생성)" }, + "promptSaveNew": { "message": "새 템플릿으로 저장" }, + "promptUpdate": { "message": "현재 템플릿 업데이트" }, + "promptDelete": { "message": "삭제" }, + "promptVarsLabel": { "message": "변수:" }, + "promptVarSrc": { "message": "원본 언어" }, + "promptVarTgt": { "message": "대상 언어" }, + "promptBatchNote": { "message": "일괄 번역 시 [#번호] 마커 설명이 자동으로 추가됩니다" }, + "promptNamePrompt": { "message": "템플릿 이름:" }, + "promptDefaultName": { + "message": "사용자 지정 $N$", + "placeholders": { "n": { "content": "$1" } } + }, + "promptConfirmDelete": { "message": "이 템플릿을 삭제할까요?" }, + "statusPromptEmpty": { "message": "먼저 프롬프트 내용을 입력하세요" }, + "statusPromptSaved": { "message": "✓ 새 템플릿을 저장했습니다" }, + "statusPromptUpdated": { "message": "✓ 템플릿을 업데이트했습니다" }, + "statusPromptDeleted": { "message": "✓ 템플릿을 삭제했습니다" }, + "pageTranslateLabel": { "message": "페이지 번역" }, - "shortcutHint": { "message": "텍스트 선택 후 Alt+T로 빠른 번역" }, + "shortcutLabel": { "message": "단축키" }, + "shortcutHint": { "message": "텍스트 선택 후" }, + "shortcutHint2": { "message": "로 빠른 번역" }, "displayModeLabel": { "message": "표시 모드" }, "displayModeTooltip": { "message": "플로팅 툴팁" }, "displayModeInline": { "message": "인라인 교체" }, "displayModeSidebar": { "message": "사이드바" }, "testBtn": { "message": "연결 테스트" }, "saveBtn": { "message": "설정 저장" }, - "aboutTitle": { "message": "만능 번역기 v1.0.0" }, + + "aboutTitle": { "message": "Easy Translator v1.0.0" }, "aboutDesc": { "message": "웹페이지, PDF, PPT, DOCX 등을 번역합니다." }, "aboutModels": { "message": "다양한 LLM 백엔드 지원:" }, + "aboutModelOpenAI": { "message": "OpenAI GPT 계열" }, + "aboutModelAnthropic": { "message": "Anthropic Claude 계열" }, + "aboutModelGemini": { "message": "Google Gemini 계열" }, + "aboutModelOthers": { "message": "DeepSeek, Qwen, Zhipu 등" }, "aboutUsage": { "message": "사용 방법:" }, "aboutUsage1": { "message": "텍스트 선택 → 우클릭 → 번역" }, "aboutUsage2": { "message": "텍스트 선택 → Alt+T 누르기" }, "aboutUsage3": { "message": "확장 프로그램 아이콘 클릭하여 텍스트 입력" }, "aboutPDF": { "message": "PDF: 번역 뷰어에서 자동으로 열립니다" }, - "modelConfigTitle": { "message": "모델 설정" }, - "showHide": { "message": "표시/숨기기" }, - "refreshModels": { "message": "모델 새로고침" }, - "shortcutLabel": { "message": "단축키" }, + + "statusFetchingModels": { "message": "모델 목록을 가져오는 중..." }, + "statusModelsUpdated": { "message": "모델 목록을 업데이트했습니다" }, + "statusModelsDefault": { "message": "기본 모델 목록을 불러왔습니다" }, + "statusNeedApiKey": { "message": "API 키를 입력하세요" }, + "statusTesting": { "message": "연결을 테스트하는 중..." }, + "statusTestOk": { + "message": "✓ 연결 성공! 테스트 번역: $RESULT$", + "placeholders": { "result": { "content": "$1" } } + }, + "statusTestFail": { + "message": "✗ 연결 실패: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "statusSettingsSaved": { "message": "✓ 설정을 저장했습니다" }, + "statusInjectFailed": { "message": "번역 스크립트를 주입할 수 없습니다" }, + "statusNotAFile": { "message": "현재 페이지는 PDF 또는 Office 파일이 아닙니다" }, + "btnTranslating": { "message": "번역 중..." }, + "btnTesting": { "message": "테스트 중..." }, + "contextMenuSelection": { "message": "선택한 텍스트 번역" }, "contextMenuPage": { "message": "전체 페이지 번역" }, + + "confirmReload": { "message": "번역 확장 프로그램이 업데이트되었습니다. 계속 사용하려면 페이지를 새로고침해야 합니다. 새로고침할까요?" }, + "floatBtnTranslate": { "message": "번역" }, + "tooltipTitle": { "message": "번역 결과" }, + "loading": { "message": "번역 중..." }, + "actionCopy": { "message": "복사" }, + "actionSwitch": { "message": "언어 전환" }, + "translateFailed": { + "message": "번역 실패: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "sidebarOriginal": { "message": "원문" }, + "sidebarTranslation": { "message": "번역문" }, + "alertNeedApiKey": { "message": "먼저 API 키를 설정하세요 (확장 프로그램 아이콘 → 설정)" }, + + "bannerPreparing": { "message": "준비 중..." }, + "bannerScanning": { "message": "페이지 텍스트를 스캔하는 중..." }, + "bannerNoText": { "message": "번역할 텍스트를 찾을 수 없습니다" }, + "bannerCacheLoaded": { "message": "캐시된 번역을 불러왔습니다" }, + "bannerCacheLoadedCount": { + "message": "캐시된 번역을 불러왔습니다 ($COUNT$개 단락)", + "placeholders": { "count": { "content": "$1" } } + }, + "bannerProgressStart": { + "message": "번역 중... 0/$TOTAL$ (캐시 $CACHED$, 번역 필요 $TOTAL2$)", + "placeholders": { + "total": { "content": "$1" }, + "cached": { "content": "$2" }, + "total2": { "content": "$3" } + } + }, + "bannerProgress": { + "message": "번역 중... $DONE$/$TOTAL$ ($CHUNK$/$CHUNKS$ 블록)", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" }, + "chunk": { "content": "$3" }, + "chunks": { "content": "$4" } + } + }, + "bannerDone": { + "message": "번역 완료! $DONE$/$TOTAL$개 단락", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "bannerStopped": { "message": "번역을 중지했습니다" }, + "bannerReverted": { "message": "원문으로 되돌렸습니다" }, + "bannerShowingTranslation": { "message": "번역문 표시 중" }, + "titleClose": { "message": "닫기" }, + "btnStop": { "message": "중지" }, + "titleStop": { "message": "번역 중지" }, + "btnShowTranslation": { "message": "번역문 보기" }, + "btnShowOriginal": { "message": "원문" }, + + "pdfBtnTranslate": { "message": "PDF 번역" }, + "pdfBtnTranslateThis": { "message": "이 PDF 번역" }, + "pdfNoText": { "message": "이 PDF에서 텍스트를 추출할 수 없습니다. 스캔본이거나 암호화된 문서일 수 있습니다." }, + "pdfFailedMark": { "message": "[번역 실패]" }, + "pdfPanelTitle": { "message": "PDF 번역" }, + "pdfPreparing": { "message": "번역 준비 중..." }, + "pdfProgress": { + "message": "번역 진행률: $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "pdfDone": { "message": "✅ 번역 완료" }, + "viewerTitle": { "message": "PDF 번역" }, "viewerTranslate": { "message": "번역" }, - "viewerToggleOrig": { "message": "원문 보기" }, - "viewerToggleTrans": { "message": "번역문 보기" }, - "viewerSourceLang": { "message": "소스" }, - "viewerTargetLang": { "message": "타겟" }, - "viewerPageOf": { "message": "전체" }, - "viewerPages": { "message": "페이지" } + "viewerToggleOrig": { "message": "원문" }, + "viewerToggleTrans": { "message": "번역문" }, + "viewerMissingParam": { "message": "파일 매개변수가 없습니다" }, + "viewerNoPdfData": { "message": "PDF 데이터를 불러올 수 없습니다" }, + "viewerParseFailed": { + "message": "PDF 분석 실패: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "viewerRendering": { + "message": "렌더링 중... $PAGE$/$TOTAL$ 페이지", + "placeholders": { + "page": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTranslatingProgress": { + "message": "번역 중... $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTransFailed": { "message": "(번역 실패)" }, + "viewerLoadingFile": { "message": "파일을 불러오는 중..." }, + "viewerNoFileData": { "message": "파일 데이터를 불러올 수 없습니다" }, + "viewerEmptyFile": { "message": "파일 내용이 비어 있습니다" }, + "viewerSegments": { + "message": "총 $COUNT$개 단락", + "placeholders": { "count": { "content": "$1" } } + }, + "viewerPagesCount": { + "message": "총 $COUNT$페이지", + "placeholders": { "count": { "content": "$1" } } + }, + + "promptSrcAuto": { "message": "원문" }, + "promptBatch": { + "message": "당신은 전문 번역가입니다. 다음 $SRC$을(를) $TGT$(으)로 번역하세요. 텍스트의 각 단락은 \"[#번호]\" 마커로 시작합니다. 각 단락을 하나씩 번역하고 \"[#번호]\" 마커와 단락 순서를 그대로 유지하세요. 단락을 합치거나 건너뛰지 마세요. 번역 결과만 반환하고 추가 내용은 포함하지 마세요.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptFormal": { + "message": "당신은 전문 번역가입니다. 다음 $SRC$을(를) $TGT$(으)로 번역하세요. 격식 있고 전문적인 어조를 사용하세요. 번역 결과만 반환하고 추가 내용은 포함하지 마세요.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptNatural": { + "message": "당신은 전문 번역가입니다. 다음 $SRC$을(를) $TGT$(으)로 번역하세요. 자연스럽고 유창한 구어체를 사용하세요. 번역 결과만 반환하고 추가 내용은 포함하지 마세요.", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptBatchSuffix": { "message": "텍스트의 각 단락은 \"[#번호]\" 마커로 시작합니다. 각 단락을 하나씩 번역하고 \"[#번호]\" 마커와 단락 순서를 그대로 유지하세요. 단락을 합치거나 건너뛰지 마세요." }, + + "errNoApiKey": { "message": "먼저 API 키를 설정하세요" }, + "errNoText": { "message": "번역할 텍스트가 없습니다" }, + "errApi": { + "message": "API 오류 ($STATUS$): $BODY$", + "placeholders": { + "status": { "content": "$1" }, + "body": { "content": "$2" } + } + }, + "errOllamaConnect": { "message": "Ollama에 연결할 수 없습니다. Ollama가 실행 중인지 확인하세요 (ollama serve)" }, + "errOllamaCors": { "message": "Ollama가 확장 프로그램 요청을 거부했습니다 (CORS). Ollama 실행 시 환경 변수 OLLAMA_ORIGINS=* 를 설정하세요" }, + "errPdfNoText": { "message": "PDF에서 텍스트를 추출할 수 없거나 스캔 문서입니다" }, + "errPdfFailed": { + "message": "PDF 처리 실패: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + } } diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 0c8674b..bddd6f7 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -1,9 +1,11 @@ { "extName": { "message": "Easy Translator" }, "extDesc": { "message": "翻译网页、PDF、PPT、DOCX文件,支持自定义接入大模型" }, + "tabTranslate": { "message": "翻译" }, "tabSettings": { "message": "设置" }, "tabAbout": { "message": "关于" }, + "translatePage": { "message": "翻译整个页面" }, "translatePageDesc": { "message": "替换当前页面所有文字为译文" }, "translateFile": { "message": "翻译当前文件" }, @@ -13,41 +15,235 @@ "autoDetect": { "message": "自动检测" }, "translateBtn": { "message": "翻译" }, "swapLang": { "message": "交换语言" }, + + "modelConfigTitle": { "message": "模型配置" }, "providerLabel": { "message": "API 提供商" }, + "providerOpenAI": { "message": "OpenAI 兼容 (OpenAI / DeepSeek / 通义千问 / 智谱等)" }, + "providerOllama": { "message": "Ollama (本地模型)" }, + "ollamaHintPre": { "message": "需要设置环境变量" }, + "ollamaHintPost": { "message": "后启动 Ollama,否则会被 CORS 阻止" }, "apiUrlLabel": { "message": "API 地址" }, "apiKeyLabel": { "message": "API Key" }, + "showHide": { "message": "显示/隐藏" }, "modelLabel": { "message": "模型名称" }, + "refreshModels": { "message": "刷新可用模型" }, "styleLabel": { "message": "翻译风格" }, "styleNatural": { "message": "自然流畅" }, "styleFormal": { "message": "正式专业" }, + + "customPromptLabel": { "message": "自定义 Prompt" }, + "promptDefaultOption": { "message": "默认 (根据翻译风格自动生成)" }, + "promptPlaceholder": { "message": "你是一个专业翻译..." }, + "promptDefaultPlaceholder": { "message": "使用默认 prompt(根据“翻译风格”自动生成)" }, + "promptSaveNew": { "message": "保存为新模板" }, + "promptUpdate": { "message": "更新当前模板" }, + "promptDelete": { "message": "删除" }, + "promptVarsLabel": { "message": "变量:" }, + "promptVarSrc": { "message": "源语言" }, + "promptVarTgt": { "message": "目标语言" }, + "promptBatchNote": { "message": "系统会自动在批量翻译时加入 [#数字] 标记说明" }, + "promptNamePrompt": { "message": "模板名称:" }, + "promptDefaultName": { + "message": "自定义 $N$", + "placeholders": { "n": { "content": "$1" } } + }, + "promptConfirmDelete": { "message": "确定删除此模板?" }, + "statusPromptEmpty": { "message": "请先输入 prompt 内容" }, + "statusPromptSaved": { "message": "✓ 已保存新模板" }, + "statusPromptUpdated": { "message": "✓ 已更新模板" }, + "statusPromptDeleted": { "message": "✓ 已删除模板" }, + "pageTranslateLabel": { "message": "页面翻译" }, - "shortcutHint": { "message": "选中文本后按 Alt+T 快速翻译" }, + "shortcutLabel": { "message": "快捷键" }, + "shortcutHint": { "message": "选中文本后按" }, + "shortcutHint2": { "message": "快速翻译" }, "displayModeLabel": { "message": "显示模式" }, "displayModeTooltip": { "message": "浮动提示框" }, "displayModeInline": { "message": "替换原文" }, "displayModeSidebar": { "message": "侧边栏" }, "testBtn": { "message": "测试连接" }, "saveBtn": { "message": "保存设置" }, + "aboutTitle": { "message": "Easy Translator v1.0.0" }, "aboutDesc": { "message": "支持翻译网页、PDF、PPT、DOCX等文件内容。" }, "aboutModels": { "message": "可接入多种大模型:" }, + "aboutModelOpenAI": { "message": "OpenAI GPT 系列" }, + "aboutModelAnthropic": { "message": "Anthropic Claude 系列" }, + "aboutModelGemini": { "message": "Google Gemini 系列" }, + "aboutModelOthers": { "message": "DeepSeek、通义千问、智谱等" }, "aboutUsage": { "message": "使用方式:" }, "aboutUsage1": { "message": "选中文本 → 右键 → 翻译选中文本" }, "aboutUsage2": { "message": "选中文本 → 按 Alt+T" }, "aboutUsage3": { "message": "点击图标打开弹窗输入文本" }, "aboutPDF": { "message": "PDF 支持:打开 PDF 文件后自动加载翻译工具" }, - "modelConfigTitle": { "message": "模型配置" }, - "showHide": { "message": "显示/隐藏" }, - "refreshModels": { "message": "刷新可用模型" }, - "shortcutLabel": { "message": "快捷键" }, + + "statusFetchingModels": { "message": "正在获取模型列表..." }, + "statusModelsUpdated": { "message": "模型列表已更新" }, + "statusModelsDefault": { "message": "已加载默认模型列表" }, + "statusNeedApiKey": { "message": "请填写 API Key" }, + "statusTesting": { "message": "正在测试连接..." }, + "statusTestOk": { + "message": "✓ 连接成功!测试翻译: $RESULT$", + "placeholders": { "result": { "content": "$1" } } + }, + "statusTestFail": { + "message": "✗ 连接失败: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "statusSettingsSaved": { "message": "✓ 设置已保存" }, + "statusInjectFailed": { "message": "无法注入翻译脚本" }, + "statusNotAFile": { "message": "当前页面不是 PDF 或 Office 文件" }, + "btnTranslating": { "message": "翻译中..." }, + "btnTesting": { "message": "测试中..." }, + "contextMenuSelection": { "message": "翻译选中文本" }, "contextMenuPage": { "message": "翻译整个页面" }, + + "confirmReload": { "message": "翻译插件已更新,需要刷新页面才能继续使用。是否刷新?" }, + "floatBtnTranslate": { "message": "翻译" }, + "tooltipTitle": { "message": "翻译结果" }, + "loading": { "message": "翻译中..." }, + "actionCopy": { "message": "复制" }, + "actionSwitch": { "message": "切换语言" }, + "translateFailed": { + "message": "翻译失败: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "sidebarOriginal": { "message": "原文" }, + "sidebarTranslation": { "message": "译文" }, + "alertNeedApiKey": { "message": "请先配置 API Key!(点击扩展图标 → 设置)" }, + + "bannerPreparing": { "message": "准备中..." }, + "bannerScanning": { "message": "正在扫描页面文本..." }, + "bannerNoText": { "message": "未找到可翻译的文本" }, + "bannerCacheLoaded": { "message": "已加载缓存翻译" }, + "bannerCacheLoadedCount": { + "message": "已加载缓存翻译 ($COUNT$ 段)", + "placeholders": { "count": { "content": "$1" } } + }, + "bannerProgressStart": { + "message": "正在翻译... 0/$TOTAL$ (缓存命中 $CACHED$, 需翻译 $TOTAL2$)", + "placeholders": { + "total": { "content": "$1" }, + "cached": { "content": "$2" }, + "total2": { "content": "$3" } + } + }, + "bannerProgress": { + "message": "正在翻译... $DONE$/$TOTAL$ ($CHUNK$/$CHUNKS$ 块)", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" }, + "chunk": { "content": "$3" }, + "chunks": { "content": "$4" } + } + }, + "bannerDone": { + "message": "翻译完成!$DONE$/$TOTAL$ 段文本", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "bannerStopped": { "message": "翻译已停止" }, + "bannerReverted": { "message": "已恢复原文" }, + "bannerShowingTranslation": { "message": "显示译文" }, + "titleClose": { "message": "关闭" }, + "btnStop": { "message": "停止" }, + "titleStop": { "message": "停止翻译" }, + "btnShowTranslation": { "message": "显示译文" }, + "btnShowOriginal": { "message": "原文" }, + + "pdfBtnTranslate": { "message": "翻译PDF" }, + "pdfBtnTranslateThis": { "message": "翻译此PDF" }, + "pdfNoText": { "message": "无法从 PDF 中提取文本。可能是扫描件或加密文档。" }, + "pdfFailedMark": { "message": "[翻译失败]" }, + "pdfPanelTitle": { "message": "PDF 翻译" }, + "pdfPreparing": { "message": "准备翻译..." }, + "pdfProgress": { + "message": "翻译进度: $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "pdfDone": { "message": "✅ 翻译完成" }, + "viewerTitle": { "message": "PDF 翻译" }, "viewerTranslate": { "message": "翻译" }, - "viewerToggleOrig": { "message": "显示原文" }, - "viewerToggleTrans": { "message": "显示译文" }, - "viewerSourceLang": { "message": "源语言" }, - "viewerTargetLang": { "message": "目标语言" }, - "viewerPageOf": { "message": "共" }, - "viewerPages": { "message": "页" } + "viewerToggleOrig": { "message": "原文" }, + "viewerToggleTrans": { "message": "译文" }, + "viewerMissingParam": { "message": "缺少文件参数" }, + "viewerNoPdfData": { "message": "无法加载 PDF 数据" }, + "viewerParseFailed": { + "message": "PDF 解析失败: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + }, + "viewerRendering": { + "message": "正在渲染... $PAGE$/$TOTAL$ 页", + "placeholders": { + "page": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTranslatingProgress": { + "message": "翻译中... $DONE$/$TOTAL$", + "placeholders": { + "done": { "content": "$1" }, + "total": { "content": "$2" } + } + }, + "viewerTransFailed": { "message": "(翻译失败)" }, + "viewerLoadingFile": { "message": "正在加载文件..." }, + "viewerNoFileData": { "message": "无法加载文件数据" }, + "viewerEmptyFile": { "message": "文件内容为空" }, + "viewerSegments": { + "message": "共 $COUNT$ 段", + "placeholders": { "count": { "content": "$1" } } + }, + "viewerPagesCount": { + "message": "共 $COUNT$ 页", + "placeholders": { "count": { "content": "$1" } } + }, + + "promptSrcAuto": { "message": "原文" }, + "promptBatch": { + "message": "你是一个专业翻译。请将以下$SRC$翻译为$TGT$。文本中的每个段落以“[#数字]”标记开头。请逐一翻译每个段落,严格保持“[#数字]”标记和段落顺序不变。不要合并或跳过任何段落。只返回翻译结果,不要任何额外内容。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptFormal": { + "message": "你是一个专业翻译。请将以下$SRC$翻译为$TGT$。使用正式、专业的语气。只返回翻译结果,不要任何额外内容。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptNatural": { + "message": "你是一个专业翻译。请将以下$SRC$翻译为$TGT$。使用自然流畅的口语。只返回翻译结果,不要任何额外内容。", + "placeholders": { + "src": { "content": "$1" }, + "tgt": { "content": "$2" } + } + }, + "promptBatchSuffix": { "message": "文本中的每个段落以“[#数字]”标记开头。请逐一翻译每个段落,严格保持“[#数字]”标记和段落顺序不变。不要合并或跳过任何段落。" }, + + "errNoApiKey": { "message": "请先配置 API Key" }, + "errNoText": { "message": "没有待翻译的文本" }, + "errApi": { + "message": "API 错误 ($STATUS$): $BODY$", + "placeholders": { + "status": { "content": "$1" }, + "body": { "content": "$2" } + } + }, + "errOllamaConnect": { "message": "无法连接 Ollama,请确认 Ollama 已启动(ollama serve)" }, + "errOllamaCors": { "message": "Ollama 拒绝了扩展请求(CORS),请在启动 Ollama 时设置环境变量:OLLAMA_ORIGINS=*" }, + "errPdfNoText": { "message": "无法从PDF中提取文本,或PDF为扫描件" }, + "errPdfFailed": { + "message": "PDF处理失败: $ERROR$", + "placeholders": { "error": { "content": "$1" } } + } } diff --git a/background.js b/background.js index 2d61b1c..ee5ec74 100644 --- a/background.js +++ b/background.js @@ -185,12 +185,12 @@ async function handlePDFTranslation(tabId, fileUrl) { // Simple text extraction from PDF (handles text-based PDFs) const text = await extractTextFromPDF(arrayBuffer); - if (!text.trim()) throw new Error('无法从PDF中提取文本,或PDF为扫描件'); + if (!text.trim()) throw new Error(t('errPdfNoText')); const settings = await new Promise(resolve => chrome.storage.sync.get(null, resolve)); return await translateText(text.slice(0, 10000), settings); } catch (err) { - throw new Error(`PDF处理失败: ${err.message}`); + throw new Error(t('errPdfFailed', err.message)); } } diff --git a/content/content.js b/content/content.js index f227a94..13d9b2d 100644 --- a/content/content.js +++ b/content/content.js @@ -5,6 +5,16 @@ // 跳过 PDF 页面(由 pdf-content.js 处理) if (isPDFPage()) return; + // i18n — content scripts can call chrome.i18n directly; falls back to the key + // so a missing message stays visible instead of rendering as an empty label. + function t(key, ...subs) { + if (typeof chrome === 'undefined' || !chrome.i18n) return key; + const msg = subs.length + ? chrome.i18n.getMessage(key, subs.map(String)) + : chrome.i18n.getMessage(key); + return msg || key; + } + // ─── 状态 ────────────────────────────────────────── let floatBtn = null; let tooltip = null; @@ -48,7 +58,7 @@ console.warn('Extension reloaded detected, reloading page to re-inject content script'); try { // Try to reconnect by triggering a page reload - if (confirm('翻译插件已更新,需要刷新页面才能继续使用。是否刷新?')) { + if (confirm(t('confirmReload'))) { window.location.reload(); } } catch {} @@ -137,7 +147,7 @@ if (!floatBtn) { floatBtn = document.createElement('button'); floatBtn.className = 'translate-float-btn'; - floatBtn.innerHTML = '🌐 翻译'; + floatBtn.textContent = '🌐 ' + t('floatBtnTranslate'); floatBtn.addEventListener('click', (e) => { e.stopPropagation(); showTranslation(selectedText); @@ -178,13 +188,13 @@ tooltip.className = 'translate-tooltip'; tooltip.innerHTML = `
- 翻译结果 + ${escapeHtml(t('tooltipTitle'))}
-
翻译中...
+
${escapeHtml(t('loading'))}
- - + +
`; tooltip.querySelector('.tooltip-close').addEventListener('click', removeTooltip); tooltip.querySelector('.action-copy').addEventListener('click', () => { @@ -220,10 +230,10 @@ if (langOverride) settings.targetLang = langOverride; try { const result = await chrome.runtime.sendMessage({ action: 'translate', text, settings }); - contentEl.textContent = result.success ? result.text : `翻译失败: ${result.error}`; + contentEl.textContent = result.success ? result.text : t('translateFailed', result.error); } catch (err) { if (isExtensionInvalidated(err)) { handleExtensionReload(); return; } - contentEl.textContent = `翻译失败: ${err.message}`; + contentEl.textContent = t('translateFailed', err.message); } } @@ -231,7 +241,7 @@ const settings = await loadSettings(); [settings.sourceLang, settings.targetLang] = [settings.targetLang, settings.sourceLang === 'auto' ? 'English' : settings.sourceLang]; const contentEl = tooltip?.querySelector('.tooltip-content'); - if (contentEl) contentEl.innerHTML = '
翻译中...
'; + if (contentEl) contentEl.innerHTML = `
${escapeHtml(t('loading'))}
`; doTranslate(text, settings.targetLang); } @@ -252,25 +262,25 @@
-
原文
+
${escapeHtml(t('sidebarOriginal'))}
-
译文
- +
${escapeHtml(t('sidebarTranslation'))}
+
`; sidebar.querySelector('.sidebar-close').addEventListener('click', () => sidebar.remove()); document.body.appendChild(sidebar); } else { sidebar.querySelector('.sidebar-original').textContent = text; - sidebar.querySelector('.sidebar-translated').innerHTML = '
翻译中...
'; + sidebar.querySelector('.sidebar-translated').innerHTML = `
${escapeHtml(t('loading'))}
`; } loadSettings().then(settings => { chrome.runtime.sendMessage({ action: 'translate', text, settings }).then(result => { const el = sidebar?.querySelector('.sidebar-translated'); - if (el) el.textContent = result.success ? result.text : `翻译失败: ${result.error}`; + if (el) el.textContent = result.success ? result.text : t('translateFailed', result.error); }).catch(err => { if (isExtensionInvalidated(err)) { handleExtensionReload(); return; } const el = sidebar?.querySelector('.sidebar-translated'); - if (el) el.textContent = `翻译失败: ${err.message}`; + if (el) el.textContent = t('translateFailed', err.message); }); }); } @@ -291,7 +301,7 @@ const banner = createBanner(); document.body.prepend(banner); fullPageState.banner = banner; - updateBanner(banner, 'done', '已加载缓存翻译'); + updateBanner(banner, 'done', t('bannerCacheLoaded')); banner.querySelector('.toggle-btn').style.display = 'inline-block'; setupCloseButton(banner); showTranslated(); @@ -307,17 +317,17 @@ if (targetLangOverride) settings.targetLang = targetLangOverride; if (!settings.apiKey && settings.provider !== 'ollama') { - alert('请先配置 API Key!(点击扩展图标 → 设置)'); + alert(t('alertNeedApiKey')); return; } const banner = createBanner(); document.body.prepend(banner); - updateBanner(banner, 'collecting', '正在扫描页面文本...'); + updateBanner(banner, 'collecting', t('bannerScanning')); const textNodes = collectTextNodes(); if (textNodes.length === 0) { - updateBanner(banner, 'error', '未找到可翻译的文本'); + updateBanner(banner, 'error', t('bannerNoText')); setTimeout(() => banner.remove(), 2000); return; } @@ -353,7 +363,7 @@ // 全部命中缓存 → 跳过 API if (uncachedNodes.length === 0) { - updateBanner(banner, 'done', `已加载缓存翻译 (${cachedCount} 段)`); + updateBanner(banner, 'done', t('bannerCacheLoadedCount', cachedCount)); const toggleBtn = banner.querySelector('.toggle-btn'); toggleBtn.style.display = 'inline-block'; toggleBtn.onclick = () => revertFullPage(); @@ -367,7 +377,7 @@ abortFlag = { aborted: false }; setupStopButton(banner, abortFlag, originalTexts); updateBanner(banner, 'progress', - `正在翻译... 0/${uncachedNodes.length} (缓存命中 ${cachedCount}, 需翻译 ${uncachedNodes.length})`); + t('bannerProgressStart', uncachedNodes.length, cachedCount, uncachedNodes.length)); const resultMap = await translateChunksParallel(chunks, settings, banner, uncachedNodes.length, abortFlag); @@ -382,7 +392,7 @@ resultMap.forEach((translated, node) => translatedTexts.set(node, translated)); const freshCount = resultMap.size; - updateBanner(banner, 'done', `翻译完成!${cachedCount + freshCount}/${total} 段文本`); + updateBanner(banner, 'done', t('bannerDone', cachedCount + freshCount, total)); const toggleBtn = banner.querySelector('.toggle-btn'); toggleBtn.style.display = 'inline-block'; toggleBtn.onclick = () => revertFullPage(); @@ -435,7 +445,7 @@ function setupCloseButton(banner) { const btn = banner.querySelector('.ft-close-btn'); btn.textContent = '✕'; - btn.title = '关闭'; + btn.title = t('titleClose'); const origPadding = banner._origPadding; btn.replaceWith(btn.cloneNode(true)); const newBtn = banner.querySelector('.ft-close-btn'); @@ -454,14 +464,14 @@ // 设置停止按钮(翻译进行中) function setupStopButton(banner, flag, originalTexts) { const btn = banner.querySelector('.ft-close-btn'); - btn.textContent = '⏹ 停止'; - btn.title = '停止翻译'; + btn.textContent = '⏹ ' + t('btnStop'); + btn.title = t('titleStop'); const origPadding = banner._origPadding; btn.replaceWith(btn.cloneNode(true)); const newBtn = banner.querySelector('.ft-close-btn'); newBtn.addEventListener('click', () => { flag.aborted = true; - updateBanner(banner, 'error', '翻译已停止'); + updateBanner(banner, 'error', t('bannerStopped')); // 通知后台终止所有进行中的 HTTP 请求 chrome.runtime.sendMessage({ action: 'abortTranslation' }).catch(() => {}); }); @@ -551,7 +561,7 @@ completedNodes += chunk.nodes.length; completedChunks++; updateBanner(banner, 'progress', - `正在翻译... ${Math.min(completedNodes, totalNodes)}/${totalNodes} (${completedChunks}/${chunks.length} 块)`); + t('bannerProgress', Math.min(completedNodes, totalNodes), totalNodes, completedChunks, chunks.length)); } } @@ -595,10 +605,10 @@ }); fullPageState.isShowingTranslation = false; if (fullPageState.banner) { - updateBanner(fullPageState.banner, 'done', '已恢复原文'); + updateBanner(fullPageState.banner, 'done', t('bannerReverted')); const toggleBtn = fullPageState.banner.querySelector('.toggle-btn'); if (toggleBtn) { - toggleBtn.textContent = '🌐 显示译文'; + toggleBtn.textContent = '🌐 ' + t('btnShowTranslation'); toggleBtn.onclick = () => showTranslated(); } } @@ -618,10 +628,10 @@ }); fullPageState.isShowingTranslation = true; if (fullPageState.banner) { - updateBanner(fullPageState.banner, 'done', '显示译文'); + updateBanner(fullPageState.banner, 'done', t('bannerShowingTranslation')); const toggleBtn = fullPageState.banner.querySelector('.toggle-btn'); if (toggleBtn) { - toggleBtn.textContent = '↩ 原文'; + toggleBtn.textContent = '↩ ' + t('btnShowOriginal'); toggleBtn.onclick = () => revertFullPage(); } } @@ -645,9 +655,9 @@
Easy Translator - 准备中... + ${escapeHtml(t('bannerPreparing'))}
- +
`; document.body.appendChild(banner); diff --git a/content/pdf-content.js b/content/pdf-content.js index 93cb4a7..4adde8d 100644 --- a/content/pdf-content.js +++ b/content/pdf-content.js @@ -11,6 +11,15 @@ if (!isPDFViewer) return; + // i18n — content scripts can call chrome.i18n directly; falls back to the key + function t(key, ...subs) { + if (typeof chrome === 'undefined' || !chrome.i18n) return key; + const msg = subs.length + ? chrome.i18n.getMessage(key, subs.map(String)) + : chrome.i18n.getMessage(key); + return msg || key; + } + // 暴露函数供 content.js 调用 window.translatePDFFile = () => { waitForPDF().then(() => translatePDF()); @@ -65,7 +74,7 @@ function addToolbarButton(toolbar) { const btn = document.createElement('button'); - btn.textContent = '🌐 翻译PDF'; + btn.textContent = '🌐 ' + t('pdfBtnTranslate'); Object.assign(btn.style, { padding: '4px 12px', margin: '0 8px', @@ -83,7 +92,7 @@ function addFloatingButton() { const btn = document.createElement('div'); - btn.textContent = '🌐 翻译此PDF'; + btn.textContent = '🌐 ' + t('pdfBtnTranslateThis'); Object.assign(btn.style, { position: 'fixed', top: '12px', @@ -107,7 +116,7 @@ async function translatePDF() { const text = extractPDFText(); if (!text || text.trim().length < 10) { - alert('无法从 PDF 中提取文本。可能是扫描件或加密文档。'); + alert(t('pdfNoText')); return; } @@ -127,12 +136,12 @@ const result = await translateBatch(batch, settings); translated.push(...result); } catch (err) { - translated.push(...batch.map(() => '[翻译失败]')); + translated.push(...batch.map(() => t('pdfFailedMark'))); } // Update progress const progress = overlay.querySelector('.pdf-progress'); if (progress) { - progress.textContent = `翻译进度: ${Math.min(i + 3, paragraphs.length)}/${paragraphs.length}`; + progress.textContent = t('pdfProgress', Math.min(i + 3, paragraphs.length), paragraphs.length); } } @@ -154,7 +163,7 @@ } const progress = overlay.querySelector('.pdf-progress'); - if (progress) progress.textContent = '✅ 翻译完成'; + if (progress) progress.textContent = t('pdfDone'); } function extractPDFText() { @@ -215,8 +224,8 @@ panel.innerHTML = `
-

📄 PDF 翻译

-
准备翻译...
+

📄 ${escapeHtml(t('pdfPanelTitle'))}

+
${escapeHtml(t('pdfPreparing'))}
@@ -246,9 +255,9 @@ text, settings }); - results.push(res.success ? res.text : '[翻译失败]'); + results.push(res.success ? res.text : t('pdfFailedMark')); } catch { - results.push('[翻译失败]'); + results.push(t('pdfFailedMark')); } } return results; diff --git a/i18n.js b/i18n.js index 6c72cb6..e2baa78 100644 --- a/i18n.js +++ b/i18n.js @@ -1,137 +1,40 @@ -// i18n — detects browser language and applies translations via data-i18n attributes +// i18n — thin wrapper over chrome.i18n; applies translations via data-i18n attributes. +// Messages live in _locales//messages.json; Chrome picks the locale itself +// (UI language → default_locale from the manifest). (function () { 'use strict'; - const messages = { - en: { - tabTranslate: 'Translate', tabSettings: 'Settings', tabAbout: 'About', - translatePage: 'Translate Entire Page', translatePageDesc: 'Replace all page text with translations', - translateFile: 'Translate Current File', translateFileDesc: 'Translate PDF / PPT / DOCX content', - orInputText: 'or enter text', inputPlaceholder: 'Enter or paste text to translate...', - autoDetect: 'Auto Detect', translateBtn: 'Translate', swapLang: 'Swap languages', - providerLabel: 'API Provider', apiUrlLabel: 'API URL', apiKeyLabel: 'API Key', - modelLabel: 'Model Name', styleLabel: 'Translation Style', - styleNatural: 'Natural & Fluent', styleFormal: 'Formal & Professional', - modelConfigTitle: 'Model Configuration', pageTranslateLabel: 'Page Translation', - shortcutLabel: 'Shortcut', shortcutHint: 'Select text and press', shortcutHint2: 'for quick translation', - displayModeLabel: 'Display Mode', displayModeTooltip: 'Floating Tooltip', - displayModeInline: 'Replace Inline', displayModeSidebar: 'Sidebar', - testBtn: 'Test Connection', saveBtn: 'Save Settings', showHide: 'Show/Hide', - refreshModels: 'Refresh Models', - aboutTitle: 'Universal Translator v1.0.0', - aboutDesc: 'Translate web pages, PDF, PPT, DOCX and more.', - aboutModels: 'Supports multiple LLM backends:', - aboutUsage: 'How to use:', aboutUsage1: 'Select text → Right-click → Translate', - aboutUsage2: 'Select text → Press Alt+T', aboutUsage3: 'Click extension icon to translate input text', - aboutPDF: 'PDF: Opens automatically in the translation viewer', - viewerTranslate: 'Translate', viewerToggleOrig: 'Show Original', viewerToggleTrans: 'Show Translation', - }, - zh_CN: { - tabTranslate: '翻译', tabSettings: '设置', tabAbout: '关于', - translatePage: '翻译整个页面', translatePageDesc: '替换当前页面所有文字为译文', - translateFile: '翻译当前文件', translateFileDesc: '翻译 PDF / PPT / DOCX 内容', - orInputText: '或输入文本', inputPlaceholder: '输入或粘贴要翻译的文本...', - autoDetect: '自动检测', translateBtn: '翻译', swapLang: '交换语言', - providerLabel: 'API 提供商', apiUrlLabel: 'API 地址', apiKeyLabel: 'API Key', - modelLabel: '模型名称', styleLabel: '翻译风格', - styleNatural: '自然流畅', styleFormal: '正式专业', - modelConfigTitle: '模型配置', pageTranslateLabel: '页面翻译', - shortcutLabel: '快捷键', shortcutHint: '选中文本后按', shortcutHint2: '快速翻译', - displayModeLabel: '显示模式', displayModeTooltip: '浮动提示框', - displayModeInline: '替换原文', displayModeSidebar: '侧边栏', - testBtn: '测试连接', saveBtn: '保存设置', showHide: '显示/隐藏', - refreshModels: '刷新可用模型', - aboutTitle: 'Easy Translator v1.0.0', - aboutDesc: '支持翻译网页、PDF、PPT、DOCX等文件内容。', - aboutModels: '可接入多种大模型:', - aboutUsage: '使用方式:', aboutUsage1: '选中文本 → 右键 → 翻译选中文本', - aboutUsage2: '选中文本 → 按 Alt+T', aboutUsage3: '点击图标打开弹窗输入文本', - aboutPDF: 'PDF 支持:打开 PDF 文件后自动加载翻译工具', - viewerTranslate: '翻译', viewerToggleOrig: '显示原文', viewerToggleTrans: '显示译文', - }, - ja: { - tabTranslate: '翻訳', tabSettings: '設定', tabAbout: '概要', - translatePage: 'ページ全体を翻訳', translatePageDesc: 'ページ上のすべてのテキストを翻訳に置き換え', - translateFile: '現在のファイルを翻訳', translateFileDesc: 'PDF / PPT / DOCX の内容を翻訳', - orInputText: 'またはテキストを入力', inputPlaceholder: '翻訳するテキストを入力または貼り付け...', - autoDetect: '自動検出', translateBtn: '翻訳', swapLang: '言語を入れ替え', - providerLabel: 'API プロバイダ', apiUrlLabel: 'API URL', apiKeyLabel: 'API キー', - modelLabel: 'モデル名', styleLabel: '翻訳スタイル', - styleNatural: '自然で流暢', styleFormal: 'フォーマル・専門的', - modelConfigTitle: 'モデル設定', pageTranslateLabel: 'ページ翻訳', - shortcutLabel: 'ショートカット', shortcutHint: 'テキストを選択して', shortcutHint2: 'でクイック翻訳', - displayModeLabel: '表示モード', displayModeTooltip: 'フローティングツールチップ', - displayModeInline: 'インライン置換', displayModeSidebar: 'サイドバー', - testBtn: '接続テスト', saveBtn: '設定を保存', showHide: '表示/非表示', - refreshModels: 'モデル更新', - aboutTitle: '万能翻訳機 v1.0.0', - aboutDesc: 'ウェブページ、PDF、PPT、DOCXなどを翻訳します。', - aboutModels: '複数のLLMバックエンドに対応:', - aboutUsage: '使い方:', aboutUsage1: 'テキストを選択 → 右クリック → 翻訳', - aboutUsage2: 'テキストを選択 → Alt+T を押す', aboutUsage3: '拡張機能アイコンをクリックしてテキストを入力', - aboutPDF: 'PDF:自動的に翻訳ビューアーで開きます', - viewerTranslate: '翻訳', viewerToggleOrig: '原文表示', viewerToggleTrans: '訳文表示', - }, - ko: { - tabTranslate: '번역', tabSettings: '설정', tabAbout: '정보', - translatePage: '전체 페이지 번역', translatePageDesc: '페이지의 모든 텍스트를 번역문으로 교체', - translateFile: '현재 파일 번역', translateFileDesc: 'PDF / PPT / DOCX 내용 번역', - orInputText: '또는 텍스트 입력', inputPlaceholder: '번역할 텍스트를 입력하거나 붙여넣기...', - autoDetect: '자동 감지', translateBtn: '번역', swapLang: '언어 교환', - providerLabel: 'API 제공자', apiUrlLabel: 'API URL', apiKeyLabel: 'API 키', - modelLabel: '모델 이름', styleLabel: '번역 스타일', - styleNatural: '자연스럽고 유창하게', styleFormal: '격식 있고 전문적으로', - modelConfigTitle: '모델 설정', pageTranslateLabel: '페이지 번역', - shortcutLabel: '단축키', shortcutHint: '텍스트 선택 후', shortcutHint2: '로 빠른 번역', - displayModeLabel: '표시 모드', displayModeTooltip: '플로팅 툴팁', - displayModeInline: '인라인 교체', displayModeSidebar: '사이드바', - testBtn: '연결 테스트', saveBtn: '설정 저장', showHide: '표시/숨기기', - refreshModels: '모델 새로고침', - aboutTitle: '만능 번역기 v1.0.0', - aboutDesc: '웹페이지, PDF, PPT, DOCX 등을 번역합니다.', - aboutModels: '다양한 LLM 백엔드 지원:', - aboutUsage: '사용 방법:', aboutUsage1: '텍스트 선택 → 우클릭 → 번역', - aboutUsage2: '텍스트 선택 → Alt+T 누르기', aboutUsage3: '확장 프로그램 아이콘 클릭하여 텍스트 입력', - aboutPDF: 'PDF: 번역 뷰어에서 자동으로 열립니다', - viewerTranslate: '번역', viewerToggleOrig: '원문 보기', viewerToggleTrans: '번역문 보기', - } - }; - - function detectLang() { - const lang = (navigator.language || 'en').replace('-', '_'); - if (messages[lang]) return lang; - const base = lang.split('_')[0]; - if (messages[base]) return base; - // check if any key starts with the base - for (const k of Object.keys(messages)) { - if (k.startsWith(base + '_')) return k; - } - return 'en'; + // Returns the localized string for `key`, or the key itself if it is missing — + // a missing key stays visible instead of collapsing to an empty label. + // Extra arguments are passed to chrome.i18n as $1..$9 substitutions. + function t(key, ...subs) { + if (typeof chrome === 'undefined' || !chrome.i18n) return key; + const msg = subs.length + ? chrome.i18n.getMessage(key, subs.map(String)) + : chrome.i18n.getMessage(key); + return msg || key; } - const currentLang = detectLang(); - const t = messages[currentLang] || messages.en; - // Apply translations to DOM elements with data-i18n attribute window.applyI18n = function (root = document) { root.querySelectorAll('[data-i18n]').forEach(el => { - const key = el.dataset.i18n; - if (t[key]) el.textContent = t[key]; + const msg = t(el.dataset.i18n); + if (msg !== el.dataset.i18n) el.textContent = msg; }); root.querySelectorAll('[data-i18n-placeholder]').forEach(el => { const key = el.dataset.i18nPlaceholder; - if (t[key]) el.placeholder = t[key]; + const msg = t(key); + if (msg !== key) el.placeholder = msg; }); root.querySelectorAll('[data-i18n-title]').forEach(el => { const key = el.dataset.i18nTitle; - if (t[key]) el.title = t[key]; + const msg = t(key); + if (msg !== key) el.title = msg; }); }; // Expose i18n message getter for JS use - window.i18nGet = function (key) { - return t[key] || key; - }; + window.i18nGet = t; // Auto-apply on DOM ready if (document.readyState === 'loading') { diff --git a/lib/translator.js b/lib/translator.js index 9546e2b..3a2e854 100644 --- a/lib/translator.js +++ b/lib/translator.js @@ -1,7 +1,19 @@ // LLM API 抽象层 - 支持多种大模型 + +// i18n — available in the service worker via chrome.i18n; falls back to the key. +// Both the user-facing errors and the system prompts below are localized, so the +// prompt sent to the model follows the extension's UI language. +function t(key, ...subs) { + if (typeof chrome === 'undefined' || !chrome.i18n) return key; + const msg = subs.length + ? chrome.i18n.getMessage(key, subs.map(String)) + : chrome.i18n.getMessage(key); + return msg || key; +} + const DEFAULT_PROVIDERS = { openai: { - name: 'OpenAI 兼容', + name: t('providerOpenAI'), baseUrl: 'https://api.openai.com/v1', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus', 'glm-4'], chatEndpoint: '/chat/completions' @@ -34,10 +46,10 @@ function getDefaultTargetLang() { async function translateText(text, settings, batchMode = false, signal = null) { const { provider, apiKey, apiUrl, model, sourceLang, targetLang, style, customPrompts, activePrompt } = settings; - if (!apiKey && provider !== 'ollama') throw new Error('请先配置 API Key'); - if (!text?.trim()) throw new Error('没有待翻译的文本'); + if (!apiKey && provider !== 'ollama') throw new Error(t('errNoApiKey')); + if (!text?.trim()) throw new Error(t('errNoText')); - const src = sourceLang === 'auto' ? '原文' : sourceLang; + const src = sourceLang === 'auto' ? t('promptSrcAuto') : sourceLang; const tgt = targetLang || getDefaultTargetLang(); let systemPrompt; @@ -48,16 +60,16 @@ async function translateText(text, settings, batchMode = false, signal = null) { .replace(/\{src\}/g, src) .replace(/\{tgt\}/g, tgt); } else if (batchMode) { - systemPrompt = `你是一个专业翻译。请将以下${src}翻译为${tgt}。文本中的每个段落以"[#数字]"标记开头。请逐一翻译每个段落,严格保持"[#数字]"标记和段落顺序不变。不要合并或跳过任何段落。只返回翻译结果,不要任何额外内容。`; + systemPrompt = t('promptBatch', src, tgt); } else if (style === 'formal') { - systemPrompt = `你是一个专业翻译。请将以下${src}翻译为${tgt}。使用正式、专业的语气。只返回翻译结果,不要任何额外内容。`; + systemPrompt = t('promptFormal', src, tgt); } else { - systemPrompt = `你是一个专业翻译。请将以下${src}翻译为${tgt}。使用自然流畅的口语。只返回翻译结果,不要任何额外内容。`; + systemPrompt = t('promptNatural', src, tgt); } // 批量翻译时,如果使用自定义 prompt,追加段落标记说明 if (batchMode && activePrompt && activePrompt !== 'default' && customPrompts?.[activePrompt]) { - systemPrompt += '\n\n文本中的每个段落以"[#数字]"标记开头。请逐一翻译每个段落,严格保持"[#数字]"标记和段落顺序不变。不要合并或跳过任何段落。'; + systemPrompt += '\n\n' + t('promptBatchSuffix'); } switch (provider) { @@ -86,7 +98,7 @@ async function translateWithOpenAI(apiKey, apiUrl, model, systemPrompt, text, si }); if (!res.ok) { const err = await res.text(); - throw new Error(`API 错误 (${res.status}): ${err}`); + throw new Error(t('errApi', res.status, err)); } const data = await res.json(); return data.choices?.[0]?.message?.content?.trim() || ''; @@ -107,7 +119,7 @@ async function translateWithAnthropic(apiKey, apiUrl, model, systemPrompt, text, }); if (!res.ok) { const err = await res.text(); - throw new Error(`API 错误 (${res.status}): ${err}`); + throw new Error(t('errApi', res.status, err)); } const data = await res.json(); return data.content?.[0]?.text?.trim() || ''; @@ -128,7 +140,7 @@ async function translateWithGemini(apiKey, apiUrl, model, systemPrompt, text, si }); if (!res.ok) { const err = await res.text(); - throw new Error(`API 错误 (${res.status}): ${err}`); + throw new Error(t('errApi', res.status, err)); } const data = await res.json(); return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || ''; @@ -153,14 +165,14 @@ async function translateWithOllama(apiKey, apiUrl, model, systemPrompt, text, si }) }); } catch (e) { - throw new Error('无法连接 Ollama,请确认 Ollama 已启动(ollama serve)'); + throw new Error(t('errOllamaConnect')); } if (res.status === 403) { - throw new Error('Ollama 拒绝了扩展请求(CORS),请在启动 Ollama 时设置环境变量:OLLAMA_ORIGINS=*'); + throw new Error(t('errOllamaCors')); } if (!res.ok) { const err = await res.text(); - throw new Error(`API 错误 (${res.status}): ${err}`); + throw new Error(t('errApi', res.status, err)); } const data = await res.json(); return data.choices?.[0]?.message?.content?.trim() || ''; diff --git a/popup/popup.html b/popup/popup.html index b491079..77c69d7 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -72,13 +72,13 @@

模型配置

@@ -99,20 +99,20 @@

模型配置

- +
- +
- - - + + +

- 变量: {src} 源语言, {tgt} 目标语言
- 系统会自动在批量翻译时加入 [#数字] 标记说明 + 变量: {src} 源语言, {tgt} 目标语言
+ 系统会自动在批量翻译时加入 [#数字] 标记说明

@@ -143,10 +143,10 @@

Easy Translator v1.0.0

支持翻译网页、PDF、PPT、DOCX等文件内容。

可接入多种大模型:

    -
  • OpenAI GPT 系列
  • -
  • Anthropic Claude 系列
  • -
  • Google Gemini 系列
  • -
  • DeepSeek、通义千问、智谱等
  • +
  • OpenAI GPT 系列
  • +
  • Anthropic Claude 系列
  • +
  • Google Gemini 系列
  • +
  • DeepSeek、通义千问、智谱等

使用方式:

    diff --git a/popup/popup.js b/popup/popup.js index 939fa7d..6c40217 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -146,7 +146,7 @@ els.refreshModels.addEventListener('click', async () => { const apiKey = els.apiKey.value; const apiUrl = els.apiUrl.value; - showStatus('正在获取模型列表...', 'info'); + showStatus(i18nGet('statusFetchingModels'), 'info'); try { const result = await chrome.runtime.sendMessage({ action: 'getModels', @@ -160,14 +160,14 @@ els.refreshModels.addEventListener('click', async () => { opt.value = m; datalist.appendChild(opt); }); - showStatus('模型列表已更新', 'success'); + showStatus(i18nGet('statusModelsUpdated'), 'success'); } else { updateModelList(provider); - showStatus('已加载默认模型列表', 'info'); + showStatus(i18nGet('statusModelsDefault'), 'info'); } } catch { updateModelList(provider); - showStatus('已加载默认模型列表', 'info'); + showStatus(i18nGet('statusModelsDefault'), 'info'); } }); @@ -192,7 +192,7 @@ els.translateBtn.addEventListener('click', async () => { const settings = collectSettings(); els.translateBtn.disabled = true; - els.translateBtn.textContent = '翻译中...'; + els.translateBtn.textContent = i18nGet('btnTranslating'); try { const result = await chrome.runtime.sendMessage({ @@ -211,7 +211,7 @@ els.translateBtn.addEventListener('click', async () => { showStatus(err.message, 'error'); } finally { els.translateBtn.disabled = false; - els.translateBtn.textContent = '翻译'; + els.translateBtn.textContent = i18nGet('translateBtn'); } }); @@ -219,13 +219,13 @@ els.translateBtn.addEventListener('click', async () => { els.testBtn.addEventListener('click', async () => { const settings = collectSettings(); if (!settings.apiKey && settings.provider !== 'ollama') { - showStatus('请填写 API Key', 'error'); + showStatus(i18nGet('statusNeedApiKey'), 'error'); return; } els.testBtn.disabled = true; - els.testBtn.textContent = '测试中...'; - showStatus('正在测试连接...', 'info'); + els.testBtn.textContent = i18nGet('btnTesting'); + showStatus(i18nGet('statusTesting'), 'info'); try { const result = await chrome.runtime.sendMessage({ @@ -234,15 +234,15 @@ els.testBtn.addEventListener('click', async () => { }); if (result.success) { - showStatus(`✓ 连接成功!测试翻译: ${result.text}`, 'success'); + showStatus(i18nGet('statusTestOk', result.text), 'success'); } else { throw new Error(result.error); } } catch (err) { - showStatus(`✗ 连接失败: ${err.message}`, 'error'); + showStatus(i18nGet('statusTestFail', err.message), 'error'); } finally { els.testBtn.disabled = false; - els.testBtn.textContent = '测试连接'; + els.testBtn.textContent = i18nGet('testBtn'); } }); @@ -250,7 +250,7 @@ els.testBtn.addEventListener('click', async () => { els.saveBtn.addEventListener('click', async () => { const settings = collectSettings(); await chrome.storage.sync.set(settings); - showStatus('✓ 设置已保存', 'success'); + showStatus(i18nGet('statusSettingsSaved'), 'success'); }); function showStatus(msg, type) { @@ -305,7 +305,7 @@ els.translateFullPage.addEventListener('click', async () => { try { await chrome.tabs.sendMessage(tab.id, msg); } catch (e) { - showStatus('无法注入翻译脚本', 'error'); + showStatus(i18nGet('statusInjectFailed'), 'error'); } }, 300); } @@ -345,7 +345,7 @@ els.translateFile.addEventListener('click', async () => { }, 300); } } else { - showStatus('当前页面不是 PDF 或 Office 文件', 'error'); + showStatus(i18nGet('statusNotAFile'), 'error'); } window.close(); }); diff --git a/popup/prompt-manager.js b/popup/prompt-manager.js index f91b71e..da6674b 100644 --- a/popup/prompt-manager.js +++ b/popup/prompt-manager.js @@ -9,7 +9,11 @@ function loadPrompts(saved, activeId) { const sel = document.getElementById('promptSelector'); if (!sel) return; - sel.innerHTML = ''; + sel.innerHTML = ''; + const defaultOpt = document.createElement('option'); + defaultOpt.value = 'default'; + defaultOpt.textContent = i18nGet('promptDefaultOption'); + sel.appendChild(defaultOpt); Object.entries(customPrompts).forEach(([id, p]) => { const opt = document.createElement('option'); @@ -32,7 +36,7 @@ function updatePromptText() { const id = sel.value; if (id === 'default') { text.value = ''; - text.placeholder = '使用默认 prompt(根据"翻译风格"自动生成)'; + text.placeholder = i18nGet('promptDefaultPlaceholder'); if (updateBtn) updateBtn.disabled = true; if (deleteBtn) deleteBtn.disabled = true; } else { @@ -66,16 +70,16 @@ function initPromptManager(showStatusFn) { if (!text) return; const content = text.value.trim(); if (!content) { - showStatusFn('请先输入 prompt 内容', 'error'); + showStatusFn(i18nGet('statusPromptEmpty'), 'error'); return; } - const name = prompt('模板名称:', `自定义 ${Object.keys(customPrompts).length + 1}`); + const name = prompt(i18nGet('promptNamePrompt'), i18nGet('promptDefaultName', Object.keys(customPrompts).length + 1)); if (!name) return; const id = 'custom_' + Date.now(); customPrompts[id] = { name, template: content }; loadPrompts(customPrompts, id); - showStatusFn('✓ 已保存新模板', 'success'); + showStatusFn(i18nGet('statusPromptSaved'), 'success'); }); } @@ -87,11 +91,11 @@ function initPromptManager(showStatusFn) { if (!text) return; const content = text.value.trim(); if (!content) { - showStatusFn('请先输入 prompt 内容', 'error'); + showStatusFn(i18nGet('statusPromptEmpty'), 'error'); return; } customPrompts[id].template = content; - showStatusFn('✓ 已更新模板', 'success'); + showStatusFn(i18nGet('statusPromptUpdated'), 'success'); }); } @@ -100,10 +104,10 @@ function initPromptManager(showStatusFn) { deleteBtn.addEventListener('click', () => { const id = sel.value; if (id === 'default') return; - if (!confirm('确定删除此模板?')) return; + if (!confirm(i18nGet('promptConfirmDelete'))) return; delete customPrompts[id]; loadPrompts(customPrompts, 'default'); - showStatusFn('✓ 已删除模板', 'success'); + showStatusFn(i18nGet('statusPromptDeleted'), 'success'); }); } } diff --git a/viewer/viewer.html b/viewer/viewer.html index 6cfcc8d..3f1b1c5 100644 --- a/viewer/viewer.html +++ b/viewer/viewer.html @@ -20,6 +20,7 @@ + diff --git a/viewer/viewer.js b/viewer/viewer.js index df9c7ea..c6f859b 100644 --- a/viewer/viewer.js +++ b/viewer/viewer.js @@ -4,6 +4,9 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('viewer/pdf.worker.min.js'); + // i18n.js is loaded first and exposes window.i18nGet + const t = (key, ...subs) => (window.i18nGet ? window.i18nGet(key, ...subs) : key); + const SCALE = 1.5; const MAX_PAGES = 100; const BATCH_SIZE = 8; @@ -16,6 +19,7 @@ let originalTexts = []; // flat array of paragraph texts let translatedTexts = []; // flat array of translated texts let isTranslated = false; + let panelsVisible = false; // whether the translation panels are currently shown let settings = {}; const container = document.getElementById('viewer-container'); @@ -37,7 +41,7 @@ const params = new URLSearchParams(window.location.search); const src = params.get('src'); const id = params.get('id'); - if (!src && !id) { loading.textContent = '缺少文件参数'; return; } + if (!src && !id) { loading.textContent = t('viewerMissingParam'); return; } if (id && id.startsWith('office_')) { await initOfficeFile(id); return; } await initPDF(src, id); } @@ -47,19 +51,19 @@ if (src) { pdfData = src; } else if (id) { pdfData = await loadFromBg(id); - if (!pdfData) { loading.textContent = '无法加载 PDF 数据'; return; } + if (!pdfData) { loading.textContent = t('viewerNoPdfData'); return; } } try { pdfDoc = await pdfjsLib.getDocument(pdfData).promise; totalPages = Math.min(pdfDoc.numPages, MAX_PAGES); - pageInfo.textContent = `${totalPages} pages`; + pageInfo.textContent = t('viewerPagesCount', totalPages); updateNav(); await renderAllPages(); loading.style.display = 'none'; translateBtn.disabled = false; } catch (err) { - loading.textContent = 'PDF 解析失败: ' + err.message; + loading.textContent = t('viewerParseFailed', err.message); } } @@ -72,7 +76,7 @@ container.querySelectorAll('.pdf-page-wrapper').forEach(p => p.remove()); for (let i = 1; i <= totalPages; i++) { - loading.textContent = `正在渲染... ${i}/${totalPages} 页`; + loading.textContent = t('viewerRendering', i, totalPages); const page = await pdfDoc.getPage(i); const viewport = page.getViewport({ scale: SCALE }); @@ -215,12 +219,12 @@ toggleBtn.addEventListener('click', toggleTranslation); async function translateAll() { - if (!settings.apiKey) { alert('请先配置 API Key'); return; } + if (!settings.apiKey) { alert(t('errNoApiKey')); return; } settings.sourceLang = sourceLang.value; settings.targetLang = targetLang.value; translateBtn.disabled = true; - translateBtn.textContent = '翻译中...'; + translateBtn.textContent = t('btnTranslating'); translatedTexts = new Array(originalTexts.length).fill(null); const items = originalTexts.map((text, i) => ({ text: text.trim(), idx: i })) @@ -249,7 +253,7 @@ } } catch (err) { console.warn('Chunk error:', err); } completed += chunk.length; - translateBtn.textContent = `翻译中... ${Math.min(completed, items.length)}/${items.length}`; + translateBtn.textContent = t('viewerTranslatingProgress', Math.min(completed, items.length), items.length); } } @@ -259,9 +263,10 @@ buildTranslationPanels(); container.querySelectorAll('.pdf-trans-panel').forEach(p => p.style.display = 'block'); isTranslated = true; - translateBtn.textContent = (window.i18nGet ? window.i18nGet('translateBtn') : '翻译') + ' ✓'; + panelsVisible = true; + translateBtn.textContent = t('viewerTranslate') + ' ✓'; toggleBtn.style.display = 'inline-block'; - toggleBtn.textContent = '↩ ' + (window.i18nGet ? window.i18nGet('viewerToggleOrig') : '原文'); + toggleBtn.textContent = '↩ ' + t('viewerToggleOrig'); } // Build translation panel content for each page @@ -290,7 +295,7 @@ const transDiv = document.createElement('div'); transDiv.className = 'trans-text'; - transDiv.textContent = trans || '(翻译失败)'; + transDiv.textContent = trans || t('viewerTransFailed'); row.appendChild(origDiv); row.appendChild(transDiv); @@ -301,30 +306,26 @@ function toggleTranslation() { if (!isTranslated || translatedTexts.length === 0) return; - const origLabel = window.i18nGet ? window.i18nGet('viewerToggleOrig') : '原文'; - const transLabel = window.i18nGet ? window.i18nGet('viewerToggleTrans') : '译文'; - const showingTrans = toggleBtn.textContent.includes(origLabel); - if (showingTrans) { - container.querySelectorAll('.pdf-trans-panel').forEach(p => p.style.display = 'none'); - toggleBtn.textContent = '🌐 ' + transLabel; - } else { - container.querySelectorAll('.pdf-trans-panel').forEach(p => p.style.display = 'block'); - toggleBtn.textContent = '↩ ' + origLabel; - } + panelsVisible = !panelsVisible; + container.querySelectorAll('.pdf-trans-panel') + .forEach(p => p.style.display = panelsVisible ? 'block' : 'none'); + toggleBtn.textContent = panelsVisible + ? '↩ ' + t('viewerToggleOrig') + : '🌐 ' + t('viewerToggleTrans'); } // ─── Office files ──────────────────────────────── async function initOfficeFile(id) { - loading.textContent = '正在加载文件...'; + loading.textContent = t('viewerLoadingFile'); const data = await loadFromBg(id); - if (!data) { loading.textContent = '无法加载文件数据'; return; } + if (!data) { loading.textContent = t('viewerNoFileData'); return; } const text = new TextDecoder('utf-8').decode(data); - if (!text.trim()) { loading.textContent = '文件内容为空'; return; } + if (!text.trim()) { loading.textContent = t('viewerEmptyFile'); return; } const pars = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); totalPages = 1; - pageInfo.textContent = `共 ${pars.length} 段`; + pageInfo.textContent = t('viewerSegments', pars.length); prevBtn.style.display = 'none'; nextBtn.style.display = 'none'; const wrapper = document.createElement('div');