From 32abc00693135414e07a20a28767123acf86313e Mon Sep 17 00:00:00 2001 From: mic Date: Fri, 27 Mar 2026 23:59:17 +0100 Subject: [PATCH] mail translation first try. see #247 --- _locales/en/messages.json | 96 ++++++++++++ claude-spec/01-architecture.md | 38 ++++- claude-spec/02-prompts.md | 21 +++ claude-spec/05-options.md | 19 ++- js/mzta-compose-script.js | 206 +++++++++++++++++++++++- js/mzta-menus.js | 4 +- js/mzta-prompts.js | 34 ++-- js/mzta-storage.js | 62 +++++++- js/mzta-translationstore.js | 150 ++++++++++++++++++ js/mzta-utils-prompt.js | 20 +++ mzta-background.js | 132 ++++++++++++++++ options/mzta-options-default.js | 5 +- options/mzta-options.html | 11 ++ options/mzta-options.js | 36 ++++- pages/translate/mzta-translate.css | 180 +++++++++++++++++++++ pages/translate/mzta-translate.html | 97 ++++++++++++ pages/translate/mzta-translate.js | 232 ++++++++++++++++++++++++++++ 17 files changed, 1316 insertions(+), 27 deletions(-) create mode 100644 js/mzta-translationstore.js create mode 100644 pages/translate/mzta-translate.css create mode 100644 pages/translate/mzta-translate.html create mode 100644 pages/translate/mzta-translate.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 15824bc7..49c64d6d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2049,6 +2049,102 @@ "message": "Delete summary", "description": "" }, + "prefs_OptionText_translate": { + "message": "Translate email", + "description": "" + }, + "prefs_OptionText_translate_use_specific_integration_Info": { + "message": "If checked, the Model and API specified below will be used for translating email(s), regardless the one chosen in the ThunderAI options page.", + "description": "" + }, + "prefs_OptionText_translate_Info": { + "message": "If checked, adds a translate button in the message body.", + "description": "" + }, + "prefs_OptionText_btnManageTranslateInfo": { + "message": "Manage translate settings", + "description": "" + }, + "Translate_PageTitle": { + "message": "Manage Translation Settings", + "description": "" + }, + "Translate_info_default": { + "message": "In this page you can modify the default prompt used to translate emails.", + "description": "" + }, + "Translate_prompt_text_title": { + "message": "Current prompt text", + "description": "" + }, + "Translate_prompt_prefs_title": { + "message": "Translation Options", + "description": "" + }, + "prefs_OptionText_translate_auto": { + "message": "Auto-translate messages", + "description": "" + }, + "prefs_OptionText_translate_auto_disabled": { + "message": "Disabled", + "description": "" + }, + "prefs_OptionText_translate_auto_manual": { + "message": "Manual button only", + "description": "" + }, + "prefs_OptionText_translate_auto_automatic": { + "message": "Automatic", + "description": "" + }, + "prefs_OptionText_translate_auto_Info": { + "message": "Choose when to translate messages: disabled, only when clicking the button, or automatically when opening a message.", + "description": "" + }, + "prefs_OptionText_translate_lang": { + "message": "Translation target language", + "description": "" + }, + "prefs_OptionText_translate_lang_Info": { + "message": "Language to translate emails into. If empty, uses the default language setting.", + "description": "" + }, + "prefs_OptionText_Translate_main_prompt": { + "message": "The prompt describing the translation task:", + "description": "" + }, + "translate_generating": { + "message": "Translating...", + "description": "" + }, + "translate_click_to_generate": { + "message": "Click here to translate this email", + "description": "" + }, + "get_ai_translation": { + "message": "AI Translation", + "description": "" + }, + "translate_chatgpt_web_not_supported": { + "message": "Auto-translation requires an API-based connection. Please configure an API connection in ThunderAI settings.", + "description": "" + }, + "translate_refresh": { + "message": "Refresh translation", + "description": "" + }, + "translate_delete": { + "message": "Delete translation", + "description": "" + }, + "translate_banner_title": { + "message": "AI Translation", + "description": "" + }, + "translate_error": { + "message": "Translation failed.", + "description": "" + }, "antispam_by": { "message": "Antispam by", "description": "" diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index 085affed..a679df6f 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -78,6 +78,38 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) mzta-compose-script.js (render summary banner in message body) ``` +### Data Flow: Inline Translation on Message Display + +The `translate_auto` preference controls when translation is triggered. Unlike summarize, translation has no `display_mode` option (always inline). + +- `translate_auto = 0` (disabled) → do nothing +- `translate_auto = 1` (manual button) → show "Get AI Translation" button in message body +- `translate_auto = 2` (automatic) → generate translation immediately on message open + +The target language is determined by `translate_lang` (fallback on `default_chatgpt_lang`). + +``` +User opens/selects a message in Thunderbird + ↓ +mzta-compose-script.js (sends "initTranslation" to background) + ↓ +mzta-background.js (checks translate + translate_auto prefs) + ↓ + ┌──────────────────────────────────────────────────────────┐ + │ translate_auto = 0 → do nothing │ + │ translate_auto = 1 → show "click to translate" button │ + │ translate_auto = 2 → generate immediately (always inline)│ + └──────────────────────────────────────────────────────────┘ + ↓ (if generating) + taTranslationStore (check cache / set processing) + ↓ (cache miss) + mzta-special-commands (via Web Worker, NOT chatgpt_web) + ↓ + taTranslationStore (save result via taStorage) + ↓ + mzta-compose-script.js (render translation banner in message body) +``` + ## Key Modules | File | Role | @@ -87,7 +119,7 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) | `js/mzta-prompts.js` | Prompt definitions (built-in) and custom prompt loading | | `js/mzta-placeholders.js` | Placeholder definitions and resolution logic | | `js/mzta-utils.js` | General utilities (email parsing, storage helpers, etc.) | -| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly) | +| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly, `buildTranslationPrompt()` for translation prompt assembly) | | `js/mzta-compose-script.js` | Content script for compose and message display: injects AI response into compose window, renders summary/spam banners in message display | | `js/mzta-chatgpt.js` | ChatGPT Web integration (opens browser window, reads DOM) | | `js/mzta-special-commands.js` | Handles special prompt actions (add_tags, calendar, task) | @@ -97,6 +129,7 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) | `js/mzta-store.js` | Storage abstraction helpers | | `js/mzta-storage.js` | Unified per-message storage layer (`taStorage` class) for summary, spam, and translation data | | `js/mzta-summarystore.js` | Summary-specific storage wrapper (`taSummaryStore` class) with caching, truncation, and processing-state tracking | +| `js/mzta-translationstore.js` | Translation-specific storage wrapper (`taTranslationStore` class) with caching, truncation, and processing-state tracking | | `js/mzta-working-status.js` | Visual status indicator during AI processing | | `js/mzta-addatags-exclusion-list.js` | Tag exclusion list management | | `js/mzta-placeholders-autocomplete.js` | Autocomplete for placeholders in prompt editor | @@ -139,6 +172,7 @@ Each subdirectory is a self-contained settings/UI page for a specific feature: | `get-task/` | Task creation settings | | `spamfilter/` | Spam filter settings | | `summarize/` | Email summarization settings | +| `translate/` | Email translation settings | | `onboarding/` | First-run welcome page | | `_lib/` | Shared libraries used by pages | @@ -151,3 +185,5 @@ All preferences are stored via `browser.storage.local`. The keys and default val Per-message data (summaries, spam reports, translations) is stored via `js/mzta-storage.js` (`taStorage` class). Each record is keyed by `msg:` in `messenger.storage.local` and follows schema version 1. Records contain optional fields: `summary`, `spam`, `translation`, plus metadata (`v`, `ts`). The `taStorage` class provides typed read/write/delete methods per field, automatic record cleanup when all fields are removed, and age-based cleanup. `js/mzta-summarystore.js` (`taSummaryStore` class) wraps `taStorage` for summary-specific operations: load/save/remove summaries, track in-flight generation state via `browser.storage.session`, enforce a 100-entry cache limit with oldest-first truncation, and store error states. + +`js/mzta-translationstore.js` (`taTranslationStore` class) wraps `taStorage` for translation-specific operations: load/save/remove translations, track in-flight generation state via `browser.storage.session`, enforce a 100-entry cache limit with oldest-first truncation, and store error states. Each translation record stores `translated_text`, `lang`, and optional error information. diff --git a/claude-spec/02-prompts.md b/claude-spec/02-prompts.md index aa2b3864..77b271c2 100644 --- a/claude-spec/02-prompts.md +++ b/claude-spec/02-prompts.md @@ -55,6 +55,7 @@ Some prompts trigger additional Thunderbird actions beyond just sending text to | `summarize` | Summarize email content | | `get_calendar_event` | Extract and create a calendar event | | `get_task` | Extract and create a task | +| `translate` | Translate email content into a target language | These special prompts can have their own dedicated API integration settings (configured in the Options page). The list of these special prompts is in `options/mzta-options-default.js` as `special_prompts_with_integration`. @@ -85,6 +86,26 @@ The summarize feature uses two distinct prompt pathways: - Accepts an array of `{ message, fullMessage }` entries - Returns `{ promptText, promptInfo }` where `promptInfo` is the `prompt_summarize` prompt object +### Translate: Inline-Only Prompt System + +The translate feature uses a single special prompt (`prompt_translate_this`) for inline translation in the message body. Unlike summarize, it has no context menu entry and no webchat mode. + +**Inline Translation on Message Display** (controlled by `translate_auto` pref): +- Uses a single special prompt: `prompt_translate_this` +- The prompt text is appended with the target language and the email body: `prompt_text + " " + lang + ". \"" + body_text + "\""` +- Target language is determined by `translate_lang` pref, falling back to `default_chatgpt_lang` +- Does **not** support `chatgpt_web` connection type (shows error if configured) +- Result is rendered as a styled banner (green/teal theme) in the message body via `mzta-compose-script.js` +- Banner includes refresh (↻) and delete (×) buttons +- Cached per-message via `taTranslationStore` / `taStorage` (max 100 entries) +- The prompt was originally a regular prompt (`defaultPrompts`) and was moved to `specialPrompts` with `is_special: "1"` and `type: "1"` (reading email only) + +**Prompt Building** — `taPromptUtils.buildTranslationPrompt(fullMessage, lang)`: +- Retrieves the `prompt_translate_this` special prompt text +- Extracts the email body from the full message +- Combines prompt + language + body text +- Returns `{ promptText, promptInfo }` + ## Prompt Types Reference ``` diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index b61cd92b..7dbe7217 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -35,7 +35,7 @@ use_specific_integration (default: false) ### Special Prompt Integration Overrides -The 5 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`) each get their own `use_specific_integration` and `connection_type` keys: +The 6 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`, `translate`) each get their own `use_specific_integration` and `connection_type` keys: ``` {prefix}_use_specific_integration (default: false) @@ -98,6 +98,9 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `summarize_auto` | `1` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open) | | `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` always uses inline regardless of this setting. | | `summarize_max_display_length` | `0` | Maximum characters shown in inline summary before truncation. `0` = no limit (show full text). When set, text is truncated at a word boundary and a "See more"/"See less" toggle link is shown. | +| `translate` | `true` | Enable email translation | +| `translate_auto` | `0` | Auto-translate mode: `0` = disabled, `1` = manual (show button), `2` = automatic (translate on message open) | +| `translate_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. | ### Summarize Settings Page (`pages/summarize/`) @@ -120,6 +123,20 @@ The summarize settings page provides: - Each has Save/Reset buttons and placeholder autocomplete - Default text comes from i18n strings (`prompt_summarize_full_text`, etc.) +### Translate Settings Page (`pages/translate/`) + +The translate settings page provides: + +1. **Specific integration checkbox** — enables per-feature API override (like other special prompts) +2. **Auto-translate dropdown** (`translate_auto`) — three modes: + - `0` (Disabled) — no inline translations + - `1` (Manual) — shows a "Get AI Translation" button in message display + - `2` (Automatic) — generates translation immediately when message is opened +3. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`. +4. **One editable prompt** — the translation instruction prompt (`prompt_translate_this`) with Save/Reset buttons and placeholder autocomplete. Default text comes from i18n string `prompt_translate_this_full_text`. + +Unlike summarize, translation has no `display_mode` option (always inline) and no max display length setting. + ## Adding a New Preference 1. Add the key and default value to `prefs_default` in `options/mzta-options-default.js` diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 1eb3de51..3fc11537 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1181,6 +1181,209 @@ switch (message.command) { } return Promise.resolve(true); + case "showTranslation": + const existingTranslationGenerating = document.getElementById('mzta-translation-generating'); + if(existingTranslationGenerating) existingTranslationGenerating.remove(); + + const existingTranslationTriggerWrapper = document.getElementById('mzta-translation-trigger-wrapper'); + if(existingTranslationTriggerWrapper) existingTranslationTriggerWrapper.remove(); + const existingTranslationTriggerBtn = document.getElementById('mzta-translation-trigger'); + if(existingTranslationTriggerBtn) existingTranslationTriggerBtn.remove(); + + const existingTranslationBanner = document.getElementById('mzta-translation-banner'); + if(existingTranslationBanner) existingTranslationBanner.remove(); + + const translationData = message.data; + const translationContainer = document.createElement('div'); + translationContainer.id = 'mzta-translation-banner'; + + const isDarkTranslation = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorTranslation = isDarkTranslation ? '#1a2e2a' : '#e8f5e9'; + let textColorTranslation = isDarkTranslation ? '#c8e6c9' : '#1b5e20'; + let borderColorTranslation = isDarkTranslation ? '#2e5740' : '#a5d6a7'; + + if (translationData.error) { + bgColorTranslation = isDarkTranslation ? '#3a1a1a' : '#f7e6e6'; + textColorTranslation = isDarkTranslation ? '#ffcccc' : '#660000'; + borderColorTranslation = '#660000'; + } + + translationContainer.className = 'thunderai-translation-pane'; + translationContainer.style.cssText = `background-color: ${bgColorTranslation}; color: ${textColorTranslation}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorTranslation}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + + const translationHeader = document.createElement('div'); + translationHeader.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 6px;'; + + const translationIcon = document.createElement('img'); + translationIcon.src = browser.runtime.getURL("/images/ai_summary.png"); + translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslation ? ' filter: invert(1);' : ''}`; + + const translationTitleSpan = document.createElement('span'); + translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${textColorTranslation}; flex-grow: 1;`; + translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation"; + if (translationData.lang) { + translationTitleSpan.textContent += ' (' + translationData.lang + ')'; + } + + const translationMenu = createThreeDotsMenu(isDarkTranslation, [ + { + icon: '↻', + label: browser.i18n.getMessage("translate_refresh") || 'Refresh translation', + hoverColor: isDarkTranslation ? '#4d9de0' : '#1a5fa8', + disableAfterClick: true, + onClick: () => { + browser.runtime.sendMessage({ + command: "refreshTranslation", + headerMessageId: translationData.headerMessageId + }); + } + }, + { + icon: '×', + label: browser.i18n.getMessage("translate_delete") || 'Delete translation', + hoverColor: '#cc0000', + onClick: () => { + translationContainer.remove(); + browser.runtime.sendMessage({ + command: "removeTranslation", + headerMessageId: translationData.headerMessageId + }); + } + } + ], { bg: bgColorTranslation, border: borderColorTranslation, text: textColorTranslation }); + + translationHeader.appendChild(translationIcon); + translationHeader.appendChild(translationTitleSpan); + translationHeader.appendChild(translationMenu); + translationContainer.appendChild(translationHeader); + + const translationText = document.createElement('div'); + translationText.style.cssText = 'white-space: pre-wrap; line-height: 1.5;'; + if (translationData.error) { + translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error") || "Translation failed."; + } else { + translationText.textContent = translationData.translated_text || ''; + } + translationContainer.appendChild(translationText); + + const summaryBannerForTranslation = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-generating'); + const spamBannerForTranslation = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); + const insertAfterTranslation = summaryBannerForTranslation || spamBannerForTranslation; + if (insertAfterTranslation) { + document.body.insertBefore(translationContainer, insertAfterTranslation.nextSibling); + } else { + document.body.insertBefore(translationContainer, document.body.firstChild); + } + return Promise.resolve(true); + + case "showTranslationGenerating": + const existingTranslationGen = document.getElementById('mzta-translation-generating'); + if(existingTranslationGen) return Promise.resolve(true); + + const existingTranslationBannerGen = document.getElementById('mzta-translation-banner'); + if(existingTranslationBannerGen) existingTranslationBannerGen.remove(); + + const existingTranslationTrigWrap = document.getElementById('mzta-translation-trigger-wrapper'); + if(existingTranslationTrigWrap) existingTranslationTrigWrap.remove(); + const existingTranslationTrig = document.getElementById('mzta-translation-trigger'); + if(existingTranslationTrig) existingTranslationTrig.remove(); + + const isDarkTranslationGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorTranslationGen = isDarkTranslationGen ? '#1a2e2a' : '#e8f5e9'; + let textColorTranslationGen = isDarkTranslationGen ? '#c8e6c9' : '#1b5e20'; + let borderColorTranslationGen = isDarkTranslationGen ? '#2e5740' : '#a5d6a7'; + + const translationGenContainer = document.createElement('div'); + translationGenContainer.id = 'mzta-translation-generating'; + translationGenContainer.className = 'thunderai-translation-pane'; + translationGenContainer.style.cssText = `background-color: ${bgColorTranslationGen}; color: ${textColorTranslationGen}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorTranslationGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`; + + const translationGenIcon = document.createElement('img'); + translationGenIcon.src = browser.runtime.getURL("/images/ai_summary.png"); + translationGenIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslationGen ? ' filter: invert(1);' : ''}`; + + const translationGenLoadingImg = document.createElement('img'); + translationGenLoadingImg.src = browser.runtime.getURL("/images/loading.gif"); + translationGenLoadingImg.style.cssText = "height: 16px; width: 16px;"; + + const translationGenTitle = document.createElement('span'); + translationGenTitle.textContent = browser.i18n.getMessage("translate_generating") || "Translating..."; + translationGenTitle.style.cssText = `font-size: 14px;`; + + translationGenContainer.appendChild(translationGenIcon); + translationGenContainer.appendChild(translationGenLoadingImg); + translationGenContainer.appendChild(translationGenTitle); + + const summaryBannerForGen = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-generating'); + const spamBannerForGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); + const insertAfterGen = summaryBannerForGen || spamBannerForGen; + if (insertAfterGen) { + document.body.insertBefore(translationGenContainer, insertAfterGen.nextSibling); + } else { + document.body.insertBefore(translationGenContainer, document.body.firstChild); + } + return Promise.resolve(true); + + case "showTranslationButton": + const existingTranslationButton = document.getElementById('mzta-translation-trigger'); + if(existingTranslationButton) return Promise.resolve(true); + + const isDarkTranslationBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorTranslationBtn = isDarkTranslationBtn ? '#1a2e2a' : '#e8f5e9'; + let textColorTranslationBtn = isDarkTranslationBtn ? '#c8e6c9' : '#1b5e20'; + let borderColorTranslationBtn = isDarkTranslationBtn ? '#2e5740' : '#a5d6a7'; + + const spamBannerTranslationTrigger = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); + const summaryBannerTranslationTrigger = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-trigger-wrapper') || document.getElementById('mzta-summary-trigger'); + const translationTriggerBtn = document.createElement('div'); + translationTriggerBtn.id = 'mzta-translation-trigger'; + translationTriggerBtn.title = browser.i18n.getMessage("translate_click_to_generate") || "Click to translate this email"; + const translationTriggerBtnBase = `background-color: ${bgColorTranslationBtn}; border: 1px solid ${borderColorTranslationBtn}; border-radius: 4px; padding: 6px 10px; cursor: pointer; font-family: system-ui, -apple-system, sans-serif; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${textColorTranslationBtn}; display: inline-flex; align-items: center; gap: 6px; width: fit-content;`; + const insertAfterTranslationBtn = summaryBannerTranslationTrigger || spamBannerTranslationTrigger; + if (insertAfterTranslationBtn) { + translationTriggerBtn.style.cssText = translationTriggerBtnBase + ' margin-left: auto; margin-top: 4px;'; + } else { + translationTriggerBtn.style.cssText = translationTriggerBtnBase + ' position: fixed; top: 8px; right: 8px; z-index: 9997;'; + } + + const translationTriggerIcon = document.createElement('img'); + translationTriggerIcon.src = browser.runtime.getURL("/images/ai_summary.png"); + translationTriggerIcon.style.cssText = `height: 14px; width: 14px;${isDarkTranslationBtn ? ' filter: invert(1);' : ''}`; + translationTriggerBtn.appendChild(translationTriggerIcon); + + const translationTriggerLabel = document.createElement('span'); + translationTriggerLabel.textContent = browser.i18n.getMessage("get_ai_translation") || "Get AI Translation"; + translationTriggerBtn.appendChild(translationTriggerLabel); + translationTriggerBtn.onmouseover = () => { translationTriggerBtn.style.opacity = '1'; }; + translationTriggerBtn.onmouseout = () => { translationTriggerBtn.style.opacity = '0.7'; }; + translationTriggerBtn.onclick = async () => { + translationTriggerBtn.onclick = null; + translationTriggerBtn.style.cursor = 'default'; + translationTriggerBtn.style.opacity = '0.7'; + translationTriggerBtn.onmouseover = null; + translationTriggerBtn.onmouseout = null; + const wrapper = document.getElementById('mzta-translation-trigger-wrapper'); + if (wrapper) wrapper.remove(); else translationTriggerBtn.remove(); + browser.runtime.sendMessage({ + command: "triggerTranslationGeneration", + headerMessageId: message.headerMessageId + }); + }; + + if (insertAfterTranslationBtn) { + const translationTriggerWrapper = document.createElement('div'); + translationTriggerWrapper.id = 'mzta-translation-trigger-wrapper'; + translationTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;'; + translationTriggerWrapper.appendChild(translationTriggerBtn); + document.body.insertBefore(translationTriggerWrapper, insertAfterTranslationBtn.nextSibling); + } else { + document.body.appendChild(translationTriggerBtn); + } + return Promise.resolve(true); + default: // do nothing return Promise.resolve(false); @@ -1189,4 +1392,5 @@ switch (message.command) { }); browser.runtime.sendMessage({ command: "checkSpamReport" }); -browser.runtime.sendMessage({ command: "initSummary" }); \ No newline at end of file +browser.runtime.sendMessage({ command: "initSummary" }); +browser.runtime.sendMessage({ command: "initTranslation" }); \ No newline at end of file diff --git a/js/mzta-menus.js b/js/mzta-menus.js index eed540cb..1c86f99e 100644 --- a/js/mzta-menus.js +++ b/js/mzta-menus.js @@ -202,8 +202,8 @@ export class mzta_Menus { switch(curr_prompt.id){ case 'prompt_translate_this': - let prefs2 = await browser.storage.sync.get({default_chatgpt_lang: getLanguageDisplayName(browser.i18n.getUILanguage())}); - let chatgpt_lang2 = prefs2.default_chatgpt_lang; + let prefs2 = await browser.storage.sync.get({default_chatgpt_lang: getLanguageDisplayName(browser.i18n.getUILanguage()), translate_lang: ''}); + let chatgpt_lang2 = prefs2.translate_lang || prefs2.default_chatgpt_lang; if(chatgpt_lang2 === ''){ chatgpt_lang2 = getLanguageDisplayName(browser.i18n.getUILanguage()); } diff --git a/js/mzta-prompts.js b/js/mzta-prompts.js index d7603ff1..57b14678 100644 --- a/js/mzta-prompts.js +++ b/js/mzta-prompts.js @@ -235,24 +235,6 @@ const defaultPrompts = [ is_default: "1", is_special: "0", }, - { - id: 'prompt_translate_this', - name: "__MSG_prompt_translate_this__", - text: "prompt_translate_this_full_text", - type: "0", - action: "0", - need_selected: "0", - need_signature: "0", - need_custom_text: "0", - define_response_lang: "0", - use_diff_viewer: "0", - chatgpt_web_model: '', - chatgpt_web_project: '', - chatgpt_web_custom_gpt: '', - api_type: '', - is_default: "1", - is_special: "0", - }, { id: 'prompt_this', name: "__MSG_prompt_this__", @@ -396,6 +378,22 @@ const specialPrompts = [ api_model: '', is_default: "1", is_special: "1", + }, + { + id: 'prompt_translate_this', + name: "__MSG_prompt_translate_this__", + text: "prompt_translate_this_full_text", + type: "1", + action: "0", + need_selected: "0", + need_signature: "0", + need_custom_text: "0", + define_response_lang: "0", + use_diff_viewer: "0", + api_type: '', + api_model: '', + is_default: "1", + is_special: "1", } ]; diff --git a/js/mzta-storage.js b/js/mzta-storage.js index b5699618..9b29c6b5 100644 --- a/js/mzta-storage.js +++ b/js/mzta-storage.js @@ -278,7 +278,7 @@ export class taStorage { * @param {string} lang - Target language code. * @param {boolean} [force=true] - If true, overwrite existing translation data. */ - async writeTranslation(messageId, translated_text, lang, force = true) { + async writeTranslation(messageId, translated_text, lang, force = true, error = false, error_message = '') { this.taLog.log('[writeTranslation] messageId: ' + messageId + ', lang: ' + lang + ', force: ' + force); try { let key = this._buildKey(messageId); @@ -288,7 +288,7 @@ export class taStorage { return; } let now = Date.now(); - record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, ts: now }; + record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, error: error, message: error_message, ts: now }; record.ts = now; await messenger.storage.local.set({ [key]: record }); } catch (e) { @@ -296,6 +296,64 @@ export class taStorage { } } + /** + * Get all records that have a translation field. + * @returns {Promise} A map of messageId → translation data. + */ + async getAllTranslationRecords() { + this.taLog.log('[getAllTranslationRecords] loading all translation records'); + try { + let all = await messenger.storage.local.get(null); + let result = {}; + for (let [key, record] of Object.entries(all)) { + if (!key.startsWith(taStorage.STORAGE_KEY_PREFIX)) continue; + if (!this.hasField(record, taStorage.FIELD_TRANSLATION)) continue; + let messageId = key.slice(taStorage.STORAGE_KEY_PREFIX.length); + let translation = record[taStorage.FIELD_TRANSLATION]; + result[messageId] = { + headerMessageId: messageId, + translated_text: translation.translated_text, + lang: translation.lang || '', + error: translation.error || false, + message: translation.message || '', + translation_date: new Date(translation.ts), + }; + } + return result; + } catch (e) { + this.taLog.error('getAllTranslationRecords error: ' + e); + return {}; + } + } + + /** + * Delete only the translation field from a record. + * Deletes the entire record if no other data fields remain. + * @param {string} messageId - The Message-ID header string. + */ + async deleteTranslationField(messageId) { + this.taLog.log('[deleteTranslationField] messageId: ' + messageId); + try { + let key = this._buildKey(messageId); + let record = await this.getRecord(messageId); + if (!record || !(taStorage.FIELD_TRANSLATION in record)) { + this.taLog.log('[deleteTranslationField] no translation field found for messageId: ' + messageId); + return; + } + delete record[taStorage.FIELD_TRANSLATION]; + const remainingFields = Object.keys(record).filter(k => k !== 'v' && k !== 'ts'); + if (remainingFields.length === 0) { + this.taLog.log('[deleteTranslationField] no remaining fields, deleting entire record'); + await messenger.storage.local.remove(key); + } else { + this.taLog.log('[deleteTranslationField] remaining fields: ' + remainingFields.join(', ')); + await messenger.storage.local.set({ [key]: record }); + } + } catch (e) { + this.taLog.error('deleteTranslationField error: ' + e); + } + } + /** * Delete the entire record for a given Message-ID. * @param {string} messageId - The Message-ID header string. diff --git a/js/mzta-translationstore.js b/js/mzta-translationstore.js new file mode 100644 index 00000000..4f3dbe48 --- /dev/null +++ b/js/mzta-translationstore.js @@ -0,0 +1,150 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 - 2026 Mic (m@micz.it) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { taStorage } from './mzta-storage.js'; +import { taLogger } from './mzta-logger.js'; + +export class taTranslationStore { + + _processing_prefix = 'mzta-translation-processing-'; + _max_translations = 100; + _storage = null; + taLog = null; + + constructor(do_debug = false) { + this._storage = new taStorage(do_debug); + this.taLog = new taLogger('mzta-translationstore', do_debug); + } + + async setProcessing(data_id) { + this.taLog.log("[setProcessing] data_id: " + data_id); + const key = this._processing_prefix + data_id; + await browser.storage.session.set({ [key]: true }); + } + + async isProcessing(data_id) { + this.taLog.log("[isProcessing] data_id: " + data_id); + const key = this._processing_prefix + data_id; + let output = await browser.storage.session.get(key); + let result = output[key] || false; + this.taLog.log("[isProcessing] result: " + result); + return result; + } + + async saveTranslation(data, data_id) { + this.taLog.log("[saveTranslation] data_id: " + data_id); + try { + await this._storage.writeTranslation(data_id, data.translated_text || '', data.lang || '', true, data.error || false, data.message || ''); + await browser.storage.session.remove(this._processing_prefix + data_id); + } catch (e) { + this.taLog.error("[saveTranslation] error: " + e); + throw e; + } + } + + async saveError(data_id, error_message) { + this.taLog.log("[saveError] data_id: " + data_id + ", error_message: " + error_message); + let data = { + translated_text: '', + lang: '', + error: true, + message: error_message, + headerMessageId: data_id + }; + await this.saveTranslation(data, data_id); + return data; + } + + async loadTranslation(data_id) { + this.taLog.log("[loadTranslation] data_id: " + data_id); + let record = await this._storage.getRecord(data_id); + if (!record || !this._storage.hasField(record, taStorage.FIELD_TRANSLATION)) { + this.taLog.log("[loadTranslation] no record found for data_id: " + data_id); + return null; + } + let translation = record[taStorage.FIELD_TRANSLATION]; + return { + headerMessageId: data_id, + translated_text: translation.translated_text || '', + lang: translation.lang || '', + error: translation.error || false, + message: translation.message || '', + translation_date: new Date(translation.ts), + }; + } + + async removeTranslation(data_id) { + this.taLog.log("[removeTranslation] data_id: " + data_id); + await this._storage.deleteTranslationField(data_id); + await browser.storage.session.remove(this._processing_prefix + data_id); + } + + async getAllTranslations() { + this.taLog.log("[getAllTranslations] loading all translations"); + return await this._storage.getAllTranslationRecords(); + } + + async clearTranslations() { + this.taLog.log("[clearTranslations] clearing all translation data"); + let allTranslations = await this._storage.getAllTranslationRecords(); + let translationKeys = Object.keys(allTranslations); + this.taLog.log("[clearTranslations] deleting " + translationKeys.length + " translation records"); + for (let messageId of translationKeys) { + await this._storage.deleteTranslationField(messageId); + } + let allSession = await browser.storage.session.get(null); + let keysToDelete = Object.keys(allSession).filter(k => k.startsWith(this._processing_prefix)); + this.taLog.log("[clearTranslations] deleting " + keysToDelete.length + " session keys"); + for (let key of keysToDelete) { + await browser.storage.session.remove(key); + } + } + + async truncTranslations() { + this.taLog.log("[truncTranslations] checking translation count"); + let data = await this._storage.getAllTranslationRecords(); + let sortedData = this.sortTranslationsByDate(data); + let keys = Object.keys(sortedData); + this.taLog.log("[truncTranslations] total translations: " + keys.length + ", max: " + this._max_translations); + + if (keys.length > this._max_translations) { + let toDelete = keys.length - this._max_translations; + this.taLog.log("[truncTranslations] truncating " + toDelete + " oldest translations"); + for (let i = this._max_translations; i < keys.length; i++) { + await this._storage.deleteTranslationField(keys[i]); + } + } + } + + sortTranslationsByDate(data) { + if (!data) return {}; + const translationKeys = Object.keys(data); + translationKeys.sort((a, b) => { + const dateA = new Date(data[a].translation_date); + const dateB = new Date(data[b].translation_date); + return dateB - dateA; + }); + + let sortedTranslations = {}; + translationKeys.forEach((key) => { + sortedTranslations[key] = data[key]; + }); + + return sortedTranslations; + } +} diff --git a/js/mzta-utils-prompt.js b/js/mzta-utils-prompt.js index c81588c2..b123ff29 100644 --- a/js/mzta-utils-prompt.js +++ b/js/mzta-utils-prompt.js @@ -173,6 +173,26 @@ export const taPromptUtils = { return { promptText, promptInfo: prompt }; }, + async buildTranslationPrompt(fullMessage, lang) { + const specialPrompts = await getSpecialPrompts(); + const prompt = specialPrompts.find(p => p.id === 'prompt_translate_this'); + + let promptText = prompt.text; + if (promptText === 'prompt_translate_this_full_text') { + promptText = browser.i18n.getMessage('prompt_translate_this_full_text'); + } + + const bodyHtml = getMailBody(fullMessage); + let bodyText = htmlBodyToPlainText(bodyHtml.html); + if (bodyText.length === 0) { + bodyText = bodyHtml.text || ''; + } + + const fullPrompt = promptText + " " + lang + ". \"" + bodyText + "\""; + + return { promptText: fullPrompt, promptInfo: prompt }; + }, + /** * Extracts tags from the response text. * @param {string} response_text - The response text from which to extract tags. diff --git a/mzta-background.js b/mzta-background.js index 29ec2828..88d0dae4 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -62,6 +62,7 @@ import { } from './js/mzta-prompts.js'; import { taSpamReport } from './js/mzta-spamreport.js'; import { taSummaryStore } from './js/mzta-summarystore.js'; +import { taTranslationStore } from './js/mzta-translationstore.js'; import { taWorkingStatus } from './js/mzta-working-status.js'; import { addTags_getExclusionList, @@ -95,6 +96,7 @@ let taLog = new taLogger("mzta-background",prefs_init.do_debug); taWorkingStatus.taLog = taLog; let spamReport = new taSpamReport(prefs_init.do_debug); let summaryStore = new taSummaryStore(prefs_init.do_debug); +let translationStore = new taTranslationStore(prefs_init.do_debug); let special_prompts_ids = getActiveSpecialPromptsIDs({ addtags: prefs_init.add_tags, @@ -328,6 +330,61 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { // case 'chatgpt_open': // openChatGPT(message.prompt,message.action,message.tabId); // return true; + case 'initTranslation': + async function _initTranslation() { + try { + let tabId = sender.tab.id; + let prefs = await browser.storage.sync.get({ translate: prefs_default.translate, translate_auto: prefs_default.translate_auto }); + + if (!prefs.translate) return; + + let message = await browser.messageDisplay.getDisplayedMessage(tabId); + if (!message) return; + + let cachedTranslation = await translationStore.loadTranslation(message.headerMessageId); + if (cachedTranslation && !cachedTranslation.error) { + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } }); + return; + } + + if (await translationStore.isProcessing(message.headerMessageId)) { + browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + return; + } + + if (prefs.translate_auto === 0) return; + + if (prefs.translate_auto === 2) { + _generateTranslationForMessage(message.headerMessageId, tabId); + return; + } + + // Manual button mode (translate_auto === 1) + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId }); + } catch (e) { + taLog.error("Error in initTranslation: " + e); + } + } + _initTranslation(); + break; + case 'triggerTranslationGeneration': + async function _triggerTranslationGeneration(message) { + let tabId = sender.tab.id; + await _generateTranslationForMessage(message.headerMessageId, tabId); + } + _triggerTranslationGeneration(message); + break; + case 'refreshTranslation': + async function _refreshTranslation(message) { + let tabId = sender.tab.id; + await translationStore.removeTranslation(message.headerMessageId); + await _generateTranslationForMessage(message.headerMessageId, tabId); + } + _refreshTranslation(message); + break; + case 'removeTranslation': + translationStore.removeTranslation(message.headerMessageId); + break; case 'chatgpt_close': async function _closeChatGptWindow(window_id) { let prefs_close = await browser.storage.sync.get({chatgpt_win_save_position: prefs_default.chatgpt_win_save_position}); @@ -583,6 +640,81 @@ async function _generateSummaryForMessage(headerMessageId, tabId) { } } +async function _generateTranslationForMessage(headerMessageId, tabId) { + try { + let prefs = await browser.storage.sync.get({ + connection_type: prefs_default.connection_type, + do_debug: prefs_default.do_debug, + default_chatgpt_lang: prefs_default.default_chatgpt_lang, + translate_lang: prefs_default.translate_lang, + ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) + }); + + let cachedTranslation = await translationStore.loadTranslation(headerMessageId); + if (cachedTranslation && !cachedTranslation.error) { + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } }); + return; + } + + if (await translationStore.isProcessing(headerMessageId)) { + browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + return; + } + + await translationStore.setProcessing(headerMessageId); + taWorkingStatus.startWorking(); + browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + + const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); + if (!messageResult || messageResult.messages.length === 0) { + await translationStore.saveError(headerMessageId, "Message not found"); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: "Message not found" } }); + taWorkingStatus.stopWorking(); + return; + } + + const fullMessage = await browser.messages.getFull(messageResult.messages[0].id); + + const connectionType = getConnectionType(prefs, {}, 'translate'); + + if (connectionType === 'chatgpt_web') { + const errorMsg = browser.i18n.getMessage('translate_chatgpt_web_not_supported'); + await translationStore.saveError(headerMessageId, errorMsg); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: errorMsg } }); + taWorkingStatus.stopWorking(); + return; + } + + const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; + const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage, lang); + + const cmd = new mzta_specialCommand({ + prompt: promptText, + llm: connectionType, + do_debug: prefs.do_debug, + config: {} + }); + + await cmd.initWorker(); + const aiResponse = await cmd.sendPrompt(); + + const translationData = { + translated_text: aiResponse, + lang: lang, + headerMessageId: headerMessageId + }; + await translationStore.saveTranslation(translationData, headerMessageId); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData } }); + taWorkingStatus.stopWorking(); + + } catch (error) { + console.error("[ThunderAI] Error generating translation:", error); + await translationStore.saveError(headerMessageId, error.message || String(error)); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: error.message || "Failed to generate translation" } }); + taWorkingStatus.stopWorking(); + } +} + // options.messageData: { message, fullMessage, body_text, msg_text } — pass pre-fetched data to avoid re-querying // options.prefs: pass pre-fetched prefs to avoid re-querying // options.autoMove: if true, move spam messages to junk folder (default: false) diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index c7cbfcd8..a9bbb91e 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task']; +const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task', 'translate']; export const integration_options_config = { chatgpt: { @@ -141,6 +141,9 @@ export const prefs_default = { summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic summarize_display_mode: 'inline', // 'inline' or 'webchat' summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline + translate: true, + translate_auto: 0, // 0: disabled, 1: manual button, 2: automatic + translate_lang: '', // target language, fallback on default_chatgpt_lang spamfilter_show_msg_panel: true, ...generated_prefs } diff --git a/options/mzta-options.html b/options/mzta-options.html index 0033a590..aa2231e5 100644 --- a/options/mzta-options.html +++ b/options/mzta-options.html @@ -203,6 +203,17 @@ + + __MSG_prefs_OptionText_translate__ +
+ + + + __MSG_prefs_OptionText_get_calendar_event__
diff --git a/options/mzta-options.js b/options/mzta-options.js index 51baa538..9231eaf9 100644 --- a/options/mzta-options.js +++ b/options/mzta-options.js @@ -195,6 +195,27 @@ function disable_Summarize(prefs_opt){ } } +function disable_Translate(prefs_opt){ + let translate = document.getElementById('translate'); + let conntype_select = document.getElementById("connection_type"); + const tempPrefs = { + connection_type: conntype_select.value, + ...prefs_opt + }; + let translate_disabled = (getConnectionType(tempPrefs, null, 'translate') === "chatgpt_web"); + let translate_checked_original = translate.checked; + translate.checked = translate_disabled ? false : translate.checked; + if(!translate.checked){ + let translate_info_btn = document.getElementById('btnManageTranslateInfo'); + translate_info_btn.disabled = 'disabled'; + } + let translate_warn_API_needed = document.getElementById('translate_warn_API_needed'); + translate_warn_API_needed.style.display = (translate_disabled) ? 'inline-block' : 'none'; + if(translate_checked_original != translate.checked){ + browser.storage.sync.set({translate: translate.checked}); + } +} + async function disable_GetCalendarEvent(){ let get_calendar_event = document.getElementById('get_calendar_event'); let get_task = document.getElementById('get_task'); @@ -300,6 +321,13 @@ document.addEventListener('DOMContentLoaded', async () => { }); summarize_info_btn.disabled = summarize_el.checked ? '' : 'disabled'; + let translate_el = document.getElementById('translate'); + let translate_info_btn = document.getElementById('btnManageTranslateInfo'); + translate_el.addEventListener('click', (event) => { + translate_info_btn.disabled = event.target.checked ? '' : 'disabled'; + }); + translate_info_btn.disabled = translate_el.checked ? '' : 'disabled'; + let get_calendar_event_el = document.getElementById('get_calendar_event'); let get_calendar_event_info_btn = document.getElementById('btnManageCalendarEventInfo'); get_calendar_event_el.addEventListener('click', (event) => { @@ -334,6 +362,10 @@ document.addEventListener('DOMContentLoaded', async () => { openTab('/pages/summarize/mzta-summarize.html'); }); + document.getElementById('btnManageTranslateInfo').addEventListener('click', () => { + openTab('/pages/translate/mzta-translate.html'); + }); + document.getElementById('btnManageCalendarEventInfo').addEventListener('click', () => { openTab('/pages/get-calendar-event/mzta-get-calendar-event.html'); }); @@ -360,13 +392,15 @@ document.addEventListener('DOMContentLoaded', async () => { conntype_select.addEventListener("change", () => disable_AddTags(prefs_opt)); conntype_select.addEventListener("change", () => disable_SpamFilter(prefs_opt)); conntype_select.addEventListener("change", () => disable_Summarize(prefs_opt)); + conntype_select.addEventListener("change", () => disable_Translate(prefs_opt)); conntype_select.addEventListener("change", disable_GetCalendarEvent); - + showConnectionOptions(conntype_select); disable_MaxPromptLength(); disable_AddTags(prefs_opt); disable_SpamFilter(prefs_opt); disable_Summarize(prefs_opt); + disable_Translate(prefs_opt); disable_GetCalendarEvent(); document.getElementById('reset_max_prompt_length').addEventListener('click', resetMaxPromptLength); diff --git a/pages/translate/mzta-translate.css b/pages/translate/mzta-translate.css new file mode 100644 index 00000000..8e8fbd16 --- /dev/null +++ b/pages/translate/mzta-translate.css @@ -0,0 +1,180 @@ +#translate_prompt_container { + width: 90%; + margin: 20px auto; +} + +#translate_prompt_text { + width: 100%; +} + +.infoline { + font-size: 0.8em; + font-style: italic; +} + +#account_selector_container{ + display: none; +} + +#account_selector_checkboxes{ + padding: 10px; + border: 1px solid gray; + width: 24em; + margin-bottom: 10px; + margin-top: 10px; +} + +.specific_integration{ + background-color: #dfeaff; +} + +table { + border-collapse: separate; + border-spacing: 0; +} + +.group td { + border-top: none; + border-bottom: none; +} + +.specific_integration_sub td:first-child { + border-left: 10px solid #dfeaff; +} + +.specific_integration_sub td:last-child { + border-right: 10px solid #dfeaff; +} + +#connection_ui_end td{ + height: 6px; + background-color: #dfeaff; +} + +#connection_ui_end{ + display: none; +} + +.btn_div { + width: 100%; + display: flex; + justify-content: space-between; +} + +table#miczPrefs { + padding: 10px; + border-spacing: 0px; + width: 90%; + margin: 0px auto 40px auto; + border: 1px solid #ccc; +} + +.translate_table_title{ + width: 90%; + margin: auto; +} + +.section_title { + font-weight: bold; +} + +table#miczPrefs td { + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; + vertical-align: top; + padding: 2px; +} + +table#miczPrefs tr:first-child td { + border-top: none; +} + +table#miczPrefs tr:last-child td { + border-bottom: none; +} + +.autocomplete-container { + position: relative; +} + +.autocomplete-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + background-color: white; + border: 1px solid #ccc; + z-index: 1000; + max-height: 200px; + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; + font-size: small; +} + +.autocomplete-list li { + padding: 8px; + cursor: pointer; +} + +.autocomplete-list li:hover { + background-color: #f0f0f0; +} + +.autocomplete-list li.active { + background-color: #ddd; +} + +.hidden { + display: none; +} + +.unsaved { + color: red; +} + +label:has(input[type="checkbox"]) { + cursor: pointer; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #1c1b22; + color: rgb(251, 251, 254); + } + + a:link { color: #409eff; } + a:visited { color: #409eff; } + a:hover { color: #66b1ff; } + a:active { color: #66b1ff; } + + .autocomplete-list { + background-color: #2e2f36; + border: 1px solid #2e2f36; + } + + .autocomplete-list li:hover { + background-color: #4c4e58; + } + + .autocomplete-list li.active { + background-color: #4c4e58; + } + + .specific_integration{ + background-color: #2E3A4F; + } + + .specific_integration_sub td:first-child { + border-left: 10px solid #2E3A4F; + } + + .specific_integration_sub td:last-child { + border-right: 10px solid #2E3A4F; + } + + #connection_ui_end td{ + background-color: #2E3A4F; + } +} diff --git a/pages/translate/mzta-translate.html b/pages/translate/mzta-translate.html new file mode 100644 index 00000000..d1a0d187 --- /dev/null +++ b/pages/translate/mzta-translate.html @@ -0,0 +1,97 @@ + + + + + ThunderAI - __MSG_Translate_PageTitle__ + + + + + +
+

__MSG_Translate_PageTitle__

+

__MSG_Translate_info_default__

+
+
__MSG_Translate_prompt_prefs_title__
+ + + + + + + + + + + + + + + +
__MSG_prefs_OptionText_use_specific_integration__ + + +
__MSG_prefs_OptionText_translate_auto__ + +
__MSG_prefs_OptionText_translate_lang__ + +
+ + +
+ + + __MSG_Translate_prompt_text_title__ + + +
+ + + __MSG_prefs_OptionText_btnManagePrompts_infoline__ + + __MSG_more_info_string__ + + + +
+ + + + __MSG_prefs_OptionText_Translate_main_prompt__ + + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + + + diff --git a/pages/translate/mzta-translate.js b/pages/translate/mzta-translate.js new file mode 100644 index 00000000..2c1125a9 --- /dev/null +++ b/pages/translate/mzta-translate.js @@ -0,0 +1,232 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 - 2026 Mic (m@micz.it) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + prefs_default, + integration_options_config +} from '../../options/mzta-options-default.js'; +import { taLogger } from "../../js/mzta-logger.js"; +import { + getSpecialPrompts, + setSpecialPrompts +} from "../../js/mzta-prompts.js"; +import { + getPlaceholders, + mapPlaceholderToSuggestion +} from "../../js/mzta-placeholders.js"; +import { textareaAutocomplete } from "../../js/mzta-placeholders-autocomplete.js"; +import { + normalizeStringList, + isAPIKeyValue, + setTomSelectBorder +} from "../../js/mzta-utils.js"; +import { + initializeSpecificIntegrationUI +} from "../_lib/connection-ui.js"; + +let autocompleteSuggestions = []; +let taLog = new taLogger("mzta-translate-page", true); + +document.addEventListener("DOMContentLoaded", async () => { + + let specialPrompts = await getSpecialPrompts(); + let translate_prompt = specialPrompts.find((prompt) => prompt.id === 'prompt_translate_this'); + + if (translate_prompt && translate_prompt.api_type && translate_prompt.api_type !== '') { + let update_prefs = {}; + update_prefs['translate_connection_type'] = translate_prompt.api_type; + + let integration = translate_prompt.api_type.replace('_api', ''); + if (integration_options_config && integration_options_config[integration]) { + for (const key of Object.keys(integration_options_config[integration])) { + if (translate_prompt[key] !== undefined) { + update_prefs[`translate_${integration}_${key}`] = translate_prompt[key]; + } + } + } + await browser.storage.sync.set(update_prefs); + } + + await initializeSpecificIntegrationUI({ + prefix: 'translate', + promptId: 'prompt_translate_this', + taLog: taLog, + restoreOptionsCallback: restoreOptions + }); + + i18n.updateDocument(); + + document.querySelectorAll(".option-input").forEach(element => { + element.addEventListener("change", saveOptions); + }); + + let translate_textarea = document.getElementById("translate_prompt_text"); + let translate_save_btn = document.getElementById("btn_save_prompt"); + let translate_reset_btn = document.getElementById("btn_reset_prompt"); + + // on changing textarea + translate_textarea.addEventListener("input", (event) => { + translate_reset_btn.disabled = (event.target.value === browser.i18n.getMessage('prompt_translate_this_full_text')); + translate_save_btn.disabled = (event.target.value === translate_prompt.text); + }); + + // on clicking reset button + translate_reset_btn.addEventListener("click", () => { + translate_textarea.value = browser.i18n.getMessage("prompt_translate_this_full_text"); + translate_reset_btn.disabled = true; + let event = new Event("input", { bubbles: true, cancelable: true }); + translate_textarea.dispatchEvent(event); + }); + + // on clicking save button + translate_save_btn.addEventListener("click", () => { + specialPrompts.find(prompt => prompt.id === 'prompt_translate_this').text = translate_textarea.value; + setSpecialPrompts(specialPrompts); + translate_save_btn.disabled = true; + browser.runtime.sendMessage({ command: "reload_menus" }); + }); + + if(translate_prompt.text === 'prompt_translate_this_full_text'){ + translate_prompt.text = browser.i18n.getMessage(translate_prompt.text); + } + + translate_textarea.value = translate_prompt.text; + translate_reset_btn.disabled = (translate_textarea.value === browser.i18n.getMessage("prompt_translate_this_full_text")); + + autocompleteSuggestions = (await getPlaceholders(true)) + .filter((p) => p.id !== "additional_text") + .map(mapPlaceholderToSuggestion); + + textareaAutocomplete(translate_textarea, autocompleteSuggestions, 1); + +}); + +// Methods to manage options, derived from: /options/mzta-options.js + +function saveOptions(e) { + e.preventDefault(); + let options = {}; + let element = e.target; + switch (element.type) { + case 'checkbox': + options[element.id] = element.checked; + break; + case 'number': + options[element.id] = element.valueAsNumber; + break; + case 'text': + case 'password': + options[element.id] = element.value.trim(); + break; + case 'select-one': + if (element.id === 'translate_auto') { + options[element.id] = parseInt(element.value, 10); + } else { + options[element.id] = element.value; + } + break; + case 'textarea': + options[element.id] = normalizeStringList(element.value); + break; + default: + console.error("[ThunderAI] Unhandled input type:", element.type); + } + + browser.storage.sync.set(options); +} + +async function restoreOptions() { + function setCurrentChoice(result) { + document.querySelectorAll(".option-input").forEach(element => { + if(!element.id) return; + taLog.log("Options restoring " + element.id + " = " + (isAPIKeyValue(element.id) ? "****************" : result[element.id])); + switch (element.type) { + case 'checkbox': + element.checked = result[element.id] || false; + break; + case 'number': + element.value = result[element.id] ?? 0; + break; + case 'text': + case 'textarea': + case 'password': + let default_text_value = ''; + if(element.id == 'translate_lang') default_text_value = prefs_default.default_chatgpt_lang; + element.value = result[element.id] || default_text_value; + break; + default: + if (element.tagName === 'SELECT') { + let default_select_value = 0; + if (element.id === 'translate_auto') { + default_select_value = prefs_default.translate_auto; + } + const restoreValue = result[element.id] ?? default_select_value; + let optionExists = Array.from(element.options).some(opt => opt.value === String(restoreValue)); + if (element.tomselect) { + if (!optionExists && restoreValue !== '') { + element.tomselect.addOption({ value: String(restoreValue), text: String(restoreValue) }); + } + element.tomselect.setValue(String(restoreValue), true); + setTomSelectBorder(element.tomselect); + } else { + if (!optionExists && restoreValue !== '') { + let newOption = new Option(restoreValue, restoreValue); + element.add(newOption); + } + element.value = restoreValue; + if (element.value === '') { + element.selectedIndex = 0; + } + } + }else{ + console.error("[ThunderAI] Unhandled input type:", element.type); + } + } + }); + } + + let getting = await browser.storage.sync.get(prefs_default); + + let specialPrompts = await getSpecialPrompts(); + let translate_prompt = specialPrompts.find(prompt => prompt.id === 'prompt_translate_this'); + + if (translate_prompt) { + if (translate_prompt.api_type && translate_prompt.api_type !== '') { + getting['translate_connection_type'] = translate_prompt.api_type; + } else { + getting['translate_connection_type'] = getting['connection_type']; + } + for (const [integration, options] of Object.entries(integration_options_config)) { + for (const key of Object.keys(options)) { + const propName = `${integration}_${key}`; + if (translate_prompt[propName] !== undefined && translate_prompt[propName] !== '') { + getting[`translate_${propName}`] = translate_prompt[propName]; + } else { + getting[`translate_${propName}`] = getting[propName]; + } + } + } + } + + // If translate_lang is empty, show default_chatgpt_lang as placeholder/default + if (!getting['translate_lang']) { + getting['translate_lang'] = ''; + } + + setCurrentChoice(getting); +}