From 32abc00693135414e07a20a28767123acf86313e Mon Sep 17 00:00:00 2001 From: mic Date: Fri, 27 Mar 2026 23:59:17 +0100 Subject: [PATCH 01/32] 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); +} From ef7483031d9a71fdc7521c0f4c38a37f948f3acb Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 00:21:45 +0100 Subject: [PATCH 02/32] translate_display_mode and translate_max_display_length added. see #247 --- _locales/en/messages.json | 62 +++++++++++------- api_webchat/messagesArea.js | 25 +++++++- claude-spec/01-architecture.md | 16 +++-- claude-spec/02-prompts.md | 8 ++- claude-spec/05-options.md | 13 ++-- js/mzta-compose-script.js | 60 +++++++++++++++++- mzta-background.js | 97 +++++++++++++++++++++++++++-- options/mzta-options-default.js | 2 + pages/summarize/mzta-summarize.html | 10 +-- pages/translate/mzta-translate.html | 27 +++++++- pages/translate/mzta-translate.js | 3 + 11 files changed, 270 insertions(+), 53 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 49c64d6d..6c81b13b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -239,6 +239,10 @@ "message": "Save as Summary", "description": "Button label in the webchat window to save the AI response as a message summary" }, + "webchat_save_as_translation": { + "message": "Save as Translation", + "description": "Button label in the webchat window to save the AI response as a message translation" + }, "chatgpt_textarea_not_found_error": { "message": "It seems that the ChatGPT page is taking too long to load. If it finishes loading, click the button on the right. If the problem persists, please check the service status.", "description": "" @@ -1957,18 +1961,6 @@ "message": "Auto-summarize messages", "description": "" }, - "prefs_OptionText_summarize_auto_disabled": { - "message": "Disabled", - "description": "" - }, - "prefs_OptionText_summarize_auto_manual": { - "message": "Show summary button", - "description": "" - }, - "prefs_OptionText_summarize_auto_automatic": { - "message": "Generate automatically", - "description": "" - }, "prefs_OptionText_summarize_auto_Info": { "message": "Choose whether to automatically generate summaries when viewing messages. Requires an API-based connection (not ChatGPT Web).", "description": "" @@ -1977,14 +1969,6 @@ "message": "Display summary in", "description": "" }, - "prefs_OptionText_summarize_display_mode_inline": { - "message": "Message pane (inline)", - "description": "" - }, - "prefs_OptionText_summarize_display_mode_webchat": { - "message": "Chat window", - "description": "" - }, "prefs_OptionText_summarize_display_mode_Info": { "message": "Choose where the summary result is displayed. Inline mode shows a summary banner directly in the message pane. Chat window mode opens the AI chat window.", "description": "" @@ -2085,15 +2069,15 @@ "message": "Auto-translate messages", "description": "" }, - "prefs_OptionText_translate_auto_disabled": { + "prefs_OptionText_action_auto_disabled": { "message": "Disabled", "description": "" }, - "prefs_OptionText_translate_auto_manual": { + "prefs_OptionText_action_auto_manual": { "message": "Manual button only", "description": "" }, - "prefs_OptionText_translate_auto_automatic": { + "prefs_OptionText_action_auto_automatic": { "message": "Automatic", "description": "" }, @@ -2101,6 +2085,38 @@ "message": "Choose when to translate messages: disabled, only when clicking the button, or automatically when opening a message.", "description": "" }, + "prefs_OptionText_translate_display_mode": { + "message": "Display mode for translations", + "description": "" + }, + "prefs_OptionText_display_mode_inline": { + "message": "Message pane (inline)", + "description": "" + }, + "prefs_OptionText_display_mode_webchat": { + "message": "Chat window", + "description": "" + }, + "prefs_OptionText_translate_display_mode_Info": { + "message": "Choose where to display translations. Note: automatic mode always uses inline display.", + "description": "" + }, + "prefs_OptionText_translate_max_display_length": { + "message": "Maximum length of displayed translation", + "description": "" + }, + "prefs_OptionText_translate_max_display_length_Info": { + "message": "Maximum number of characters shown in the inline translation. 0 = no limit. When set, longer text is truncated with a \"See more\" toggle.", + "description": "" + }, + "translate_see_more": { + "message": "See more", + "description": "" + }, + "translate_see_less": { + "message": "See less", + "description": "" + }, "prefs_OptionText_translate_lang": { "message": "Translation target language", "description": "" diff --git a/api_webchat/messagesArea.js b/api_webchat/messagesArea.js index 6c01ddca..62fffa42 100644 --- a/api_webchat/messagesArea.js +++ b/api_webchat/messagesArea.js @@ -459,7 +459,7 @@ class MessagesArea extends HTMLElement { } // Save as Summary button (only shown for summary webchat sessions) - if(promptData.prompt_info?.headerMessageId) { + if(promptData.prompt_info?.headerMessageId && promptData.prompt_info?.summaryTabId) { const saveSummaryButton = document.createElement('button'); saveSummaryButton.textContent = browser.i18n.getMessage("webchat_save_as_summary"); saveSummaryButton.classList.add('action_btn'); @@ -481,6 +481,29 @@ class MessagesArea extends HTMLElement { selectionInfo.style.display = "block"; } + // Save as Translation button (only shown for translation webchat sessions) + if(promptData.prompt_info?.headerMessageId && promptData.prompt_info?.translationTabId) { + const saveTranslationButton = document.createElement('button'); + saveTranslationButton.textContent = browser.i18n.getMessage("webchat_save_as_translation"); + saveTranslationButton.classList.add('action_btn'); + saveTranslationButton.addEventListener('click', async () => { + let finalText = removeAloneBRs(fullTextHTMLAtAssignment); + const selectedHTML = this.getCurrentSelectionHTML(); + if(selectedHTML != "") { + finalText = removeAloneBRs(selectedHTML); + } + await browser.runtime.sendMessage({ + command: "chatgpt_saveTranslation", + text: finalText, + headerMessageId: promptData.prompt_info.headerMessageId, + tabId: promptData.prompt_info.translationTabId || promptData.tabId, + }); + browser.runtime.sendMessage({command: "chatgpt_close", window_id: (await browser.windows.getCurrent()).id}); + }); + actionButtons.appendChild(saveTranslationButton); + selectionInfo.style.display = "block"; + } + // diff viewer button if(promptData.prompt_info?.use_diff_viewer == "1") { const diffvButton = document.createElement('button'); diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index a679df6f..652c8fcf 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -80,11 +80,13 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) ### 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). +The `translate_display_mode` preference (`'inline'` or `'webchat'`) controls where +the translation is displayed. The `translate_auto` preference controls when it is triggered. -- `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 +- `translate_auto = 2` (automatic) always generates inline, regardless of `translate_display_mode`. +- `translate_auto = 1` (manual button) respects `translate_display_mode`: + - `'inline'` → button click triggers inline generation + - `'webchat'` → button click opens the AI chat window via `_openTranslationWebchat()` The target language is determined by `translate_lang` (fallback on `default_chatgpt_lang`). @@ -93,14 +95,16 @@ User opens/selects a message in Thunderbird ↓ mzta-compose-script.js (sends "initTranslation" to background) ↓ -mzta-background.js (checks translate + translate_auto prefs) +mzta-background.js (checks translate + translate_auto + translate_display_mode prefs) ↓ ┌──────────────────────────────────────────────────────────┐ │ translate_auto = 0 → do nothing │ │ translate_auto = 1 → show "click to translate" button │ + │ display_mode = inline → click triggers inline gen │ + │ display_mode = webchat → click opens chat window │ │ translate_auto = 2 → generate immediately (always inline)│ └──────────────────────────────────────────────────────────┘ - ↓ (if generating) + ↓ (if generating inline) taTranslationStore (check cache / set processing) ↓ (cache miss) mzta-special-commands (via Web Worker, NOT chatgpt_web) diff --git a/claude-spec/02-prompts.md b/claude-spec/02-prompts.md index 77b271c2..c2c3eaae 100644 --- a/claude-spec/02-prompts.md +++ b/claude-spec/02-prompts.md @@ -88,14 +88,16 @@ The summarize feature uses two distinct prompt pathways: ### 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. +The translate feature uses a single special prompt (`prompt_translate_this`) for translating emails. It supports both inline display and webchat mode, but has no context menu entry. -**Inline Translation on Message Display** (controlled by `translate_auto` pref): +**Inline Translation on Message Display** (controlled by `translate_auto` and `translate_display_mode` prefs): - 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` +- `translate_display_mode = 'inline'`: result is rendered as a styled banner (green/teal theme) in the message body via `mzta-compose-script.js` +- `translate_display_mode = 'webchat'`: opens AI chat window; webchat shows a "Save as Translation" button to persist the result inline +- `translate_auto = 2` (automatic) always generates inline regardless of `translate_display_mode` - 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) diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index 7dbe7217..e1af7369 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -100,6 +100,8 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `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_display_mode` | `'inline'` | Where to display translations: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `translate_auto = 2` always uses inline regardless of this setting. | +| `translate_max_display_length` | `0` | Maximum characters shown in inline translation 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_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. | ### Summarize Settings Page (`pages/summarize/`) @@ -132,10 +134,13 @@ The translate settings page provides: - `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. +3. **Display mode dropdown** (`translate_display_mode`) — controls where translations are shown: + - `'inline'` — translation banner in the message pane (default) + - `'webchat'` — opens the AI chat window with a "Save as Translation" button + - Note: `translate_auto = 2` always generates inline regardless of this setting. +4. **Max display length** (`translate_max_display_length`) — number input, limits inline translation text to N characters. `0` = no limit. When truncated, a "See more"/"See less" toggle link is appended. +5. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`. +6. **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`. ## Adding a New Preference diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 3fc11537..54a73a87 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1258,6 +1258,9 @@ switch (message.command) { translationHeader.appendChild(translationMenu); translationContainer.appendChild(translationHeader); + const translationTextWrapper = document.createElement('div'); + translationTextWrapper.style.cssText = 'flex-grow: 1;'; + const translationText = document.createElement('div'); translationText.style.cssText = 'white-space: pre-wrap; line-height: 1.5;'; if (translationData.error) { @@ -1265,7 +1268,60 @@ switch (message.command) { } else { translationText.textContent = translationData.translated_text || ''; } - translationContainer.appendChild(translationText); + translationTextWrapper.appendChild(translationText); + + // Expand/collapse for long translations + const maxLenTranslation = translationData.maxDisplayLength || 0; + const fullTranslationText = translationData.translated_text || ''; + if (!translationData.error && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) { + translationText.style.overflow = 'hidden'; + translationText.style.transition = 'max-height 0.2s ease'; + + let cutPos = fullTranslationText.lastIndexOf(' ', maxLenTranslation); + if (cutPos <= 0) cutPos = maxLenTranslation; + const truncatedTranslation = fullTranslationText.substring(0, cutPos) + '\u2026'; + translationText.textContent = truncatedTranslation; + + requestAnimationFrame(() => { + const collapsedHeight = translationText.scrollHeight; + translationText.style.maxHeight = collapsedHeight + 'px'; + }); + + const toggleLinkTranslation = document.createElement('a'); + toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + toggleLinkTranslation.href = '#'; + toggleLinkTranslation.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' + + (isDarkTranslation ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;'; + + let expandedTranslation = false; + toggleLinkTranslation.addEventListener('click', (e) => { + e.preventDefault(); + if (!expandedTranslation) { + translationText.textContent = fullTranslationText; + const fullHeight = translationText.scrollHeight; + translationText.style.maxHeight = fullHeight + 'px'; + toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; + } else { + translationText.textContent = truncatedTranslation; + const collapsedHeight = translationText.scrollHeight; + translationText.textContent = fullTranslationText; + translationText.style.maxHeight = translationText.scrollHeight + 'px'; + requestAnimationFrame(() => { + translationText.style.maxHeight = collapsedHeight + 'px'; + }); + translationText.addEventListener('transitionend', function handler() { + translationText.removeEventListener('transitionend', handler); + translationText.textContent = truncatedTranslation; + }); + toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + } + expandedTranslation = !expandedTranslation; + }); + + translationTextWrapper.appendChild(toggleLinkTranslation); + } + + translationContainer.appendChild(translationTextWrapper); 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'); @@ -1368,7 +1424,7 @@ switch (message.command) { const wrapper = document.getElementById('mzta-translation-trigger-wrapper'); if (wrapper) wrapper.remove(); else translationTriggerBtn.remove(); browser.runtime.sendMessage({ - command: "triggerTranslationGeneration", + command: message.webchat ? "triggerTranslationWebchat" : "triggerTranslationGeneration", headerMessageId: message.headerMessageId }); }; diff --git a/mzta-background.js b/mzta-background.js index 88d0dae4..b0a254d9 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -334,16 +334,17 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { 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 }); + let prefs = await browser.storage.sync.get({ translate: prefs_default.translate, translate_auto: prefs_default.translate_auto, translate_display_mode: prefs_default.translate_display_mode, translate_max_display_length: prefs_default.translate_max_display_length }); if (!prefs.translate) return; let message = await browser.messageDisplay.getDisplayedMessage(tabId); if (!message) return; + // Always show cached translation if available, regardless of translate_auto let cachedTranslation = await translationStore.loadTranslation(message.headerMessageId); if (cachedTranslation && !cachedTranslation.error) { - browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } }); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } }); return; } @@ -352,15 +353,21 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { return; } + // If translate_auto is disabled, don't show button or auto-generate if (prefs.translate_auto === 0) return; + // Auto mode (translate_auto === 2) always generates inline 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 }); + if (prefs.translate_display_mode === 'inline') { + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId }); + } else { + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId, webchat: true }); + } } catch (e) { taLog.error("Error in initTranslation: " + e); } @@ -374,17 +381,59 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { } _triggerTranslationGeneration(message); break; + case 'triggerTranslationWebchat': + async function _triggerTranslationWebchat(message) { + let tabId = sender.tab.id; + await _openTranslationWebchat(message.headerMessageId, tabId); + } + _triggerTranslationWebchat(message); + break; case 'refreshTranslation': async function _refreshTranslation(message) { let tabId = sender.tab.id; await translationStore.removeTranslation(message.headerMessageId); - await _generateTranslationForMessage(message.headerMessageId, tabId); + let prefs_refresh_tr = await browser.storage.sync.get({ translate_display_mode: prefs_default.translate_display_mode }); + if (prefs_refresh_tr.translate_display_mode === 'webchat') { + await _openTranslationWebchat(message.headerMessageId, tabId); + } else { + await _generateTranslationForMessage(message.headerMessageId, tabId); + } } _refreshTranslation(message); break; case 'removeTranslation': translationStore.removeTranslation(message.headerMessageId); break; + case 'chatgpt_saveTranslation': + async function _saveTranslationFromWebchat(msg) { + try { + let translatedText = msg.text.trim(); + let prefs_tr = await browser.storage.sync.get({ + translate_lang: prefs_default.translate_lang, + default_chatgpt_lang: prefs_default.default_chatgpt_lang, + translate_max_display_length: prefs_default.translate_max_display_length + }); + let lang = prefs_tr.translate_lang || prefs_tr.default_chatgpt_lang || ''; + const translationData = { + translated_text: translatedText, + lang: lang, + headerMessageId: msg.headerMessageId + }; + await translationStore.saveTranslation(translationData, msg.headerMessageId); + try { + browser.tabs.sendMessage(msg.tabId, { + command: "showTranslation", + data: { ...translationData, maxDisplayLength: prefs_tr.translate_max_display_length } + }); + } catch (e) { + taLog.error("Error sending showTranslation to tab: " + e); + } + } catch (error) { + console.error("[ThunderAI] Error saving translation from webchat:", error); + } + } + _saveTranslationFromWebchat(message); + 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}); @@ -647,12 +696,13 @@ async function _generateTranslationForMessage(headerMessageId, tabId) { do_debug: prefs_default.do_debug, default_chatgpt_lang: prefs_default.default_chatgpt_lang, translate_lang: prefs_default.translate_lang, + translate_max_display_length: prefs_default.translate_max_display_length, ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) }); let cachedTranslation = await translationStore.loadTranslation(headerMessageId); if (cachedTranslation && !cachedTranslation.error) { - browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } }); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } }); return; } @@ -704,7 +754,7 @@ async function _generateTranslationForMessage(headerMessageId, tabId) { headerMessageId: headerMessageId }; await translationStore.saveTranslation(translationData, headerMessageId); - browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData } }); + browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData, maxDisplayLength: prefs.translate_max_display_length } }); taWorkingStatus.stopWorking(); } catch (error) { @@ -862,6 +912,41 @@ async function _openSummaryWebchat(headerMessageId, tabId) { } } +async function _openTranslationWebchat(headerMessageId, tabId) { + try { + const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); + if (!messageResult || messageResult.messages.length === 0) { + console.error("[ThunderAI] _openTranslationWebchat: Message not found for headerMessageId:", headerMessageId); + return; + } + + const curr_message = messageResult.messages[0]; + const curr_message_full = await browser.messages.getFull(curr_message.id); + + const prefs = await browser.storage.sync.get({ + ...prefs_default, + translate_lang: prefs_default.translate_lang, + default_chatgpt_lang: prefs_default.default_chatgpt_lang + }); + 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 } }); + return; + } + + const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; + const { promptText, promptInfo } = await taPromptUtils.buildTranslationPrompt(curr_message_full, lang); + promptInfo.headerMessageId = headerMessageId; + promptInfo.translationTabId = tabId; + + openChatGPT(promptText, promptInfo.action, tabId, promptInfo.name, promptInfo.need_custom_text, promptInfo); + } catch (error) { + console.error("[ThunderAI] Error opening translation webchat:", error); + } +} + // Listen for messages from ThunderAI-Sparks browser.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { switch (message.action) { diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index a9bbb91e..6a4b13bc 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -143,6 +143,8 @@ export const prefs_default = { 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_display_mode: 'inline', // 'inline' or 'webchat' + translate_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate_lang: '', // target language, fallback on default_chatgpt_lang spamfilter_show_msg_panel: true, ...generated_prefs diff --git a/pages/summarize/mzta-summarize.html b/pages/summarize/mzta-summarize.html index ea6f59c7..dd15b738 100644 --- a/pages/summarize/mzta-summarize.html +++ b/pages/summarize/mzta-summarize.html @@ -31,9 +31,9 @@ @@ -44,8 +44,8 @@ diff --git a/pages/translate/mzta-translate.html b/pages/translate/mzta-translate.html index d1a0d187..891dc485 100644 --- a/pages/translate/mzta-translate.html +++ b/pages/translate/mzta-translate.html @@ -31,14 +31,35 @@ + + __MSG_prefs_OptionText_translate_display_mode__ + + + + + + __MSG_prefs_OptionText_translate_max_display_length__ + + + + __MSG_prefs_OptionText_translate_lang__ diff --git a/pages/translate/mzta-translate.js b/pages/translate/mzta-translate.js index 2c1125a9..84e6953e 100644 --- a/pages/translate/mzta-translate.js +++ b/pages/translate/mzta-translate.js @@ -175,6 +175,9 @@ async function restoreOptions() { if (element.id === 'translate_auto') { default_select_value = prefs_default.translate_auto; } + if (element.id === 'translate_display_mode') { + default_select_value = prefs_default.translate_display_mode; + } const restoreValue = result[element.id] ?? default_select_value; let optionExists = Array.from(element.options).some(opt => opt.value === String(restoreValue)); if (element.tomselect) { From b4e41c0ba0cea39dba547bd18232676b9a6bc2e7 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 11:03:56 +0100 Subject: [PATCH 03/32] summarize batch option added --- _locales/en/messages.json | 4 ++++ options/mzta-options-default.js | 2 +- pages/summarize/mzta-summarize.html | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6c81b13b..cdff94a1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2196,5 +2196,9 @@ "prefs_chatgpt_win_position_info":{ "message": "Leave empty to use the default position.", "description": "" + }, + "prefs_OptionText_action_auto_batch": { + "message": "When the email is received", + "description": "" } } \ No newline at end of file diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index 6a4b13bc..450c1f50 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -138,7 +138,7 @@ export const prefs_default = { spamfilter_threshold: 70, spamfilter_enabled_accounts: [], summarize: false, - summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic + summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic, 3: batch processing summarize_display_mode: 'inline', // 'inline' or 'webchat' summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate: true, diff --git a/pages/summarize/mzta-summarize.html b/pages/summarize/mzta-summarize.html index dd15b738..f5f369c3 100644 --- a/pages/summarize/mzta-summarize.html +++ b/pages/summarize/mzta-summarize.html @@ -34,6 +34,7 @@ +
__MSG_prefs_OptionText_summarize_auto_Info__ From a931c864d2ba9d19c0263851322881d46c78a984 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 11:39:11 +0100 Subject: [PATCH 04/32] summarize now is possibile on also mail receive. see #580 #723 --- claude-spec/01-architecture.md | 23 +++++++++++ claude-spec/05-options.md | 4 +- mzta-background.js | 67 ++++++++++++++++++++----------- options/mzta-options-default.js | 2 +- pages/summarize/mzta-summarize.js | 15 +++++++ 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index 652c8fcf..30da7475 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -47,6 +47,7 @@ The `summarize_display_mode` preference (`'inline'` or `'webchat'`) controls whe the summary is displayed. The `summarize_auto` preference controls when it is triggered. - `summarize_auto = 2` (automatic) always generates inline, regardless of `summarize_display_mode`. +- `summarize_auto = 3` (on receive) pre-caches the summary silently when the email arrives via `onNewMailReceived`. When the user later opens the message, the cache hit triggers an instant display. - `summarize_auto = 1` (manual button) respects `summarize_display_mode`: - `'inline'` → button click triggers inline generation - `'webchat'` → button click opens the AI chat window via `_openSummaryWebchat()` @@ -67,6 +68,7 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) │ display_mode = inline → click triggers inline gen │ │ display_mode = webchat → click opens chat window │ │ summarize_auto = 2 → generate immediately (always inline)│ + │ summarize_auto = 3 → cache hit (pre-cached on receive) │ └──────────────────────────────────────────────────────────┘ ↓ (if generating inline) taSummaryStore (check cache / set processing) @@ -114,6 +116,27 @@ mzta-background.js (checks translate + translate_auto + translate_display_m mzta-compose-script.js (render translation banner in message body) ``` +### Data Flow: Background Summary on Email Receive (summarize_auto = 3) + +When `summarize_auto = 3`, a summary is generated silently when a new email arrives. The flow mirrors `add_tags_auto`: + +``` +New email arrives + ↓ +browser.messages.onNewMailReceived + ↓ +newEmailListener (checks _process_incoming, which includes summarize_auto === 3) + ↓ +processEmails({ summarizeOnReceive: true }) + ↓ (single loop — shared with addTagsAuto / spamFilter) +_generateSummaryForMessage(headerMessageId, null, { messageData }) + ← tabId is null → no UI messages sent, silent pre-cache + ↓ +taSummaryStore.saveSummary() + ↓ +[later] user opens the message → initSummary → cache hit → showSummary instantly +``` + ## Key Modules | File | Role | diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index e1af7369..f6b12bbe 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -95,8 +95,8 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `spamfilter_enabled_accounts` | `[]` | Accounts where spam filter is active | | `spamfilter_show_msg_panel` | `true` | Show info panel on spam detection | | `summarize` | `false` | Enable email summarization | -| `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_auto` | `1` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) | +| `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` and `summarize_auto = 3` always use 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) | diff --git a/mzta-background.js b/mzta-background.js index b0a254d9..ec44bd18 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -611,7 +611,9 @@ function cleanSummaryText(text) { return cleaned; } -async function _generateSummaryForMessage(headerMessageId, tabId) { +// tabId is optional — if null, runs silently (background pre-cache, no UI update) +// options.messageData: { message, fullMessage } — pass pre-fetched data to avoid re-querying +async function _generateSummaryForMessage(headerMessageId, tabId = null, options = {}) { try { let prefs = await browser.storage.sync.get({ connection_type: prefs_default.connection_type, @@ -623,40 +625,46 @@ async function _generateSummaryForMessage(headerMessageId, tabId) { let cachedSummary = await summaryStore.loadSummary(headerMessageId); if (cachedSummary && !cachedSummary.error) { - browser.tabs.sendMessage(tabId, { command: "showSummary", data: { ...cachedSummary, maxDisplayLength: prefs.summarize_max_display_length } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummary", data: { ...cachedSummary, maxDisplayLength: prefs.summarize_max_display_length } }); return; } if (await summaryStore.isProcessing(headerMessageId)) { - browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); return; } await summaryStore.setProcessing(headerMessageId); taWorkingStatus.startWorking(); - browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); - const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); - if (!messageResult || messageResult.messages.length === 0) { - await summaryStore.saveError(headerMessageId, "Message not found"); - browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: "Message not found" } }); - taWorkingStatus.stopWorking(); - return; + let message, fullMessage; + if (options.messageData) { + message = options.messageData.message; + fullMessage = options.messageData.fullMessage; + } else { + const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); + if (!messageResult || messageResult.messages.length === 0) { + await summaryStore.saveError(headerMessageId, "Message not found"); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: "Message not found" } }); + taWorkingStatus.stopWorking(); + return; + } + message = messageResult.messages[0]; + fullMessage = await browser.messages.getFull(message.id); } - const fullMessage = await browser.messages.getFull(messageResult.messages[0].id); - const connectionType = getConnectionType(prefs, {}, 'summarize'); - + if (connectionType === 'chatgpt_web') { const errorMsg = browser.i18n.getMessage('summarize_chatgpt_web_not_supported'); await summaryStore.saveError(headerMessageId, errorMsg); - browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: errorMsg } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: errorMsg } }); taWorkingStatus.stopWorking(); return; } - const { promptText } = await taPromptUtils.buildSummaryPrompt([{ message: messageResult.messages[0], fullMessage }]); + const { promptText } = await taPromptUtils.buildSummaryPrompt([{ message, fullMessage }]); const cmd = new mzta_specialCommand({ prompt: promptText, @@ -678,13 +686,13 @@ async function _generateSummaryForMessage(headerMessageId, tabId) { headerMessageId: headerMessageId }; await summaryStore.saveSummary(summaryData, headerMessageId); - browser.tabs.sendMessage(tabId, { command: "showSummary", data: { ...summaryData, maxDisplayLength: prefs.summarize_max_display_length } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummary", data: { ...summaryData, maxDisplayLength: prefs.summarize_max_display_length } }); taWorkingStatus.stopWorking(); } catch (error) { console.error("[ThunderAI] Error generating summary:", error); await summaryStore.saveError(headerMessageId, error.message || String(error)); - browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: error.message || "Failed to generate summary" } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: error.message || "Failed to generate summary" } }); taWorkingStatus.stopWorking(); } } @@ -1379,13 +1387,14 @@ async function reload_pref_init(){ add_tags_auto_only_inbox: prefs_default.add_tags_auto_only_inbox, spamfilter: prefs_default.spamfilter, summarize: prefs_default.summarize, + summarize_auto: prefs_default.summarize_auto, spamfilter_threshold: prefs_default.spamfilter_threshold, spamfilter_show_msg_panel: prefs_default.spamfilter_show_msg_panel, dynamic_menu_force_enter: prefs_default.dynamic_menu_force_enter, chatgpt_win_save_position: prefs_default.chatgpt_win_save_position, ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) }); - _process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter; + _process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter || (prefs_init.summarize && prefs_init.summarize_auto === 3); _sparks_presence = await checkSparksPresence(); } @@ -1612,7 +1621,8 @@ const newEmailListener = (folder, messagesList) => { await processEmails({ messages: messages, addTagsAuto: add_tags_auto_enabled, - spamFilter: prefs_init.spamfilter + spamFilter: prefs_init.spamfilter, + summarizeOnReceive: prefs_init.summarize && prefs_init.summarize_auto === 3 }); if(prefs_init.spamfilter){ @@ -1645,15 +1655,16 @@ async function processEmails(args) { messages, addTagsAuto = false, spamFilter = false, - summarize = false + summarize = false, + summarizeOnReceive = false } = args; taWorkingStatus.startWorking(); - // We keep two different loops, one for addTagsAuto and spamFilter and one for summarize - // because summarize is never called when an email is received, but only when using the context menu item + // One loop handles addTagsAuto, spamFilter, and summarizeOnReceive (on email receive). + // The separate summarize block below handles the context menu flow. - if (addTagsAuto || spamFilter) { + if (addTagsAuto || spamFilter || summarizeOnReceive) { let prefs_aats = await browser.storage.sync.get({ add_tags_maxnum: prefs_default.add_tags_maxnum, connection_type: prefs_default.connection_type, @@ -1748,6 +1759,16 @@ async function processEmails(args) { }); if (!result.success) continue; } + + if (summarizeOnReceive) { + if (!curr_fullMessage) { + curr_fullMessage = await browser.messages.getFull(message.id); + } + taLog.log("[ThunderAI] Pre-caching summary on receive for: " + message.headerMessageId); + await _generateSummaryForMessage(message.headerMessageId, null, { + messageData: { message, fullMessage: curr_fullMessage } + }); + } } } diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index 450c1f50..0347bc82 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -138,7 +138,7 @@ export const prefs_default = { spamfilter_threshold: 70, spamfilter_enabled_accounts: [], summarize: false, - summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic, 3: batch processing + summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic on message open, 3: generate on email receive summarize_display_mode: 'inline', // 'inline' or 'webchat' summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate: true, diff --git a/pages/summarize/mzta-summarize.js b/pages/summarize/mzta-summarize.js index edb8ffec..dd945231 100644 --- a/pages/summarize/mzta-summarize.js +++ b/pages/summarize/mzta-summarize.js @@ -76,6 +76,7 @@ document.addEventListener("DOMContentLoaded", async () => { document.querySelectorAll(".option-input").forEach(element => { element.addEventListener("change", saveOptions); }); + document.getElementById('summarize_auto').addEventListener('change', updateDisplayModeConstraint); let prefs_summarize = await browser.storage.sync.get({ summarize_enabled_accounts: [], connection_type: 'chatgpt_web' }); let summarize_textarea = document.getElementById("summarize_prompt_text"); @@ -180,6 +181,19 @@ document.addEventListener("DOMContentLoaded", async () => { // Methods to manage options, derived from: /options/mzta-options.js +function updateDisplayModeConstraint() { + const summarize_auto_el = document.getElementById('summarize_auto'); + const display_mode_el = document.getElementById('summarize_display_mode'); + const autoVal = String(summarize_auto_el.value); + if (autoVal === '2' || autoVal === '3') { + display_mode_el.value = 'inline'; + display_mode_el.disabled = true; + browser.storage.sync.set({ summarize_display_mode: 'inline' }); + } else { + display_mode_el.disabled = false; + } +} + function saveOptions(e) { e.preventDefault(); let options = {}; @@ -294,4 +308,5 @@ async function restoreOptions() { } setCurrentChoice(getting); + updateDisplayModeConstraint(); } From 6c6b57e6ea40903e30aba49a6996ba86c3d40ecf Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 11:45:50 +0100 Subject: [PATCH 05/32] i18n updated --- _locales/en/messages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cdff94a1..b8de87e5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2078,7 +2078,7 @@ "description": "" }, "prefs_OptionText_action_auto_automatic": { - "message": "Automatic", + "message": "When the email is opened", "description": "" }, "prefs_OptionText_translate_auto_Info": { From 2d478edfb9070a30c7a25a5ed95722fbb4906474 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 11:47:21 +0100 Subject: [PATCH 06/32] fields handling improved --- pages/summarize/mzta-summarize.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pages/summarize/mzta-summarize.js b/pages/summarize/mzta-summarize.js index dd945231..215558a4 100644 --- a/pages/summarize/mzta-summarize.js +++ b/pages/summarize/mzta-summarize.js @@ -189,6 +189,8 @@ function updateDisplayModeConstraint() { display_mode_el.value = 'inline'; display_mode_el.disabled = true; browser.storage.sync.set({ summarize_display_mode: 'inline' }); + } else if (autoVal === '0') { + display_mode_el.disabled = true; } else { display_mode_el.disabled = false; } From f10beb809771e0e3b19a4a18241107a648443dbb Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 11:55:06 +0100 Subject: [PATCH 07/32] onreceive translate added. see #247 #721 --- claude-spec/01-architecture.md | 23 ++++++++++- claude-spec/05-options.md | 4 +- mzta-background.js | 60 +++++++++++++++++++---------- options/mzta-options-default.js | 2 +- pages/translate/mzta-translate.html | 1 + pages/translate/mzta-translate.js | 17 ++++++++ 6 files changed, 83 insertions(+), 24 deletions(-) diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index 30da7475..7c31e7a4 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -128,7 +128,7 @@ browser.messages.onNewMailReceived newEmailListener (checks _process_incoming, which includes summarize_auto === 3) ↓ processEmails({ summarizeOnReceive: true }) - ↓ (single loop — shared with addTagsAuto / spamFilter) + ↓ (single loop — shared with addTagsAuto / spamFilter / translateOnReceive) _generateSummaryForMessage(headerMessageId, null, { messageData }) ← tabId is null → no UI messages sent, silent pre-cache ↓ @@ -137,6 +137,27 @@ taSummaryStore.saveSummary() [later] user opens the message → initSummary → cache hit → showSummary instantly ``` +### Data Flow: Background Translation on Email Receive (translate_auto = 3) + +When `translate_auto = 3`, a translation is generated silently when a new email arrives. Mirrors the summarize on-receive flow: + +``` +New email arrives + ↓ +browser.messages.onNewMailReceived + ↓ +newEmailListener (checks _process_incoming, which includes translate_auto === 3) + ↓ +processEmails({ translateOnReceive: true }) + ↓ (single loop — shared with addTagsAuto / spamFilter / summarizeOnReceive) +_generateTranslationForMessage(headerMessageId, null, { messageData }) + ← tabId is null → no UI messages sent, silent pre-cache + ↓ +taTranslationStore.saveTranslation() + ↓ +[later] user opens the message → initTranslation → cache hit → showTranslation instantly +``` + ## Key Modules | File | Role | diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index f6b12bbe..06c0d250 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -99,8 +99,8 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` and `summarize_auto = 3` always use 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_display_mode` | `'inline'` | Where to display translations: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `translate_auto = 2` always uses inline regardless of this setting. | +| `translate_auto` | `0` | Auto-translate mode: `0` = disabled, `1` = manual (show button), `2` = automatic (translate on message open), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) | +| `translate_display_mode` | `'inline'` | Where to display translations: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `translate_auto = 2` and `translate_auto = 3` always use inline regardless of this setting. | | `translate_max_display_length` | `0` | Maximum characters shown in inline translation 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_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. | diff --git a/mzta-background.js b/mzta-background.js index ec44bd18..5aaad026 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -697,7 +697,9 @@ async function _generateSummaryForMessage(headerMessageId, tabId = null, options } } -async function _generateTranslationForMessage(headerMessageId, tabId) { +// tabId is optional — if null, runs silently (background pre-cache, no UI update) +// options.messageData: { fullMessage } — pass pre-fetched data to avoid re-querying +async function _generateTranslationForMessage(headerMessageId, tabId = null, options = {}) { try { let prefs = await browser.storage.sync.get({ connection_type: prefs_default.connection_type, @@ -710,35 +712,39 @@ async function _generateTranslationForMessage(headerMessageId, tabId) { let cachedTranslation = await translationStore.loadTranslation(headerMessageId); if (cachedTranslation && !cachedTranslation.error) { - browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } }); return; } if (await translationStore.isProcessing(headerMessageId)) { - browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); return; } await translationStore.setProcessing(headerMessageId); taWorkingStatus.startWorking(); - browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + if (tabId) 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; + let fullMessage; + if (options.messageData) { + fullMessage = options.messageData.fullMessage; + } else { + const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); + if (!messageResult || messageResult.messages.length === 0) { + await translationStore.saveError(headerMessageId, "Message not found"); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: "Message not found" } }); + taWorkingStatus.stopWorking(); + return; + } + fullMessage = await browser.messages.getFull(messageResult.messages[0].id); } - 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 } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: errorMsg } }); taWorkingStatus.stopWorking(); return; } @@ -762,13 +768,13 @@ async function _generateTranslationForMessage(headerMessageId, tabId) { headerMessageId: headerMessageId }; await translationStore.saveTranslation(translationData, headerMessageId); - browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData, maxDisplayLength: prefs.translate_max_display_length } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData, maxDisplayLength: prefs.translate_max_display_length } }); 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" } }); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: error.message || "Failed to generate translation" } }); taWorkingStatus.stopWorking(); } } @@ -1388,13 +1394,15 @@ async function reload_pref_init(){ spamfilter: prefs_default.spamfilter, summarize: prefs_default.summarize, summarize_auto: prefs_default.summarize_auto, + translate: prefs_default.translate, + translate_auto: prefs_default.translate_auto, spamfilter_threshold: prefs_default.spamfilter_threshold, spamfilter_show_msg_panel: prefs_default.spamfilter_show_msg_panel, dynamic_menu_force_enter: prefs_default.dynamic_menu_force_enter, chatgpt_win_save_position: prefs_default.chatgpt_win_save_position, ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) }); - _process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter || (prefs_init.summarize && prefs_init.summarize_auto === 3); + _process_incoming = prefs_init.add_tags_auto || prefs_init.spamfilter || (prefs_init.summarize && prefs_init.summarize_auto === 3) || (prefs_init.translate && prefs_init.translate_auto === 3); _sparks_presence = await checkSparksPresence(); } @@ -1622,7 +1630,8 @@ const newEmailListener = (folder, messagesList) => { messages: messages, addTagsAuto: add_tags_auto_enabled, spamFilter: prefs_init.spamfilter, - summarizeOnReceive: prefs_init.summarize && prefs_init.summarize_auto === 3 + summarizeOnReceive: prefs_init.summarize && prefs_init.summarize_auto === 3, + translateOnReceive: prefs_init.translate && prefs_init.translate_auto === 3 }); if(prefs_init.spamfilter){ @@ -1656,15 +1665,16 @@ async function processEmails(args) { addTagsAuto = false, spamFilter = false, summarize = false, - summarizeOnReceive = false + summarizeOnReceive = false, + translateOnReceive = false } = args; taWorkingStatus.startWorking(); - // One loop handles addTagsAuto, spamFilter, and summarizeOnReceive (on email receive). + // One loop handles addTagsAuto, spamFilter, summarizeOnReceive, and translateOnReceive (on email receive). // The separate summarize block below handles the context menu flow. - if (addTagsAuto || spamFilter || summarizeOnReceive) { + if (addTagsAuto || spamFilter || summarizeOnReceive || translateOnReceive) { let prefs_aats = await browser.storage.sync.get({ add_tags_maxnum: prefs_default.add_tags_maxnum, connection_type: prefs_default.connection_type, @@ -1769,6 +1779,16 @@ async function processEmails(args) { messageData: { message, fullMessage: curr_fullMessage } }); } + + if (translateOnReceive) { + if (!curr_fullMessage) { + curr_fullMessage = await browser.messages.getFull(message.id); + } + taLog.log("[ThunderAI] Pre-caching translation on receive for: " + message.headerMessageId); + await _generateTranslationForMessage(message.headerMessageId, null, { + messageData: { fullMessage: curr_fullMessage } + }); + } } } diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index 0347bc82..17a7da3a 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -142,7 +142,7 @@ export const prefs_default = { 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_auto: 0, // 0: disabled, 1: manual button, 2: automatic on message open, 3: generate on email receive translate_display_mode: 'inline', // 'inline' or 'webchat' translate_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate_lang: '', // target language, fallback on default_chatgpt_lang diff --git a/pages/translate/mzta-translate.html b/pages/translate/mzta-translate.html index 891dc485..b424bd9f 100644 --- a/pages/translate/mzta-translate.html +++ b/pages/translate/mzta-translate.html @@ -34,6 +34,7 @@ +
__MSG_prefs_OptionText_translate_auto_Info__ diff --git a/pages/translate/mzta-translate.js b/pages/translate/mzta-translate.js index 84e6953e..a4812808 100644 --- a/pages/translate/mzta-translate.js +++ b/pages/translate/mzta-translate.js @@ -74,6 +74,7 @@ document.addEventListener("DOMContentLoaded", async () => { document.querySelectorAll(".option-input").forEach(element => { element.addEventListener("change", saveOptions); }); + document.getElementById('translate_auto').addEventListener('change', updateDisplayModeConstraint); let translate_textarea = document.getElementById("translate_prompt_text"); let translate_save_btn = document.getElementById("btn_save_prompt"); @@ -118,6 +119,21 @@ document.addEventListener("DOMContentLoaded", async () => { // Methods to manage options, derived from: /options/mzta-options.js +function updateDisplayModeConstraint() { + const translate_auto_el = document.getElementById('translate_auto'); + const display_mode_el = document.getElementById('translate_display_mode'); + const autoVal = String(translate_auto_el.value); + if (autoVal === '2' || autoVal === '3') { + display_mode_el.value = 'inline'; + display_mode_el.disabled = true; + browser.storage.sync.set({ translate_display_mode: 'inline' }); + } else if (autoVal === '0') { + display_mode_el.disabled = true; + } else { + display_mode_el.disabled = false; + } +} + function saveOptions(e) { e.preventDefault(); let options = {}; @@ -232,4 +248,5 @@ async function restoreOptions() { } setCurrentChoice(getting); + updateDisplayModeConstraint(); } From 1b0da7ce619f4fedef7d6ed35a1b6f93e8a1f694 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:02:34 +0100 Subject: [PATCH 08/32] string added to i18n --- js/mzta-compose-script.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 54a73a87..8c90dffa 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1264,7 +1264,7 @@ switch (message.command) { 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."; + translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error") || browser.i18n.getMessage("translate_error"); } else { translationText.textContent = translationData.translated_text || ''; } From fa6b5c95a9e4a0282c9a779a43575b427ce87572 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:10:00 +0100 Subject: [PATCH 09/32] panels on message ordered --- js/mzta-compose-script.js | 51 ++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 8c90dffa..b4cad1e6 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -45,6 +45,23 @@ function getCleanBodyHtml() { return clone; } +function _updatePanelMargins() { + const panelIds = [ + 'mzta-spam-check-progress', 'mzta-spam-report-banner', + 'mzta-translation-generating', 'mzta-translation-banner', + 'mzta-summary-generating', 'mzta-summary-banner' + ]; + let lastPanel = null; + for (const id of panelIds) { + const el = document.getElementById(id); + if (el) { + el.style.marginBottom = ''; + lastPanel = el; + } + } + if (lastPanel) lastPanel.style.marginBottom = '1rem'; +} + function createThreeDotsMenu(isDark, menuItems, panelColors) { const wrapper = document.createElement('div'); wrapper.style.cssText = 'position: relative; display: inline-flex; align-items: center;'; @@ -775,6 +792,7 @@ switch (message.command) { document.body.insertBefore(reposTriggerWrapperProgress, containerProgress.nextSibling); } + _updatePanelMargins(); return Promise.resolve(true); case "showSpamReport": @@ -876,6 +894,7 @@ switch (message.command) { document.body.insertBefore(reposTriggerWrapper, container.nextSibling); } + _updatePanelMargins(); return Promise.resolve(true); case "showSummary": @@ -907,7 +926,7 @@ switch (message.command) { } summaryContainer.className = 'thunderai-summary-pane'; - summaryContainer.style.cssText = `background-color: ${bgColorSummary}; color: ${textColorSummary}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorSummary}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + summaryContainer.style.cssText = `background-color: ${bgColorSummary}; color: ${textColorSummary}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${borderColorSummary}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; const summaryMenu = createThreeDotsMenu(isDarkSummary, [ { @@ -1076,8 +1095,11 @@ switch (message.command) { summaryBody.appendChild(summaryTextWrapper); summaryContainer.appendChild(summaryBody); + const translationBannerForSummary = document.getElementById('mzta-translation-banner') || document.getElementById('mzta-translation-generating'); const spamBanner = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - document.body.insertBefore(summaryContainer, spamBanner ? spamBanner.nextSibling : document.body.firstChild); + const insertAfterSummary = translationBannerForSummary || spamBanner; + document.body.insertBefore(summaryContainer, insertAfterSummary ? insertAfterSummary.nextSibling : document.body.firstChild); + _updatePanelMargins(); return Promise.resolve(true); case "showSummaryGenerating": @@ -1102,7 +1124,7 @@ switch (message.command) { const generatingContainer = document.createElement('div'); generatingContainer.id = 'mzta-summary-generating'; generatingContainer.className = 'thunderai-summary-pane'; - generatingContainer.style.cssText = `background-color: ${bgColorGen}; color: ${textColorGen}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`; + generatingContainer.style.cssText = `background-color: ${bgColorGen}; color: ${textColorGen}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${borderColorGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`; const generatingIcon = document.createElement('img'); generatingIcon.src = browser.runtime.getURL("/images/ai_summary.png"); @@ -1121,8 +1143,11 @@ switch (message.command) { generatingContainer.appendChild(generatingLoadingImg); generatingContainer.appendChild(generatingTitle); + const translationBannerForSummaryGen = document.getElementById('mzta-translation-banner') || document.getElementById('mzta-translation-generating'); const spamBannerGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - document.body.insertBefore(generatingContainer, spamBannerGen ? spamBannerGen.nextSibling : document.body.firstChild); + const insertAfterSummaryGen = translationBannerForSummaryGen || spamBannerGen; + document.body.insertBefore(generatingContainer, insertAfterSummaryGen ? insertAfterSummaryGen.nextSibling : document.body.firstChild); + _updatePanelMargins(); return Promise.resolve(true); case "showSummaryButton": @@ -1210,7 +1235,7 @@ switch (message.command) { } 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;`; + translationContainer.style.cssText = `background-color: ${bgColorTranslation}; color: ${textColorTranslation}; padding: 0.5rem; 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;'; @@ -1323,14 +1348,13 @@ switch (message.command) { translationContainer.appendChild(translationTextWrapper); - 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); + if (spamBannerForTranslation) { + document.body.insertBefore(translationContainer, spamBannerForTranslation.nextSibling); } else { document.body.insertBefore(translationContainer, document.body.firstChild); } + _updatePanelMargins(); return Promise.resolve(true); case "showTranslationGenerating": @@ -1354,7 +1378,7 @@ switch (message.command) { 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;`; + translationGenContainer.style.cssText = `background-color: ${bgColorTranslationGen}; color: ${textColorTranslationGen}; padding: 0.5rem; 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"); @@ -1372,14 +1396,13 @@ switch (message.command) { 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); + if (spamBannerForGen) { + document.body.insertBefore(translationGenContainer, spamBannerForGen.nextSibling); } else { document.body.insertBefore(translationGenContainer, document.body.firstChild); } + _updatePanelMargins(); return Promise.resolve(true); case "showTranslationButton": From 13d82d8dbfdbffe67836eb07e1b7218d16d1503a Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:16:36 +0100 Subject: [PATCH 10/32] "translation by" added. translation icon added. --- _locales/en/messages.json | 4 ++++ images/ai_translation.png | Bin 0 -> 740 bytes js/mzta-compose-script.js | 11 ++++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 images/ai_translation.png diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b8de87e5..a4b1f66a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2169,6 +2169,10 @@ "message": "Summary by", "description": "" }, + "translate_by": { + "message": "Translation by", + "description": "" + }, "prefs_THStats_1": { "message": "Do you want beautiful statistics about your emails?", "description": "" diff --git a/images/ai_translation.png b/images/ai_translation.png new file mode 100644 index 0000000000000000000000000000000000000000..4b5d6cd7418beacff9a62257a1d1f7d98c11e0c9 GIT binary patch literal 740 zcmVIs0k5&VVBAJu%oaRYlVQ|jyHHVcD|uirJ{7W!Xp6*B=#Lo{E_NLU z$$OwZvMa<`yvMN;#9ozAz5rJ$5O{&%oMpcC`45q*SG8-UZ8Ucor*)HUHI z$GxVM@f4N`<#H4sFuG{}r_!}P1Rv%5!C^dCx;>}L9!wGX`VTBEv7aIU7w`yc1h1}1 z$dx5PaQn2xUW!0JA&ebSrdj&k_)Q?yif@)R@-?f?~7^ zKd3_7!J=sJfe^ldn3%B9fE!iVMtG1~g;t#P{jbNCNN`TI1P0-Z?mawbQ5VlWjcZ>N zwDOv2SqNRTQQ8Z2*pGWcc-pZ?@Zj4&u$u{!g-^^S;r2`ZmEmF%EkX!}{SN{?D2rd3 WQK7{K+fP>j0000 Date: Sat, 28 Mar 2026 12:18:15 +0100 Subject: [PATCH 11/32] graphic attribution updated --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 563205bc..c252873c 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ _The language status represents the percentage of translated strings in the late - [Icojam](https://www.iconarchive.com/artist/icojam.html) for the spam filter context menu icon - [Roundicons](https://www.flaticon.com/authors/roundicons) for the summarize context menu icon - [HideMau](https://www.flaticon.com/authors/hidemaru) for the ai summarize icon +- [Hilmy Abiyyu A.](https://www.flaticon.com/authors/hilmy-abiyyu-a) for the ai translate icon
From 03c241c9743500e082b88b13398541045ec3f122 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:41:59 +0100 Subject: [PATCH 12/32] css fix --- js/mzta-compose-script.js | 65 ++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 0ec1b4b7..74e9a86b 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -784,13 +784,29 @@ switch (message.command) { existingFixedTriggerProgress.style.right = ''; existingFixedTriggerProgress.style.zIndex = ''; existingFixedTriggerProgress.style.marginLeft = 'auto'; - existingFixedTriggerProgress.style.marginTop = '4px'; + existingFixedTriggerProgress.style.marginTop = ''; const reposTriggerWrapperProgress = document.createElement('div'); reposTriggerWrapperProgress.id = 'mzta-summary-trigger-wrapper'; - reposTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;'; + reposTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; reposTriggerWrapperProgress.appendChild(existingFixedTriggerProgress); document.body.insertBefore(reposTriggerWrapperProgress, containerProgress.nextSibling); } + // Reposition translation trigger button if it exists as fixed + const existingFixedTranslTriggerProgress = document.getElementById('mzta-translation-trigger'); + if (existingFixedTranslTriggerProgress && !document.getElementById('mzta-translation-trigger-wrapper')) { + existingFixedTranslTriggerProgress.style.position = ''; + existingFixedTranslTriggerProgress.style.top = ''; + existingFixedTranslTriggerProgress.style.right = ''; + existingFixedTranslTriggerProgress.style.zIndex = ''; + existingFixedTranslTriggerProgress.style.marginLeft = 'auto'; + existingFixedTranslTriggerProgress.style.marginTop = ''; + const reposTranslTriggerWrapperProgress = document.createElement('div'); + reposTranslTriggerWrapperProgress.id = 'mzta-translation-trigger-wrapper'; + reposTranslTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; + reposTranslTriggerWrapperProgress.appendChild(existingFixedTranslTriggerProgress); + const insertAfterForTranslProgress = document.getElementById('mzta-summary-trigger-wrapper') || containerProgress; + document.body.insertBefore(reposTranslTriggerWrapperProgress, insertAfterForTranslProgress.nextSibling); + } _updatePanelMargins(); return Promise.resolve(true); @@ -886,13 +902,29 @@ switch (message.command) { existingFixedTrigger.style.right = ''; existingFixedTrigger.style.zIndex = ''; existingFixedTrigger.style.marginLeft = 'auto'; - existingFixedTrigger.style.marginTop = '4px'; + existingFixedTrigger.style.marginTop = ''; const reposTriggerWrapper = document.createElement('div'); reposTriggerWrapper.id = 'mzta-summary-trigger-wrapper'; - reposTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;'; + reposTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; reposTriggerWrapper.appendChild(existingFixedTrigger); document.body.insertBefore(reposTriggerWrapper, container.nextSibling); } + // Reposition translation trigger button if it exists as fixed + const existingFixedTranslTrigger = document.getElementById('mzta-translation-trigger'); + if (existingFixedTranslTrigger && !document.getElementById('mzta-translation-trigger-wrapper')) { + existingFixedTranslTrigger.style.position = ''; + existingFixedTranslTrigger.style.top = ''; + existingFixedTranslTrigger.style.right = ''; + existingFixedTranslTrigger.style.zIndex = ''; + existingFixedTranslTrigger.style.marginLeft = 'auto'; + existingFixedTranslTrigger.style.marginTop = ''; + const reposTranslTriggerWrapper = document.createElement('div'); + reposTranslTriggerWrapper.id = 'mzta-translation-trigger-wrapper'; + reposTranslTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; + reposTranslTriggerWrapper.appendChild(existingFixedTranslTrigger); + const insertAfterForTransl = document.getElementById('mzta-summary-trigger-wrapper') || container; + document.body.insertBefore(reposTranslTriggerWrapper, insertAfterForTransl.nextSibling); + } _updatePanelMargins(); return Promise.resolve(true); @@ -906,6 +938,12 @@ switch (message.command) { const existingTriggerBtn = document.getElementById('mzta-summary-trigger'); if(existingTriggerBtn) existingTriggerBtn.remove(); + // If translation button is fixed at top:40px (was offset for summary button), move it back to top:8px + const existingTranslTriggerShowSummary = document.getElementById('mzta-translation-trigger'); + if (existingTranslTriggerShowSummary && !document.getElementById('mzta-translation-trigger-wrapper')) { + existingTranslTriggerShowSummary.style.top = '8px'; + } + const summaryBanner = document.getElementById('mzta-summary-banner'); if(summaryBanner) summaryBanner.remove(); @@ -1114,6 +1152,12 @@ switch (message.command) { const existingTrigger = document.getElementById('mzta-summary-trigger'); if(existingTrigger) existingTrigger.remove(); + // If translation button is fixed at top:40px (was offset for summary button), move it back to top:8px + const existingTranslTriggerShowSummaryGen = document.getElementById('mzta-translation-trigger'); + if (existingTranslTriggerShowSummaryGen && !document.getElementById('mzta-translation-trigger-wrapper')) { + existingTranslTriggerShowSummaryGen.style.top = '8px'; + } + const isDarkGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; let bgColorGen = isDarkGen ? '#2a2a2a' : '#f0f0f0'; @@ -1203,6 +1247,11 @@ switch (message.command) { document.body.insertBefore(triggerWrapper, spamBannerTrigger.nextSibling); } else { document.body.appendChild(triggerBtn); + // If translation button is also fixed, push it down to avoid overlap + const existingTranslFixedBtn = document.getElementById('mzta-translation-trigger'); + if (existingTranslFixedBtn && !document.getElementById('mzta-translation-trigger-wrapper')) { + existingTranslFixedBtn.style.top = '40px'; + } } return Promise.resolve(true); @@ -1421,16 +1470,18 @@ switch (message.command) { 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 summaryPanelTranslationTrigger = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-trigger-wrapper'); + const summaryFixedBtnTranslationTrigger = 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; + const insertAfterTranslationBtn = summaryPanelTranslationTrigger || 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 topOffset = summaryFixedBtnTranslationTrigger ? '40px' : '8px'; + translationTriggerBtn.style.cssText = translationTriggerBtnBase + ` position: fixed; top: ${topOffset}; right: 8px; z-index: 9997;`; } const translationTriggerIcon = document.createElement('img'); From 7580efb6616fd5a790b25026f4a46defbfaba060 Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:46:04 +0100 Subject: [PATCH 13/32] cache renamed to storage --- _locales/en/messages.json | 26 +++++++++++++------------- options/mzta-options.html | 6 +++--- options/mzta-options.js | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a4b1f66a..0524c07b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -335,25 +335,25 @@ "message": "Manage your data placeholders", "description": "" }, - "prefs_cache_title": { - "message": "Cache Storage", - "description": "Title for cache management section in options" + "prefs_storage_title": { + "message": "Additional Info Storage", + "description": "Title for additional info storage management section in options" }, - "prefs_cache_storage_size": { - "message": "Cache size", - "description": "Label for cache storage size display" + "prefs_storage_size": { + "message": "Storage size", + "description": "Label for storage size display" }, - "prefs_cache_clear_button": { - "message": "Clear Cache", - "description": "Button to clear all cached message data" + "prefs_storage_clear_button": { + "message": "Clear Storage", + "description": "Button to clear all stored message data" }, "prefs_cache_clear_confirm": { - "message": "Are you sure you want to clear all cached data (summaries, spam reports, translations)? This action cannot be undone.", - "description": "Confirmation dialog for clearing cache" + "message": "Are you sure you want to clear all stored data (summaries, spam reports, translations)? This action cannot be undone.", + "description": "Confirmation dialog for clearing storage" }, - "prefs_cache_clear_done": { + "prefs_storage_clear_done": { "message": "$COUNT$ records removed.", - "description": "Message shown after cache is cleared", + "description": "Message shown after storage is cleared", "placeholders": { "count": { "content": "$1" diff --git a/options/mzta-options.html b/options/mzta-options.html index aa2231e5..4d6137ae 100644 --- a/options/mzta-options.html +++ b/options/mzta-options.html @@ -253,10 +253,10 @@ - __MSG_prefs_cache_title__ + __MSG_prefs_storage_title__ - __MSG_prefs_cache_storage_size__: -   + __MSG_prefs_storage_size__: +   diff --git a/options/mzta-options.js b/options/mzta-options.js index 9231eaf9..6d415f1d 100644 --- a/options/mzta-options.js +++ b/options/mzta-options.js @@ -417,7 +417,7 @@ document.addEventListener('DOMContentLoaded', async () => { return; } let count = await taStorage.clearAllRecords(); - alert(browser.i18n.getMessage("prefs_cache_clear_done", [String(count)])); + alert(browser.i18n.getMessage("prefs_storage_clear_done", [String(count)])); updateCacheSize(); }); From 226599ed2ae32cf253fa43c2e64d220990511a5d Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 12:46:42 +0100 Subject: [PATCH 14/32] cache renamed to storage --- _locales/en/messages.json | 2 +- options/mzta-options.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0524c07b..ae58fd9c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -347,7 +347,7 @@ "message": "Clear Storage", "description": "Button to clear all stored message data" }, - "prefs_cache_clear_confirm": { + "prefs_storage_clear_confirm": { "message": "Are you sure you want to clear all stored data (summaries, spam reports, translations)? This action cannot be undone.", "description": "Confirmation dialog for clearing storage" }, diff --git a/options/mzta-options.js b/options/mzta-options.js index 6d415f1d..eda0c48f 100644 --- a/options/mzta-options.js +++ b/options/mzta-options.js @@ -413,7 +413,7 @@ document.addEventListener('DOMContentLoaded', async () => { updateCacheSize(); document.getElementById('btnClearCache').addEventListener('click', async () => { - if (!confirm(browser.i18n.getMessage("prefs_cache_clear_confirm"))) { + if (!confirm(browser.i18n.getMessage("prefs_storage_clear_confirm"))) { return; } let count = await taStorage.clearAllRecords(); From 0945ea7d6e8174b04b8824d0f1ba104fb3cddaab Mon Sep 17 00:00:00 2001 From: mic Date: Sat, 28 Mar 2026 18:51:42 +0100 Subject: [PATCH 15/32] spec file updated --- claude-spec/01-architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index 7c31e7a4..dcc4c2d0 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -38,7 +38,7 @@ js/mzta-prompts.js (builds final prompt string) ↓ Result returned to background ↓ -js/mzta-compose-script.js (inserts text into Thunderbird compose window) +js/mzta-compose-script.js (inserts text into Thunderbird compose window and display window) ``` ### Data Flow: Inline Summary on Message Display From 1018fa777d68f8fed2ed6646d6009f0925817883 Mon Sep 17 00:00:00 2001 From: mic Date: Sun, 29 Mar 2026 22:25:25 +0200 Subject: [PATCH 16/32] improved the UI of the in messages panels --- claude-spec/01-architecture.md | 2 +- js/mzta-compose-script.js | 821 ++++++++++++++------------------- 2 files changed, 350 insertions(+), 473 deletions(-) diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index dcc4c2d0..8f5e3e42 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -168,7 +168,7 @@ taTranslationStore.saveTranslation() | `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, `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-compose-script.js` | Content script for compose and message display: injects AI response into compose window, renders unified toolbar (spam badge, summary/translation trigger buttons) and content panels (spam explanation, summary, translation) in message display via `#mzta-container` | | `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) | | `js/mzta-spamreport.js` | Spam filter logic | diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 74e9a86b..663d8c42 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -21,8 +21,7 @@ // to avoid contaminating placeholder values sent to AI providers. // Add new selectors here when new UI elements are injected into the email DOM. const MZTA_INJECTED_SELECTORS = [ - '#mzta-spam-check-progress', - '#mzta-spam-report-banner', + '#mzta-container', '.mzta_dialog', ]; @@ -45,20 +44,146 @@ function getCleanBodyHtml() { return clone; } -function _updatePanelMargins() { - const panelIds = [ - 'mzta-spam-check-progress', 'mzta-spam-report-banner', - 'mzta-translation-generating', 'mzta-translation-banner', - 'mzta-summary-generating', 'mzta-summary-banner' - ]; - let lastPanel = null; - for (const id of panelIds) { - const el = document.getElementById(id); - if (el) { - el.style.marginBottom = ''; - lastPanel = el; +// ── Theme colors ──────────────────────────────────────────────────── +function _getThemeColors(spamValue, spamThreshold) { + const isDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches; + const colors = { + isDark, + toolbar: { bg: isDark ? '#1e1e1e' : '#f5f5f5', text: isDark ? '#ddd' : '#333', border: isDark ? '#444' : '#ddd' }, + spamLoading: { bg: isDark ? '#003366' : '#e6f2ff', text: isDark ? '#cce5ff' : '#004085', border: isDark ? '#004085' : '#b8daff' }, + summary: { bg: isDark ? '#2a2a2a' : '#f0f0f0', text: isDark ? '#e0e0e0' : '#333', border: isDark ? '#444' : '#ddd' }, + summaryErr: { bg: isDark ? '#3a1a1a' : '#f7e6e6', text: isDark ? '#ffcccc' : '#660000', border: '#660000' }, + translation: { bg: isDark ? '#1a2e2a' : '#e8f5e9', text: isDark ? '#c8e6c9' : '#1b5e20', border: isDark ? '#2e5740' : '#a5d6a7' }, + translErr: { bg: isDark ? '#3a1a1a' : '#f7e6e6', text: isDark ? '#ffcccc' : '#660000', border: '#660000' }, + linkColor: isDark ? '#6db3f2' : '#1a5fa8', + }; + // Spam colors depend on the score + if (spamValue !== undefined) { + const threshold = spamThreshold || 50; + if (spamValue == -999) { + colors.spam = { bg: isDark ? '#332701' : '#fff3cd', text: isDark ? '#ffeb80' : '#856404', border: isDark ? '#664d03' : '#ffeeba' }; + } else if (spamValue >= threshold) { + colors.spam = { bg: isDark ? '#5a1a1a' : '#ffe6e6', text: isDark ? '#ffcccc' : '#cc0000', border: '#cc0000' }; + } else { + colors.spam = { bg: isDark ? '#1a401a' : '#e6ffe6', text: isDark ? '#ccffcc' : '#006600', border: '#006600' }; } } + return colors; +} + +// ── Container / Toolbar / Panels management ───────────────────────── +function _ensureContainer() { + let container = document.getElementById('mzta-container'); + if (!container) { + const colors = _getThemeColors(); + container = document.createElement('div'); + container.id = 'mzta-container'; + container.style.cssText = 'font-family: system-ui, -apple-system, sans-serif;'; + document.body.insertBefore(container, document.body.firstChild); + + const toolbar = document.createElement('div'); + toolbar.id = 'mzta-toolbar'; + toolbar.style.cssText = `display: none; align-items: center; gap: 8px; padding: 6px 0.5rem; background-color: ${colors.toolbar.bg}; border-bottom: 1px solid ${colors.toolbar.border}; font-size: 13px; color: ${colors.toolbar.text};`; + container.appendChild(toolbar); + + const panels = document.createElement('div'); + panels.id = 'mzta-panels'; + panels.style.cssText = 'display: flex; flex-direction: column; gap: 4px; padding: 4px 0;'; + container.appendChild(panels); + } + return { + toolbar: document.getElementById('mzta-toolbar'), + panels: document.getElementById('mzta-panels'), + }; +} + +function _updateToolbarVisibility() { + const toolbar = document.getElementById('mzta-toolbar'); + if (!toolbar) return; + const hasItems = toolbar.querySelector('#mzta-toolbar-spam, #mzta-toolbar-summary, #mzta-toolbar-translation'); + toolbar.style.display = hasItems ? 'flex' : 'none'; +} + +function _ensureBranding(toolbar) { + if (document.getElementById('mzta-toolbar-branding')) return; + const branding = document.createElement('span'); + branding.id = 'mzta-toolbar-branding'; + branding.textContent = 'by ThunderAI'; + branding.style.cssText = 'margin-left: auto; font-style: italic; font-size: 10px; opacity: 0.5; white-space: nowrap;'; + toolbar.appendChild(branding); +} + +const _TOOLBAR_SLOT_ORDER = ['mzta-toolbar-spam', 'mzta-toolbar-summary', 'mzta-toolbar-translation', 'mzta-toolbar-branding']; + +function _addToolbarItem(id, element) { + const { toolbar } = _ensureContainer(); + const existing = document.getElementById(id); + if (existing) existing.remove(); + element.id = id; + + const myIndex = _TOOLBAR_SLOT_ORDER.indexOf(id); + let insertBefore = null; + for (let i = myIndex + 1; i < _TOOLBAR_SLOT_ORDER.length; i++) { + const later = document.getElementById(_TOOLBAR_SLOT_ORDER[i]); + if (later) { insertBefore = later; break; } + } + if (insertBefore) toolbar.insertBefore(element, insertBefore); + else toolbar.appendChild(element); + + _ensureBranding(toolbar); + _updateToolbarVisibility(); +} + +function _removeToolbarItem(id) { + const el = document.getElementById(id); + if (el) el.remove(); + // Remove branding if toolbar is now empty of content slots + const toolbar = document.getElementById('mzta-toolbar'); + if (toolbar && !toolbar.querySelector('#mzta-toolbar-spam, #mzta-toolbar-summary, #mzta-toolbar-translation')) { + const branding = document.getElementById('mzta-toolbar-branding'); + if (branding) branding.remove(); + } + _updateToolbarVisibility(); +} + +const _PANEL_ORDER = [ + 'mzta-spam-check-progress', 'mzta-spam-report-banner', + 'mzta-translation-generating', 'mzta-translation-banner', + 'mzta-summary-generating', 'mzta-summary-banner' +]; + +function _addPanel(id, element) { + const { panels } = _ensureContainer(); + const existing = document.getElementById(id); + if (existing) existing.remove(); + element.id = id; + + const myIndex = _PANEL_ORDER.indexOf(id); + let insertBefore = null; + for (let i = myIndex + 1; i < _PANEL_ORDER.length; i++) { + const later = document.getElementById(_PANEL_ORDER[i]); + if (later) { insertBefore = later; break; } + } + if (insertBefore) panels.insertBefore(element, insertBefore); + else panels.appendChild(element); + + _updatePanelMargins(); +} + +function _removePanel(id) { + const el = document.getElementById(id); + if (el) el.remove(); + _updatePanelMargins(); +} + +function _updatePanelMargins() { + const panels = document.getElementById('mzta-panels'); + if (!panels) return; + let lastPanel = null; + for (const child of panels.children) { + child.style.marginBottom = ''; + lastPanel = child; + } if (lastPanel) lastPanel.style.marginBottom = '1rem'; } @@ -742,114 +867,81 @@ switch (message.command) { break; - case "showSpamCheckInProgress": - const oldBanner = document.getElementById('mzta-spam-report-banner'); - if(oldBanner) oldBanner.remove(); + case "showSpamCheckInProgress": { + _removePanel('mzta-spam-report-banner'); + _removeToolbarItem('mzta-toolbar-spam'); + if (document.getElementById('mzta-spam-check-progress')) return Promise.resolve(true); - if(document.getElementById('mzta-spam-check-progress')) return Promise.resolve(true); + const colors = _getThemeColors(); - const containerProgress = document.createElement('div'); - containerProgress.id = 'mzta-spam-check-progress'; - - const isDarkProgress = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - let bgColorProgress = isDarkProgress ? '#003366' : '#e6f2ff'; - let textColorProgress = isDarkProgress ? '#cce5ff' : '#004085'; - let borderColorProgress = isDarkProgress ? '#004085' : '#b8daff'; + // Loading badge in toolbar + const badge = document.createElement('div'); + badge.style.cssText = `background-color: ${colors.spamLoading.bg}; color: ${colors.spamLoading.text}; border: 1px solid ${colors.spamLoading.border}; border-radius: 4px; padding: 2px 8px; font-size: 12px; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;`; + const badgeLoading = document.createElement('img'); + badgeLoading.src = browser.runtime.getURL("/images/loading.gif"); + badgeLoading.style.cssText = "height: 14px; width: 14px;"; + badge.appendChild(badgeLoading); + const badgeText = document.createElement('span'); + badgeText.textContent = browser.i18n.getMessage("spam_check_in_progress"); + badge.appendChild(badgeText); + _addToolbarItem('mzta-toolbar-spam', badge); - containerProgress.style.cssText = `background-color: ${bgColorProgress}; color: ${textColorProgress}; border-bottom: 1px solid ${borderColorProgress}; padding: 8px 12px; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`; + // Loading panel + const panel = document.createElement('div'); + panel.style.cssText = `background-color: ${colors.spamLoading.bg}; color: ${colors.spamLoading.text}; border: 1px solid ${colors.spamLoading.border}; border-radius: 4px; padding: 8px 0.5rem; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`; + const panelLoading = document.createElement('img'); + panelLoading.src = browser.runtime.getURL("/images/loading.gif"); + panelLoading.style.cssText = "height: 16px; width: 16px;"; + const panelText = document.createElement('strong'); + panelText.textContent = browser.i18n.getMessage("spam_check_in_progress"); + panel.appendChild(panelLoading); + panel.appendChild(panelText); - const textProgress = document.createElement('strong'); - textProgress.textContent = browser.i18n.getMessage("spam_check_in_progress"); - - const loadingImg = document.createElement('img'); - loadingImg.src = browser.runtime.getURL("/images/loading.gif"); - loadingImg.style.cssText = "height: 16px; width: 16px;"; - - const brandingProgress = document.createElement('span'); - brandingProgress.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI"; - brandingProgress.style.cssText = 'margin-left: auto; font-style: italic; font-size: 11px; opacity: 0.7;'; - - containerProgress.appendChild(loadingImg); - containerProgress.appendChild(textProgress); - containerProgress.appendChild(brandingProgress); - - document.body.insertBefore(containerProgress, document.body.firstChild); - - // Reposition summary trigger button if it exists as fixed - const existingFixedTriggerProgress = document.getElementById('mzta-summary-trigger'); - if (existingFixedTriggerProgress && !document.getElementById('mzta-summary-trigger-wrapper')) { - existingFixedTriggerProgress.style.position = ''; - existingFixedTriggerProgress.style.top = ''; - existingFixedTriggerProgress.style.right = ''; - existingFixedTriggerProgress.style.zIndex = ''; - existingFixedTriggerProgress.style.marginLeft = 'auto'; - existingFixedTriggerProgress.style.marginTop = ''; - const reposTriggerWrapperProgress = document.createElement('div'); - reposTriggerWrapperProgress.id = 'mzta-summary-trigger-wrapper'; - reposTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; - reposTriggerWrapperProgress.appendChild(existingFixedTriggerProgress); - document.body.insertBefore(reposTriggerWrapperProgress, containerProgress.nextSibling); - } - // Reposition translation trigger button if it exists as fixed - const existingFixedTranslTriggerProgress = document.getElementById('mzta-translation-trigger'); - if (existingFixedTranslTriggerProgress && !document.getElementById('mzta-translation-trigger-wrapper')) { - existingFixedTranslTriggerProgress.style.position = ''; - existingFixedTranslTriggerProgress.style.top = ''; - existingFixedTranslTriggerProgress.style.right = ''; - existingFixedTranslTriggerProgress.style.zIndex = ''; - existingFixedTranslTriggerProgress.style.marginLeft = 'auto'; - existingFixedTranslTriggerProgress.style.marginTop = ''; - const reposTranslTriggerWrapperProgress = document.createElement('div'); - reposTranslTriggerWrapperProgress.id = 'mzta-translation-trigger-wrapper'; - reposTranslTriggerWrapperProgress.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; - reposTranslTriggerWrapperProgress.appendChild(existingFixedTranslTriggerProgress); - const insertAfterForTranslProgress = document.getElementById('mzta-summary-trigger-wrapper') || containerProgress; - document.body.insertBefore(reposTranslTriggerWrapperProgress, insertAfterForTranslProgress.nextSibling); - } - - _updatePanelMargins(); + _addPanel('mzta-spam-check-progress', panel); return Promise.resolve(true); + } - case "showSpamReport": - const progressBanner = document.getElementById('mzta-spam-check-progress'); - if(progressBanner) progressBanner.remove(); + case "showSpamReport": { + _removePanel('mzta-spam-check-progress'); + _removeToolbarItem('mzta-toolbar-spam'); const data = message.data; - if(document.getElementById('mzta-spam-report-banner')) return Promise.resolve(true); + if (document.getElementById('mzta-spam-report-banner')) return Promise.resolve(true); - const container = document.createElement('div'); - container.id = 'mzta-spam-report-banner'; - - const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - let bgColor = '#f8f9fa'; - let textColor = '#333'; - let borderColor = '#ccc'; - + const colors = _getThemeColors(data.spamValue, data.SpamThreshold); + const sc = colors.spam; + + // Spam badge in toolbar (clickable to toggle explanation panel) + const badge = document.createElement('div'); + badge.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; border: 1px solid ${sc.border}; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: bold; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; transition: opacity 0.2s;`; if (data.spamValue == -999) { - bgColor = isDark ? '#332701' : '#fff3cd'; - textColor = isDark ? '#ffeb80' : '#856404'; - borderColor = isDark ? '#664d03' : '#ffeeba'; - } else if (data.spamValue >= (data.SpamThreshold || 50)) { - bgColor = isDark ? '#5a1a1a' : '#ffe6e6'; - textColor = isDark ? '#ffcccc' : '#cc0000'; - borderColor = '#cc0000'; + badge.textContent = browser.i18n.getMessage("apiwebchat_error"); } else { - bgColor = isDark ? '#1a401a' : '#e6ffe6'; - textColor = isDark ? '#ccffcc' : '#006600'; - borderColor = '#006600'; + badge.textContent = ((data.spamValue >= (data.SpamThreshold || 50)) ? "\u26A0\uFE0F " + browser.i18n.getMessage("Spam") : "\uD83D\uDEE1\uFE0F " + browser.i18n.getMessage("Valid")) + " [" + data.spamValue + "/100]"; } + const chevron = document.createElement('span'); + chevron.textContent = ' \u25BC'; + chevron.style.cssText = 'font-size: 10px; transition: transform 0.2s; transform: rotate(-90deg);'; + badge.appendChild(chevron); - container.style.cssText = `background-color: ${bgColor}; color: ${textColor}; border-bottom: 1px solid ${borderColor}; border-radius:4px; padding: 8px 0.5rem; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`; + let spamExpanded = false; + badge.onclick = () => { + const panel = document.getElementById('mzta-spam-report-banner'); + if (panel) { + spamExpanded = !spamExpanded; + panel.style.display = spamExpanded ? 'flex' : 'none'; + chevron.style.transform = spamExpanded ? '' : 'rotate(-90deg)'; + } + }; + badge.onmouseover = () => { badge.style.opacity = '0.8'; }; + badge.onmouseout = () => { badge.style.opacity = '1'; }; + + _addToolbarItem('mzta-toolbar-spam', badge); + + // Explanation panel (collapsed by default) + const panel = document.createElement('div'); + panel.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; border: 1px solid ${sc.border}; border-radius: 4px; padding: 8px 0.5rem; font-size: 13px; display: none; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`; - const scoreText = document.createElement('strong'); - if (data.spamValue == -999) { - scoreText.textContent = browser.i18n.getMessage("apiwebchat_error"); - } else { - scoreText.textContent = ((data.spamValue >= (data.SpamThreshold || 50)) ? "⚠️ " + browser.i18n.getMessage("Spam") : "🛡️ " + browser.i18n.getMessage("Valid")) + " [" + data.spamValue + "/100]"; - } - const reasonText = document.createElement('span'); if (data.spamValue == -999) { reasonText.textContent = data.explanation; @@ -857,138 +949,76 @@ switch (message.command) { reasonText.textContent = browser.i18n.getMessage("Explanation") + ": " + data.explanation; } - const branding = document.createElement('span'); - branding.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI"; - branding.style.cssText = 'margin-left: auto; font-style: italic; font-size: 10px; opacity: 0.5;'; - - const spamMenu = createThreeDotsMenu(isDark, [ + const spamMenu = createThreeDotsMenu(colors.isDark, [ { - icon: '↻', + icon: '\u21BB', label: browser.i18n.getMessage("spamfilter_refresh") || 'Refresh spam report', - hoverColor: isDark ? '#4d9de0' : '#1a5fa8', + hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8', disableAfterClick: true, onClick: () => { browser.runtime.sendMessage({ command: "refreshSpamReport", headerMessageId: data.headerMessageId }); } }, { - icon: '×', + icon: '\u00D7', label: browser.i18n.getMessage("spamfilter_delete") || 'Delete spam report', hoverColor: '#cc0000', onClick: () => { - container.remove(); + _removePanel('mzta-spam-report-banner'); + _removeToolbarItem('mzta-toolbar-spam'); browser.runtime.sendMessage({ command: "removeSpamReport", headerMessageId: data.headerMessageId }); } } - ], { bg: bgColor, border: borderColor, text: textColor }); + ], { bg: sc.bg, border: sc.border, text: sc.text }); - const spamRightGroup = document.createElement('span'); - spamRightGroup.style.cssText = 'margin-left: auto; margin-right:1px; display: flex; align-items: center; gap: 5px;'; + const rightGroup = document.createElement('span'); + rightGroup.style.cssText = 'margin-left: auto; display: flex; align-items: center; gap: 5px;'; + const branding = document.createElement('span'); + branding.textContent = browser.i18n.getMessage("antispam_by") + " ThunderAI"; branding.style.cssText = 'font-style: italic; font-size: 10px; opacity: 0.5;'; - spamRightGroup.appendChild(branding); - spamRightGroup.appendChild(spamMenu); + rightGroup.appendChild(branding); + rightGroup.appendChild(spamMenu); - container.appendChild(scoreText); - container.appendChild(reasonText); - container.appendChild(spamRightGroup); + panel.appendChild(reasonText); + panel.appendChild(rightGroup); - document.body.insertBefore(container, document.body.firstChild); - - // Reposition summary trigger button if it exists as fixed - const existingFixedTrigger = document.getElementById('mzta-summary-trigger'); - if (existingFixedTrigger && !document.getElementById('mzta-summary-trigger-wrapper')) { - existingFixedTrigger.style.position = ''; - existingFixedTrigger.style.top = ''; - existingFixedTrigger.style.right = ''; - existingFixedTrigger.style.zIndex = ''; - existingFixedTrigger.style.marginLeft = 'auto'; - existingFixedTrigger.style.marginTop = ''; - const reposTriggerWrapper = document.createElement('div'); - reposTriggerWrapper.id = 'mzta-summary-trigger-wrapper'; - reposTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; - reposTriggerWrapper.appendChild(existingFixedTrigger); - document.body.insertBefore(reposTriggerWrapper, container.nextSibling); - } - // Reposition translation trigger button if it exists as fixed - const existingFixedTranslTrigger = document.getElementById('mzta-translation-trigger'); - if (existingFixedTranslTrigger && !document.getElementById('mzta-translation-trigger-wrapper')) { - existingFixedTranslTrigger.style.position = ''; - existingFixedTranslTrigger.style.top = ''; - existingFixedTranslTrigger.style.right = ''; - existingFixedTranslTrigger.style.zIndex = ''; - existingFixedTranslTrigger.style.marginLeft = 'auto'; - existingFixedTranslTrigger.style.marginTop = ''; - const reposTranslTriggerWrapper = document.createElement('div'); - reposTranslTriggerWrapper.id = 'mzta-translation-trigger-wrapper'; - reposTranslTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 2px 0.5rem;'; - reposTranslTriggerWrapper.appendChild(existingFixedTranslTrigger); - const insertAfterForTransl = document.getElementById('mzta-summary-trigger-wrapper') || container; - document.body.insertBefore(reposTranslTriggerWrapper, insertAfterForTransl.nextSibling); - } - - _updatePanelMargins(); + _addPanel('mzta-spam-report-banner', panel); return Promise.resolve(true); + } - case "showSummary": - const generatingBanner = document.getElementById('mzta-summary-generating'); - if(generatingBanner) generatingBanner.remove(); - - const existingTriggerWrapper = document.getElementById('mzta-summary-trigger-wrapper'); - if(existingTriggerWrapper) existingTriggerWrapper.remove(); - const existingTriggerBtn = document.getElementById('mzta-summary-trigger'); - if(existingTriggerBtn) existingTriggerBtn.remove(); - - // If translation button is fixed at top:40px (was offset for summary button), move it back to top:8px - const existingTranslTriggerShowSummary = document.getElementById('mzta-translation-trigger'); - if (existingTranslTriggerShowSummary && !document.getElementById('mzta-translation-trigger-wrapper')) { - existingTranslTriggerShowSummary.style.top = '8px'; - } - - const summaryBanner = document.getElementById('mzta-summary-banner'); - if(summaryBanner) summaryBanner.remove(); + case "showSummary": { + _removePanel('mzta-summary-generating'); + _removePanel('mzta-summary-banner'); + _removeToolbarItem('mzta-toolbar-summary'); const summaryData = message.data; + const colors = _getThemeColors(); + const sc = summaryData.error ? colors.summaryErr : colors.summary; + const summaryContainer = document.createElement('div'); - summaryContainer.id = 'mzta-summary-banner'; - - const isDarkSummary = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - let bgColorSummary = isDarkSummary ? '#2a2a2a' : '#f0f0f0'; - let textColorSummary = isDarkSummary ? '#e0e0e0' : '#333'; - let borderColorSummary = isDarkSummary ? '#444' : '#ddd'; - - if (summaryData.error) { - bgColorSummary = isDarkSummary ? '#3a1a1a' : '#f7e6e6'; - textColorSummary = isDarkSummary ? '#ffcccc' : '#660000'; - borderColorSummary = '#660000'; - } - summaryContainer.className = 'thunderai-summary-pane'; - summaryContainer.style.cssText = `background-color: ${bgColorSummary}; color: ${textColorSummary}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${borderColorSummary}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + summaryContainer.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${sc.border}; font-size: 14px;`; - const summaryMenu = createThreeDotsMenu(isDarkSummary, [ + const summaryMenu = createThreeDotsMenu(colors.isDark, [ { - icon: '↻', + icon: '\u21BB', label: browser.i18n.getMessage("summarize_refresh") || 'Refresh summary', - hoverColor: isDarkSummary ? '#4d9de0' : '#1a5fa8', + hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8', disableAfterClick: true, onClick: () => { - browser.runtime.sendMessage({ - command: "refreshSummary", - headerMessageId: summaryData.headerMessageId - }); + browser.runtime.sendMessage({ command: "refreshSummary", headerMessageId: summaryData.headerMessageId }); } }, { - icon: '×', + icon: '\u00D7', label: browser.i18n.getMessage("summarize_delete") || 'Delete summary', hoverColor: '#cc0000', onClick: () => { - summaryContainer.remove(); + _removePanel('mzta-summary-banner'); browser.runtime.sendMessage({ command: "removeSummary", headerMessageId: summaryData.headerMessageId }); } } - ], { bg: bgColorSummary, border: borderColorSummary, text: textColorSummary }); + ], { bg: sc.bg, border: sc.border, text: sc.text }); const summaryBranding = document.createElement('span'); summaryBranding.textContent = browser.i18n.getMessage("summary_by") + " ThunderAI"; @@ -1001,18 +1031,16 @@ switch (message.command) { const summaryIcon = document.createElement('img'); summaryIcon.src = browser.runtime.getURL("/images/ai_summary.png"); - summaryIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0; margin-top: 2px;${isDarkSummary ? ' filter: invert(1);' : ''}`; + summaryIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0; margin-top: 2px;${colors.isDark ? ' filter: invert(1);' : ''}`; const summaryTextWrapper = document.createElement('div'); summaryTextWrapper.style.cssText = 'flex: 1; min-width: 0;'; - summaryTextWrapper.appendChild(summaryRightGroup); const summaryText = document.createElement('div'); summaryText.className = 'thunderai-summary-content'; const hasHtml = !!summaryData.summary_html; - // Helper to set summary content using DOMParser (innerHTML is blocked in Thunderbird content scripts) function setSummaryHtml(element, html) { element.textContent = ''; const parser = new DOMParser(); @@ -1030,57 +1058,45 @@ switch (message.command) { } else { summaryText.textContent = summaryData.summary; } - summaryText.style.cssText = `font-size: 14px; line-height: 1.4;`; - + summaryText.style.cssText = 'font-size: 14px; line-height: 1.4;'; summaryTextWrapper.appendChild(summaryText); const maxLen = summaryData.maxDisplayLength || 0; const fullText = summaryData.summary; if (!summaryData.error && maxLen > 0 && fullText && fullText.length > maxLen) { - // Set up animated expand/collapse via max-height transition summaryText.style.overflow = 'hidden'; summaryText.style.transition = 'max-height 0.2s ease'; if (!hasHtml) { - // Plain text: truncate by character position let cutPos = fullText.lastIndexOf(' ', maxLen); if (cutPos <= 0) cutPos = maxLen; const truncated = fullText.substring(0, cutPos) + '\u2026'; summaryText.textContent = truncated; - // Measure truncated height after layout requestAnimationFrame(() => { - const collapsedHeight = summaryText.scrollHeight; - summaryText.style.maxHeight = collapsedHeight + 'px'; + summaryText.style.maxHeight = summaryText.scrollHeight + 'px'; }); const toggleLink = document.createElement('a'); toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more"; toggleLink.href = '#'; - toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' + - (isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;'; + toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`; let expanded = false; toggleLink.addEventListener('click', (e) => { e.preventDefault(); if (!expanded) { - // Expand: set full text, measure, animate to full height summaryText.textContent = fullText; - const fullHeight = summaryText.scrollHeight; - summaryText.style.maxHeight = fullHeight + 'px'; + summaryText.style.maxHeight = summaryText.scrollHeight + 'px'; toggleLink.textContent = browser.i18n.getMessage("summarize_see_less") || "See less"; } else { - // Collapse: measure current truncated height, then animate down summaryText.textContent = truncated; - // Force layout to get the target height before animating const collapsedHeight = summaryText.scrollHeight; summaryText.textContent = fullText; - // Set explicit current height so transition has a starting point summaryText.style.maxHeight = summaryText.scrollHeight + 'px'; requestAnimationFrame(() => { summaryText.style.maxHeight = collapsedHeight + 'px'; }); - // Swap text after transition ends summaryText.addEventListener('transitionend', function handler() { summaryText.removeEventListener('transitionend', handler); summaryText.textContent = truncated; @@ -1089,18 +1105,15 @@ switch (message.command) { } expanded = !expanded; }); - summaryTextWrapper.appendChild(toggleLink); } else { - // HTML content: use max-height to collapse, preserve full HTML - const collapsedMaxHeight = '4.2em'; // ~3 lines collapsed + const collapsedMaxHeight = '4.2em'; summaryText.style.maxHeight = collapsedMaxHeight; const toggleLink = document.createElement('a'); toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more"; toggleLink.href = '#'; - toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' + - (isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;'; + toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`; let expanded = false; toggleLink.addEventListener('click', (e) => { @@ -1115,7 +1128,6 @@ switch (message.command) { expanded = !expanded; }); - // Only show toggle if content is actually taller than collapsed height requestAnimationFrame(() => { if (summaryText.scrollHeight > summaryText.clientHeight) { summaryTextWrapper.appendChild(toggleLink); @@ -1133,199 +1145,120 @@ switch (message.command) { summaryBody.appendChild(summaryTextWrapper); summaryContainer.appendChild(summaryBody); - const translationBannerForSummary = document.getElementById('mzta-translation-banner') || document.getElementById('mzta-translation-generating'); - const spamBanner = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - const insertAfterSummary = translationBannerForSummary || spamBanner; - document.body.insertBefore(summaryContainer, insertAfterSummary ? insertAfterSummary.nextSibling : document.body.firstChild); - _updatePanelMargins(); + _addPanel('mzta-summary-banner', summaryContainer); return Promise.resolve(true); + } - case "showSummaryGenerating": - const existingGenerating = document.getElementById('mzta-summary-generating'); - if(existingGenerating) return Promise.resolve(true); + case "showSummaryGenerating": { + if (document.getElementById('mzta-summary-generating')) return Promise.resolve(true); - const existingSummary = document.getElementById('mzta-summary-banner'); - if(existingSummary) existingSummary.remove(); + _removePanel('mzta-summary-banner'); + _removeToolbarItem('mzta-toolbar-summary'); - const existingTriggerWrap = document.getElementById('mzta-summary-trigger-wrapper'); - if(existingTriggerWrap) existingTriggerWrap.remove(); - const existingTrigger = document.getElementById('mzta-summary-trigger'); - if(existingTrigger) existingTrigger.remove(); + const colors = _getThemeColors(); + const genContainer = document.createElement('div'); + genContainer.className = 'thunderai-summary-pane'; + genContainer.style.cssText = `background-color: ${colors.summary.bg}; color: ${colors.summary.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${colors.summary.border}; font-size: 14px; display: flex; align-items: center; gap: 10px;`; - // If translation button is fixed at top:40px (was offset for summary button), move it back to top:8px - const existingTranslTriggerShowSummaryGen = document.getElementById('mzta-translation-trigger'); - if (existingTranslTriggerShowSummaryGen && !document.getElementById('mzta-translation-trigger-wrapper')) { - existingTranslTriggerShowSummaryGen.style.top = '8px'; - } + const genIcon = document.createElement('img'); + genIcon.src = browser.runtime.getURL("/images/ai_summary.png"); + genIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`; - const isDarkGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - let bgColorGen = isDarkGen ? '#2a2a2a' : '#f0f0f0'; - let textColorGen = isDarkGen ? '#e0e0e0' : '#333'; - let borderColorGen = isDarkGen ? '#444' : '#ddd'; - let titleColorGen = isDarkGen ? '#ff6b6b' : '#d70022'; + const genLoading = document.createElement('img'); + genLoading.src = browser.runtime.getURL("/images/loading.gif"); + genLoading.style.cssText = "height: 16px; width: 16px;"; - const generatingContainer = document.createElement('div'); - generatingContainer.id = 'mzta-summary-generating'; - generatingContainer.className = 'thunderai-summary-pane'; - generatingContainer.style.cssText = `background-color: ${bgColorGen}; color: ${textColorGen}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${borderColorGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`; + const genTitle = document.createElement('span'); + genTitle.className = 'thunderai-summary-title'; + genTitle.textContent = browser.i18n.getMessage("summarize_generating"); + genTitle.style.cssText = 'font-size: 14px;'; - const generatingIcon = document.createElement('img'); - generatingIcon.src = browser.runtime.getURL("/images/ai_summary.png"); - generatingIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkGen ? ' filter: invert(1);' : ''}`; + genContainer.appendChild(genIcon); + genContainer.appendChild(genLoading); + genContainer.appendChild(genTitle); - const generatingLoadingImg = document.createElement('img'); - generatingLoadingImg.src = browser.runtime.getURL("/images/loading.gif"); - generatingLoadingImg.style.cssText = "height: 16px; width: 16px;"; - - const generatingTitle = document.createElement('span'); - generatingTitle.className = 'thunderai-summary-title'; - generatingTitle.textContent = browser.i18n.getMessage("summarize_generating"); - generatingTitle.style.cssText = `font-size: 14px;`; - - generatingContainer.appendChild(generatingIcon); - generatingContainer.appendChild(generatingLoadingImg); - generatingContainer.appendChild(generatingTitle); - - const translationBannerForSummaryGen = document.getElementById('mzta-translation-banner') || document.getElementById('mzta-translation-generating'); - const spamBannerGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - const insertAfterSummaryGen = translationBannerForSummaryGen || spamBannerGen; - document.body.insertBefore(generatingContainer, insertAfterSummaryGen ? insertAfterSummaryGen.nextSibling : document.body.firstChild); - _updatePanelMargins(); + _addPanel('mzta-summary-generating', genContainer); return Promise.resolve(true); + } - case "showSummaryButton": - const existingButton = document.getElementById('mzta-summary-trigger'); - if(existingButton) return Promise.resolve(true); + case "showSummaryButton": { + if (document.getElementById('mzta-toolbar-summary')) return Promise.resolve(true); - const isDarkBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - - let bgColorBtn = isDarkBtn ? '#2a2a2a' : '#f0f0f0'; - let textColorBtn = isDarkBtn ? '#e0e0e0' : '#333'; - let borderColorBtn = isDarkBtn ? '#444' : '#ddd'; - - const spamBannerTrigger = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); + const colors = _getThemeColors(); const triggerBtn = document.createElement('div'); - triggerBtn.id = 'mzta-summary-trigger'; triggerBtn.title = browser.i18n.getMessage("summarize_click_to_generate"); - const triggerBtnBase = `background-color: ${bgColorBtn}; border: 1px solid ${borderColorBtn}; 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: ${textColorBtn}; display: inline-flex; align-items: center; gap: 6px; width: fit-content;`; - if (spamBannerTrigger) { - triggerBtn.style.cssText = triggerBtnBase + ' margin-left: auto; margin-top: 4px;'; - } else { - triggerBtn.style.cssText = triggerBtnBase + ' position: fixed; top: 8px; right: 8px; z-index: 9998;'; - } + triggerBtn.style.cssText = `background-color: ${colors.summary.bg}; border: 1px solid ${colors.summary.border}; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${colors.summary.text}; display: inline-flex; align-items: center; gap: 6px;`; const triggerIcon = document.createElement('img'); triggerIcon.src = browser.runtime.getURL("/images/ai_summary.png"); - triggerIcon.style.cssText = `height: 14px; width: 14px;${isDarkBtn ? ' filter: invert(1);' : ''}`; + triggerIcon.style.cssText = `height: 14px; width: 14px;${colors.isDark ? ' filter: invert(1);' : ''}`; triggerBtn.appendChild(triggerIcon); const triggerLabel = document.createElement('span'); triggerLabel.textContent = browser.i18n.getMessage("get_ai_summary"); triggerBtn.appendChild(triggerLabel); + triggerBtn.onmouseover = () => { triggerBtn.style.opacity = '1'; }; triggerBtn.onmouseout = () => { triggerBtn.style.opacity = '0.7'; }; - triggerBtn.onclick = async () => { - triggerBtn.onclick = null; - triggerBtn.style.cursor = 'default'; - triggerBtn.style.opacity = '0.7'; - triggerBtn.onmouseover = null; - triggerBtn.onmouseout = null; - const wrapper = document.getElementById('mzta-summary-trigger-wrapper'); - if (wrapper) wrapper.remove(); else triggerBtn.remove(); + triggerBtn.onclick = () => { + _removeToolbarItem('mzta-toolbar-summary'); browser.runtime.sendMessage({ command: message.webchat ? "triggerSummaryWebchat" : "triggerSummaryGeneration", headerMessageId: message.headerMessageId }); }; - if (spamBannerTrigger) { - const triggerWrapper = document.createElement('div'); - triggerWrapper.id = 'mzta-summary-trigger-wrapper'; - triggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;'; - triggerWrapper.appendChild(triggerBtn); - document.body.insertBefore(triggerWrapper, spamBannerTrigger.nextSibling); - } else { - document.body.appendChild(triggerBtn); - // If translation button is also fixed, push it down to avoid overlap - const existingTranslFixedBtn = document.getElementById('mzta-translation-trigger'); - if (existingTranslFixedBtn && !document.getElementById('mzta-translation-trigger-wrapper')) { - existingTranslFixedBtn.style.top = '40px'; - } - } + _addToolbarItem('mzta-toolbar-summary', triggerBtn); 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(); + case "showTranslation": { + _removePanel('mzta-translation-generating'); + _removePanel('mzta-translation-banner'); + _removeToolbarItem('mzta-toolbar-translation'); const translationData = message.data; + const colors = _getThemeColors(); + const tc = translationData.error ? colors.translErr : colors.translation; + 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; border-radius: 4px; border: 1px solid ${borderColorTranslation}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + translationContainer.style.cssText = `background-color: ${tc.bg}; color: ${tc.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${tc.border}; 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_translation.png"); - translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslation ? ' filter: invert(1);' : ''}`; + translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`; const translationTitleSpan = document.createElement('span'); - translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${textColorTranslation}; flex-grow: 1;`; + translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${tc.text}; flex-grow: 1;`; translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation"; if (translationData.lang) { translationTitleSpan.textContent += ' (' + translationData.lang + ')'; } - const translationMenu = createThreeDotsMenu(isDarkTranslation, [ + const translationMenu = createThreeDotsMenu(colors.isDark, [ { - icon: '↻', + icon: '\u21BB', label: browser.i18n.getMessage("translate_refresh") || 'Refresh translation', - hoverColor: isDarkTranslation ? '#4d9de0' : '#1a5fa8', + hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8', disableAfterClick: true, onClick: () => { - browser.runtime.sendMessage({ - command: "refreshTranslation", - headerMessageId: translationData.headerMessageId - }); + browser.runtime.sendMessage({ command: "refreshTranslation", headerMessageId: translationData.headerMessageId }); } }, { - icon: '×', + icon: '\u00D7', label: browser.i18n.getMessage("translate_delete") || 'Delete translation', hoverColor: '#cc0000', onClick: () => { - translationContainer.remove(); - browser.runtime.sendMessage({ - command: "removeTranslation", - headerMessageId: translationData.headerMessageId - }); + _removePanel('mzta-translation-banner'); + browser.runtime.sendMessage({ command: "removeTranslation", headerMessageId: translationData.headerMessageId }); } } - ], { bg: bgColorTranslation, border: borderColorTranslation, text: textColorTranslation }); + ], { bg: tc.bg, border: tc.border, text: tc.text }); const translationBranding = document.createElement('span'); translationBranding.textContent = browser.i18n.getMessage("translate_by") + " ThunderAI"; @@ -1343,13 +1276,12 @@ switch (message.command) { 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") || browser.i18n.getMessage("translate_error"); + translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error"); } else { translationText.textContent = translationData.translated_text || ''; } translationTextWrapper.appendChild(translationText); - // Expand/collapse for long translations const maxLenTranslation = translationData.maxDisplayLength || 0; const fullTranslationText = translationData.translated_text || ''; if (!translationData.error && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) { @@ -1362,24 +1294,21 @@ switch (message.command) { translationText.textContent = truncatedTranslation; requestAnimationFrame(() => { - const collapsedHeight = translationText.scrollHeight; - translationText.style.maxHeight = collapsedHeight + 'px'; + translationText.style.maxHeight = translationText.scrollHeight + 'px'; }); - const toggleLinkTranslation = document.createElement('a'); - toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; - toggleLinkTranslation.href = '#'; - toggleLinkTranslation.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' + - (isDarkTranslation ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;'; + const toggleLink = document.createElement('a'); + toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + toggleLink.href = '#'; + toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`; - let expandedTranslation = false; - toggleLinkTranslation.addEventListener('click', (e) => { + let expanded = false; + toggleLink.addEventListener('click', (e) => { e.preventDefault(); - if (!expandedTranslation) { + if (!expanded) { translationText.textContent = fullTranslationText; - const fullHeight = translationText.scrollHeight; - translationText.style.maxHeight = fullHeight + 'px'; - toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; + translationText.style.maxHeight = translationText.scrollHeight + 'px'; + toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; } else { translationText.textContent = truncatedTranslation; const collapsedHeight = translationText.scrollHeight; @@ -1392,132 +1321,80 @@ switch (message.command) { translationText.removeEventListener('transitionend', handler); translationText.textContent = truncatedTranslation; }); - toggleLinkTranslation.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; } - expandedTranslation = !expandedTranslation; + expanded = !expanded; }); - - translationTextWrapper.appendChild(toggleLinkTranslation); + translationTextWrapper.appendChild(toggleLink); } translationContainer.appendChild(translationTextWrapper); - const spamBannerForTranslation = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - if (spamBannerForTranslation) { - document.body.insertBefore(translationContainer, spamBannerForTranslation.nextSibling); - } else { - document.body.insertBefore(translationContainer, document.body.firstChild); - } - _updatePanelMargins(); + _addPanel('mzta-translation-banner', translationContainer); return Promise.resolve(true); + } - case "showTranslationGenerating": - const existingTranslationGen = document.getElementById('mzta-translation-generating'); - if(existingTranslationGen) return Promise.resolve(true); + case "showTranslationGenerating": { + if (document.getElementById('mzta-translation-generating')) return Promise.resolve(true); - const existingTranslationBannerGen = document.getElementById('mzta-translation-banner'); - if(existingTranslationBannerGen) existingTranslationBannerGen.remove(); + _removePanel('mzta-translation-banner'); + _removeToolbarItem('mzta-toolbar-translation'); - const existingTranslationTrigWrap = document.getElementById('mzta-translation-trigger-wrapper'); - if(existingTranslationTrigWrap) existingTranslationTrigWrap.remove(); - const existingTranslationTrig = document.getElementById('mzta-translation-trigger'); - if(existingTranslationTrig) existingTranslationTrig.remove(); + const colors = _getThemeColors(); + const genContainer = document.createElement('div'); + genContainer.className = 'thunderai-translation-pane'; + genContainer.style.cssText = `background-color: ${colors.translation.bg}; color: ${colors.translation.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${colors.translation.border}; font-size: 14px; display: flex; align-items: center; gap: 10px;`; - const isDarkTranslationGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const genIcon = document.createElement('img'); + genIcon.src = browser.runtime.getURL("/images/ai_translation.png"); + genIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`; - let bgColorTranslationGen = isDarkTranslationGen ? '#1a2e2a' : '#e8f5e9'; - let textColorTranslationGen = isDarkTranslationGen ? '#c8e6c9' : '#1b5e20'; - let borderColorTranslationGen = isDarkTranslationGen ? '#2e5740' : '#a5d6a7'; + const genLoading = document.createElement('img'); + genLoading.src = browser.runtime.getURL("/images/loading.gif"); + genLoading.style.cssText = "height: 16px; width: 16px;"; - 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; 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 genTitle = document.createElement('span'); + genTitle.textContent = browser.i18n.getMessage("translate_generating") || "Translating..."; + genTitle.style.cssText = 'font-size: 14px;'; - const translationGenIcon = document.createElement('img'); - translationGenIcon.src = browser.runtime.getURL("/images/ai_translation.png"); - translationGenIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslationGen ? ' filter: invert(1);' : ''}`; + genContainer.appendChild(genIcon); + genContainer.appendChild(genLoading); + genContainer.appendChild(genTitle); - 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 spamBannerForGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - if (spamBannerForGen) { - document.body.insertBefore(translationGenContainer, spamBannerForGen.nextSibling); - } else { - document.body.insertBefore(translationGenContainer, document.body.firstChild); - } - _updatePanelMargins(); + _addPanel('mzta-translation-generating', genContainer); return Promise.resolve(true); + } - case "showTranslationButton": - const existingTranslationButton = document.getElementById('mzta-translation-trigger'); - if(existingTranslationButton) return Promise.resolve(true); + case "showTranslationButton": { + if (document.getElementById('mzta-toolbar-translation')) return Promise.resolve(true); - const isDarkTranslationBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const colors = _getThemeColors(); + const triggerBtn = document.createElement('div'); + triggerBtn.title = browser.i18n.getMessage("translate_click_to_generate") || "Click to translate this email"; + triggerBtn.style.cssText = `background-color: ${colors.translation.bg}; border: 1px solid ${colors.translation.border}; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${colors.translation.text}; display: inline-flex; align-items: center; gap: 6px;`; - let bgColorTranslationBtn = isDarkTranslationBtn ? '#1a2e2a' : '#e8f5e9'; - let textColorTranslationBtn = isDarkTranslationBtn ? '#c8e6c9' : '#1b5e20'; - let borderColorTranslationBtn = isDarkTranslationBtn ? '#2e5740' : '#a5d6a7'; + const triggerIcon = document.createElement('img'); + triggerIcon.src = browser.runtime.getURL("/images/ai_translation.png"); + triggerIcon.style.cssText = `height: 14px; width: 14px;${colors.isDark ? ' filter: invert(1);' : ''}`; + triggerBtn.appendChild(triggerIcon); - const spamBannerTranslationTrigger = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - const summaryPanelTranslationTrigger = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-trigger-wrapper'); - const summaryFixedBtnTranslationTrigger = 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 = summaryPanelTranslationTrigger || spamBannerTranslationTrigger; - if (insertAfterTranslationBtn) { - translationTriggerBtn.style.cssText = translationTriggerBtnBase + ' margin-left: auto; margin-top: 4px;'; - } else { - const topOffset = summaryFixedBtnTranslationTrigger ? '40px' : '8px'; - translationTriggerBtn.style.cssText = translationTriggerBtnBase + ` position: fixed; top: ${topOffset}; right: 8px; z-index: 9997;`; - } + const triggerLabel = document.createElement('span'); + triggerLabel.textContent = browser.i18n.getMessage("get_ai_translation") || "Get AI Translation"; + triggerBtn.appendChild(triggerLabel); - const translationTriggerIcon = document.createElement('img'); - translationTriggerIcon.src = browser.runtime.getURL("/images/ai_translation.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(); + triggerBtn.onmouseover = () => { triggerBtn.style.opacity = '1'; }; + triggerBtn.onmouseout = () => { triggerBtn.style.opacity = '0.7'; }; + triggerBtn.onclick = () => { + _removeToolbarItem('mzta-toolbar-translation'); browser.runtime.sendMessage({ command: message.webchat ? "triggerTranslationWebchat" : "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); - } + _addToolbarItem('mzta-toolbar-translation', triggerBtn); return Promise.resolve(true); + } default: // do nothing From 45da8622aa47e5eebc0c8551e154a75546ae2b88 Mon Sep 17 00:00:00 2001 From: mic Date: Sun, 29 Mar 2026 22:37:28 +0200 Subject: [PATCH 17/32] in-message UI improved --- _locales/en/messages.json | 4 ++++ js/mzta-compose-script.js | 38 ++++++++------------------------------ 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ae58fd9c..5728914a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2165,6 +2165,10 @@ "message": "Antispam by", "description": "" }, + "spam_badge_tooltip": { + "message": "Spam score — Click to see the explanation", + "description": "Tooltip shown on the spam score badge in the toolbar" + }, "summary_by": { "message": "Summary by", "description": "" diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 663d8c42..58eecbbb 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -102,18 +102,15 @@ function _updateToolbarVisibility() { if (!toolbar) return; const hasItems = toolbar.querySelector('#mzta-toolbar-spam, #mzta-toolbar-summary, #mzta-toolbar-translation'); toolbar.style.display = hasItems ? 'flex' : 'none'; + // Push the first button (summary or translation) to the right + const summary = document.getElementById('mzta-toolbar-summary'); + const translation = document.getElementById('mzta-toolbar-translation'); + const firstBtn = summary || translation; + if (summary) summary.style.marginLeft = (firstBtn === summary) ? 'auto' : ''; + if (translation) translation.style.marginLeft = (firstBtn === translation) ? 'auto' : ''; } -function _ensureBranding(toolbar) { - if (document.getElementById('mzta-toolbar-branding')) return; - const branding = document.createElement('span'); - branding.id = 'mzta-toolbar-branding'; - branding.textContent = 'by ThunderAI'; - branding.style.cssText = 'margin-left: auto; font-style: italic; font-size: 10px; opacity: 0.5; white-space: nowrap;'; - toolbar.appendChild(branding); -} - -const _TOOLBAR_SLOT_ORDER = ['mzta-toolbar-spam', 'mzta-toolbar-summary', 'mzta-toolbar-translation', 'mzta-toolbar-branding']; +const _TOOLBAR_SLOT_ORDER = ['mzta-toolbar-spam', 'mzta-toolbar-summary', 'mzta-toolbar-translation']; function _addToolbarItem(id, element) { const { toolbar } = _ensureContainer(); @@ -130,19 +127,12 @@ function _addToolbarItem(id, element) { if (insertBefore) toolbar.insertBefore(element, insertBefore); else toolbar.appendChild(element); - _ensureBranding(toolbar); _updateToolbarVisibility(); } function _removeToolbarItem(id) { const el = document.getElementById(id); if (el) el.remove(); - // Remove branding if toolbar is now empty of content slots - const toolbar = document.getElementById('mzta-toolbar'); - if (toolbar && !toolbar.querySelector('#mzta-toolbar-spam, #mzta-toolbar-summary, #mzta-toolbar-translation')) { - const branding = document.getElementById('mzta-toolbar-branding'); - if (branding) branding.remove(); - } _updateToolbarVisibility(); } @@ -885,19 +875,6 @@ switch (message.command) { badgeText.textContent = browser.i18n.getMessage("spam_check_in_progress"); badge.appendChild(badgeText); _addToolbarItem('mzta-toolbar-spam', badge); - - // Loading panel - const panel = document.createElement('div'); - panel.style.cssText = `background-color: ${colors.spamLoading.bg}; color: ${colors.spamLoading.text}; border: 1px solid ${colors.spamLoading.border}; border-radius: 4px; padding: 8px 0.5rem; font-size: 13px; display: flex; align-items: center; gap: 15px; width: 100%; box-sizing: border-box;`; - const panelLoading = document.createElement('img'); - panelLoading.src = browser.runtime.getURL("/images/loading.gif"); - panelLoading.style.cssText = "height: 16px; width: 16px;"; - const panelText = document.createElement('strong'); - panelText.textContent = browser.i18n.getMessage("spam_check_in_progress"); - panel.appendChild(panelLoading); - panel.appendChild(panelText); - - _addPanel('mzta-spam-check-progress', panel); return Promise.resolve(true); } @@ -913,6 +890,7 @@ switch (message.command) { // Spam badge in toolbar (clickable to toggle explanation panel) const badge = document.createElement('div'); + badge.title = browser.i18n.getMessage("spam_badge_tooltip"); badge.style.cssText = `background-color: ${sc.bg}; color: ${sc.text}; border: 1px solid ${sc.border}; border-radius: 4px; padding: 2px 8px; font-size: 12px; font-weight: bold; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; transition: opacity 0.2s;`; if (data.spamValue == -999) { badge.textContent = browser.i18n.getMessage("apiwebchat_error"); From 4ce2a0fc6e7cd6d4b72dbee3b0b3e07fe4fa75c9 Mon Sep 17 00:00:00 2001 From: mic Date: Sun, 29 Mar 2026 22:41:05 +0200 Subject: [PATCH 18/32] storage info improved. see #580 --- _locales/en/messages.json | 8 ++++++-- options/mzta-options.html | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5728914a..b456df8d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -336,8 +336,12 @@ "description": "" }, "prefs_storage_title": { - "message": "Additional Info Storage", - "description": "Title for additional info storage management section in options" + "message": "Storage", + "description": "Title for storage management section in options" + }, + "prefs_storage_info": { + "message": "The storage is used to save information about spam score, summaries and translations of each message.", + "description": "Info text for storage management section in options" }, "prefs_storage_size": { "message": "Storage size", diff --git a/options/mzta-options.html b/options/mzta-options.html index 4d6137ae..2d247247 100644 --- a/options/mzta-options.html +++ b/options/mzta-options.html @@ -253,10 +253,11 @@ - __MSG_prefs_storage_title__ + ThunderAI __MSG_prefs_storage_title__ __MSG_prefs_storage_size__:   +
__MSG_prefs_storage_info__ From f1b42f871480dec1aff9e8c887125ecd3c7044c7 Mon Sep 17 00:00:00 2001 From: mic Date: Sun, 29 Mar 2026 22:52:47 +0200 Subject: [PATCH 19/32] warning on empty lang. see #247 #716 --- _locales/en/messages.json | 4 ++++ mzta-background.js | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b456df8d..96064f2a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2165,6 +2165,10 @@ "message": "Translation failed.", "description": "" }, + "translate_no_language_configured": { + "message": "Translation language is not configured. Please set a language in the Translation settings or set a default language in the General settings.", + "description": "" + }, "antispam_by": { "message": "Antispam by", "description": "" diff --git a/mzta-background.js b/mzta-background.js index 5aaad026..24898e05 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -377,6 +377,17 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { case 'triggerTranslationGeneration': async function _triggerTranslationGeneration(message) { let tabId = sender.tab.id; + let prefs_tl = await browser.storage.sync.get({ + translate_lang: prefs_default.translate_lang, + default_chatgpt_lang: prefs_default.default_chatgpt_lang + }); + const lang_tl = prefs_tl.translate_lang || prefs_tl.default_chatgpt_lang || ''; + if (!lang_tl) { + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + browser.tabs.sendMessage(tabId, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('translate_no_language_configured') }); + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId }); + return; + } await _generateTranslationForMessage(message.headerMessageId, tabId); } _triggerTranslationGeneration(message); @@ -384,6 +395,17 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { case 'triggerTranslationWebchat': async function _triggerTranslationWebchat(message) { let tabId = sender.tab.id; + let prefs_tw = await browser.storage.sync.get({ + translate_lang: prefs_default.translate_lang, + default_chatgpt_lang: prefs_default.default_chatgpt_lang + }); + const lang_tw = prefs_tw.translate_lang || prefs_tw.default_chatgpt_lang || ''; + if (!lang_tw) { + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + browser.tabs.sendMessage(tabId, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('translate_no_language_configured') }); + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId, webchat: true }); + return; + } await _openTranslationWebchat(message.headerMessageId, tabId); } _triggerTranslationWebchat(message); @@ -716,6 +738,12 @@ async function _generateTranslationForMessage(headerMessageId, tabId = null, opt return; } + const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; + if (!lang) { + taLog.warn("Translation skipped: no language configured (translate_lang and default_chatgpt_lang are both empty)."); + return; + } + if (await translationStore.isProcessing(headerMessageId)) { if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); return; @@ -748,8 +776,6 @@ async function _generateTranslationForMessage(headerMessageId, tabId = null, opt taWorkingStatus.stopWorking(); return; } - - const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage, lang); const cmd = new mzta_specialCommand({ @@ -951,6 +977,10 @@ async function _openTranslationWebchat(headerMessageId, tabId) { } const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; + if (!lang) { + taLog.warn("Translation skipped: no language configured (translate_lang and default_chatgpt_lang are both empty)."); + return; + } const { promptText, promptInfo } = await taPromptUtils.buildTranslationPrompt(curr_message_full, lang); promptInfo.headerMessageId = headerMessageId; promptInfo.translationTabId = tabId; From 0b5d410afd7faeef5bc6e20211abfc126a95553a Mon Sep 17 00:00:00 2001 From: mic Date: Sun, 29 Mar 2026 23:00:01 +0200 Subject: [PATCH 20/32] translate_exclude_lang option added (but not used at the moment). see #247 #720 --- _locales/en/messages.json | 12 ++++++++++++ options/mzta-options-default.js | 1 + pages/translate/mzta-translate.html | 11 +++++++++++ 3 files changed, 24 insertions(+) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 96064f2a..d85181d5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2129,6 +2129,14 @@ "message": "Language to translate emails into. If empty, uses the default language setting.", "description": "" }, + "prefs_OptionText_translate_exclude_lang": { + "message": "Exclude languages", + "description": "" + }, + "prefs_OptionText_translate_exclude_lang_Info": { + "message": "Comma-separated list of language codes (e.g., en, fr, it) to skip for automatic translation. If the email is in one of these languages, it won't be translated automatically or the manual button won't be shown.", + "description": "" + }, "prefs_OptionText_Translate_main_prompt": { "message": "The prompt describing the translation task:", "description": "" @@ -2216,5 +2224,9 @@ "prefs_OptionText_action_auto_batch": { "message": "When the email is received", "description": "" + }, + "placeholder_string": { + "message": "Placeholder", + "description": "" } } \ No newline at end of file diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index 17a7da3a..c1015507 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -146,6 +146,7 @@ export const prefs_default = { translate_display_mode: 'inline', // 'inline' or 'webchat' translate_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate_lang: '', // target language, fallback on default_chatgpt_lang + translate_exclude_lang: '', // languages to do not translate spamfilter_show_msg_panel: true, ...generated_prefs } diff --git a/pages/translate/mzta-translate.html b/pages/translate/mzta-translate.html index b424bd9f..dc7a4e9d 100644 --- a/pages/translate/mzta-translate.html +++ b/pages/translate/mzta-translate.html @@ -67,6 +67,17 @@ + + + + __MSG_prefs_OptionText_translate_exclude_lang__ + + From 41071e325642d1a8caa6b530d294ad3780d95919 Mon Sep 17 00:00:00 2001 From: mic Date: Mon, 30 Mar 2026 21:45:42 +0200 Subject: [PATCH 21/32] translation id fixed --- _locales/cs/messages.json | 2 +- _locales/de/messages.json | 2 +- _locales/el/messages.json | 2 +- _locales/es/messages.json | 2 +- _locales/fr/messages.json | 2 +- _locales/hr/messages.json | 2 +- _locales/it/messages.json | 2 +- _locales/ja/messages.json | 2 +- _locales/pl/messages.json | 2 +- _locales/pt-br/messages.json | 2 +- _locales/ru/messages.json | 2 +- _locales/sv/messages.json | 2 +- _locales/zh_Hans/messages.json | 2 +- _locales/zh_Hant/messages.json | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/_locales/cs/messages.json b/_locales/cs/messages.json index faa6b283..edc7e471 100644 --- a/_locales/cs/messages.json +++ b/_locales/cs/messages.json @@ -629,7 +629,7 @@ "prefs_OptionText_add_tags_auto_only_inbox_Info": { "message": "Pokud je zaškrtnuto, AI bude přidávat štítky pouze e-mailům přijatým ve složce Doručená pošta." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Výchozí jazyk podle nastavení ThunderAI." }, "StorageSpace": { diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 053585a9..fc6ba9fe 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -753,7 +753,7 @@ "placeholder_thunderai_def_sign": { "message": "Standardsignatur wie in den ThunderAI-Optionen definiert." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Standardsprache wie in den ThunderAI-Optionen definiert." }, "prefs_OptionText_spamfilter": { diff --git a/_locales/el/messages.json b/_locales/el/messages.json index d1eaffef..d66cfbc4 100644 --- a/_locales/el/messages.json +++ b/_locales/el/messages.json @@ -692,7 +692,7 @@ "placeholder_thunderai_def_sign": { "message": "Προεπιλεγμένη υπογραφή όπως ορίζεται στις επιλογές ThunderAI." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Προεπιλεγμένη γλώσσα όπως ορίζεται στις επιλογές ThunderAI." }, "placeholder_mail_attachments_info": { diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 37105671..5753820c 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -875,7 +875,7 @@ "placeholder_thunderai_def_sign": { "message": "Firma predeterminada según lo definido en las opciones de ThunderAI." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Idioma predeterminado según lo definido en las opciones de ThunderAI." }, "placeholder_mail_attachments_info": { diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 54630870..976c8149 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -810,7 +810,7 @@ "SpamFilter_prompt_text_title": { "message": "Texte du prompt actuelle" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Langue par défaut telle que définie dans les options de ThunderAI." }, "prefs_OptionText_btnManageSpamFilterInfo": { diff --git a/_locales/hr/messages.json b/_locales/hr/messages.json index 570131bd..d7121ee5 100644 --- a/_locales/hr/messages.json +++ b/_locales/hr/messages.json @@ -759,7 +759,7 @@ "placeholder_thunderai_def_sign": { "message": "Zadani potpis kako je određeno u mogućnostima ThunderAI." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Zadani jezik kako je određeno u mogućnostima ThunderAI." }, "prefs_OptionText_spamfilter": { diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 763844ee..68ea230e 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -804,7 +804,7 @@ "SpamFilter_info_default": { "message": "In questa pagina puoi modificare il prompt predefinito utilizzato per rilevare le email di spam." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Lingua predefinita come impostata nelle opzioni di ThunderAI." }, "spamfilter_no_reports": { diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 07746075..3007f38b 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -878,7 +878,7 @@ "placeholder_thunderai_def_sign": { "message": "ThunderAIオプションで定義されたデフォルトの署名。" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "ThunderAIオプションで定義されたデフォルト言語。" }, "placeholder_mail_attachments_info": { diff --git a/_locales/pl/messages.json b/_locales/pl/messages.json index b60db714..b2af15a5 100644 --- a/_locales/pl/messages.json +++ b/_locales/pl/messages.json @@ -756,7 +756,7 @@ "Spam_Value": { "message": "Wartość spamu" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Domyślny język zgodnie z opcjami ThunderAI." }, "prompt_spamfilter": { diff --git a/_locales/pt-br/messages.json b/_locales/pt-br/messages.json index c6730a35..5fe6b00c 100644 --- a/_locales/pt-br/messages.json +++ b/_locales/pt-br/messages.json @@ -822,7 +822,7 @@ "prefs_OptionText_add_tags_auto_only_inbox_Info": { "message": "Se marcado, a IA adicionará tags apenas aos e-mails recebidos na pasta da caixa de entrada." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Idioma padrão conforme definido nas opções do ThunderAI." }, "placeholder_thunderai_def_sign": { diff --git a/_locales/ru/messages.json b/_locales/ru/messages.json index 31b4fe78..d08caa6d 100644 --- a/_locales/ru/messages.json +++ b/_locales/ru/messages.json @@ -848,7 +848,7 @@ "placeholder_thunderai_def_sign": { "message": "Подпись по умолчанию, определенная в опциях ThunderAI." }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Язык по умолчанию, определенный в опциях ThunderAI." }, "empty": { diff --git a/_locales/sv/messages.json b/_locales/sv/messages.json index 41df75e4..1c53b67f 100644 --- a/_locales/sv/messages.json +++ b/_locales/sv/messages.json @@ -734,7 +734,7 @@ "prefs_OptionText_get_calendar_event": { "message": "Lägg till en ny kalenderhändelse från markerad text" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Standardspråk som definierat i ThunderAI-alternativ." }, "placeholder_mail_attachments_info": { diff --git a/_locales/zh_Hans/messages.json b/_locales/zh_Hans/messages.json index 22d6bd28..9579793e 100644 --- a/_locales/zh_Hans/messages.json +++ b/_locales/zh_Hans/messages.json @@ -611,7 +611,7 @@ "prefs_OptionText_placeholders_use_default_value": { "message": "占位符:使用默认值" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "ThunderAI 选项中定义的默认语言。" }, "prefs_OptionText_openai_comp_info_remote": { diff --git a/_locales/zh_Hant/messages.json b/_locales/zh_Hant/messages.json index 50827707..819af18e 100644 --- a/_locales/zh_Hant/messages.json +++ b/_locales/zh_Hant/messages.json @@ -1002,7 +1002,7 @@ "prefs_OptionText_chatgpt_web_custom_gpt": { "message": "ChatGPT 網頁自訂 GPT" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "ThunderAI 選項中定義的預設語言。" }, "prefs_SurveyLinkText2": { From 7e5ba820c297565cb9d7184298b24f2ae628d28a Mon Sep 17 00:00:00 2001 From: mic Date: Mon, 30 Mar 2026 21:49:16 +0200 Subject: [PATCH 22/32] thunderai_translate_lang and thunderai_translate_exclude_lang placeholders added. see #247 #726 #727 --- _locales/en/messages.json | 10 +++++++++- js/mzta-placeholders.js | 26 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d85181d5..44f7054c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1316,10 +1316,18 @@ "message": "Default signature as defined in ThunderAI options.", "description": "" }, - "thunderai_def_lang": { + "placeholder_thunderai_def_lang": { "message": "Default language as defined in ThunderAI options.", "description": "" }, + "placeholder_thunderai_translate_lang": { + "message": "The language to be used in the mail translations.", + "description": "" + }, + "placeholder_thunderai_translate_exclude_lang": { + "message": "The languages code to do not translate when found.", + "description": "" + }, "placeholder_mail_attachments_info": { "message": "Information about the attachments in the email", "description": "" diff --git a/js/mzta-placeholders.js b/js/mzta-placeholders.js index 48de04a3..e46a6406 100644 --- a/js/mzta-placeholders.js +++ b/js/mzta-placeholders.js @@ -253,6 +253,24 @@ const defaultPlaceholders = [ is_dynamic: "0", enabled: 1, }, + { + id: 'thunderai_translate_lang', + name: "__MSG_placeholder_thunderai_translate_lang__", + default_value: "", + type: 0, + is_default: "1", + is_dynamic: "0", + enabled: 1, + }, + { + id: 'thunderai_translate_exclude_lang', + name: "__MSG_placeholder_thunderai_translate_exclude_lang__", + default_value: "", + type: 0, + is_default: "1", + is_dynamic: "0", + enabled: 1, + }, { id: 'empty', name: "__MSG_placeholder_empty__", @@ -599,6 +617,14 @@ export const placeholdersUtils = { let prefs_def_lang = await browser.storage.sync.get({ default_chatgpt_lang: prefs_default.default_chatgpt_lang }); finalSubs['thunderai_def_lang'] = placeholdersUtils.failSafePlaceholders(prefs_def_lang.default_chatgpt_lang); break; + case 'thunderai_translate_lang': + let prefs_translate_lang = await browser.storage.sync.get({ translate_lang: prefs_default.translate_lang }); + finalSubs['thunderai_translate_lang'] = placeholdersUtils.failSafePlaceholders(prefs_translate_lang.translate_lang); + break; + case 'thunderai_translate_exclude_lang': + let prefs_translate_exclude_lang = await browser.storage.sync.get({ translate_exclude_lang: prefs_default.translate_exclude_lang }); + finalSubs['thunderai_translate_exclude_lang'] = placeholdersUtils.failSafePlaceholders(prefs_translate_exclude_lang.translate_exclude_lang); + break; case 'mail_attachments_info': let attachments_info_string = ""; let attachments_info = await browser.messages.listAttachments(curr_message.id); From c5c7694d519d365235eec766a55e9cd8749056cb Mon Sep 17 00:00:00 2001 From: mic Date: Mon, 30 Mar 2026 23:02:22 +0200 Subject: [PATCH 23/32] tranlsate context menu added. see #247 #719 --- _locales/en/messages.json | 4 ++++ js/mzta-utils.js | 2 ++ mzta-background.js | 33 ++++++++++++++++++++++++++------- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 44f7054c..12bdb601 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1496,6 +1496,10 @@ "message": "Summarize", "description": "" }, + "context_menu_mzta-translate": { + "message": "Translate", + "description": "" + }, "noActiveCalendar": { "message": "No editable calendar found!", "description": "" diff --git a/js/mzta-utils.js b/js/mzta-utils.js index 8aad2068..74d62eb4 100644 --- a/js/mzta-utils.js +++ b/js/mzta-utils.js @@ -27,10 +27,12 @@ export const getMenuContextDisplay = () => 'message_display_action_menu'; export const contextMenuID_AddTags = 'mzta-add-tags'; export const contextMenuID_Spamfilter = 'mzta-spamfilter'; export const contextMenuID_Summarize = 'mzta-summarize'; +export const contextMenuID_Translate = 'mzta-translate'; export const contextMenuIconsPath = { [contextMenuID_AddTags]: 'moz-extension:images/autotags.png', [contextMenuID_Spamfilter]: 'moz-extension:images/spamfilter.png', [contextMenuID_Summarize]: 'moz-extension:images/summarize.png', + [contextMenuID_Translate]: 'moz-extension:images/ai_translation.png', }; export function getLanguageDisplayName(languageCode) { diff --git a/mzta-background.js b/mzta-background.js index 24898e05..ffb87c08 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -45,6 +45,7 @@ import { contextMenuID_AddTags, contextMenuID_Spamfilter, contextMenuID_Summarize, + contextMenuID_Translate, contextMenuIconsPath, sanitizeChatGPTModelData, sanitizeChatGPTWebCustomData, @@ -1603,6 +1604,13 @@ function addContextMenuItems() { removeContextMenu(contextMenuID_Summarize); } + // Add Context menu: Translate + if(prefs_init.translate && checkAPIIntegration(prefs_init.connection_type, prefs_init.translate_use_specific_integration, prefs_init.translate_connection_type)){ + itemsToAdd.push(contextMenuID_Translate); + } else { + removeContextMenu(contextMenuID_Translate); + } + itemsToAdd.sort((a, b) => { let titleA = browser.i18n.getMessage("context_menu_" + a); let titleB = browser.i18n.getMessage("context_menu_" + b); @@ -1621,6 +1629,7 @@ browser.menus.onClicked.addListener( (info, tab) => { let _add_tags = false let _spamfilter = false let _summarize = false; + let _translate = false; if(info.menuItemId === contextMenuID_AddTags){ _add_tags = true; } @@ -1630,12 +1639,16 @@ browser.menus.onClicked.addListener( (info, tab) => { if(info.menuItemId === contextMenuID_Summarize) { _summarize = true; } - if(_add_tags || _spamfilter || _summarize){ + if(info.menuItemId === contextMenuID_Translate) { + _translate = true; + } + if(_add_tags || _spamfilter || _summarize || _translate){ processEmails({ messages: getMessages(info.selectedMessages), addTagsAuto: _add_tags, spamFilter: _spamfilter, - summarize: _summarize + summarize: _summarize, + translate: _translate }); } }); @@ -1696,7 +1709,8 @@ async function processEmails(args) { spamFilter = false, summarize = false, summarizeOnReceive = false, - translateOnReceive = false + translateOnReceive = false, + translate = false } = args; taWorkingStatus.startWorking(); @@ -1704,7 +1718,7 @@ async function processEmails(args) { // One loop handles addTagsAuto, spamFilter, summarizeOnReceive, and translateOnReceive (on email receive). // The separate summarize block below handles the context menu flow. - if (addTagsAuto || spamFilter || summarizeOnReceive || translateOnReceive) { + if (addTagsAuto || spamFilter || summarizeOnReceive || translateOnReceive || translate) { let prefs_aats = await browser.storage.sync.get({ add_tags_maxnum: prefs_default.add_tags_maxnum, connection_type: prefs_default.connection_type, @@ -1810,12 +1824,17 @@ async function processEmails(args) { }); } - if (translateOnReceive) { + if (translateOnReceive || translate) { if (!curr_fullMessage) { curr_fullMessage = await browser.messages.getFull(message.id); } - taLog.log("[ThunderAI] Pre-caching translation on receive for: " + message.headerMessageId); - await _generateTranslationForMessage(message.headerMessageId, null, { + let translateTabId = null; + if (translate) { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + translateTabId = tabs[0].id; + } + taLog.log("[ThunderAI] Generating translation for: " + message.headerMessageId); + await _generateTranslationForMessage(message.headerMessageId, translateTabId, { messageData: { fullMessage: curr_fullMessage } }); } From 894d3475dd153fb3f292193dcd04336a8fc220ba Mon Sep 17 00:00:00 2001 From: mic Date: Mon, 30 Mar 2026 23:07:53 +0200 Subject: [PATCH 24/32] iconse renamed and translate for context menu added --- README.md | 2 +- images/{ai_translation.png => ai_translate.png} | Bin images/{autotags.png => menu_autotags.png} | Bin images/{spamfilter.png => menu_spamfilter.png} | Bin images/{summarize.png => menu_summarize.png} | Bin images/menu_translate.png | Bin 0 -> 1201 bytes js/mzta-compose-script.js | 6 +++--- js/mzta-utils.js | 8 ++++---- 8 files changed, 8 insertions(+), 8 deletions(-) rename images/{ai_translation.png => ai_translate.png} (100%) rename images/{autotags.png => menu_autotags.png} (100%) rename images/{spamfilter.png => menu_spamfilter.png} (100%) rename images/{summarize.png => menu_summarize.png} (100%) create mode 100644 images/menu_translate.png diff --git a/README.md b/README.md index c252873c..f52bf24a 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ _The language status represents the percentage of translated strings in the late - [Icojam](https://www.iconarchive.com/artist/icojam.html) for the spam filter context menu icon - [Roundicons](https://www.flaticon.com/authors/roundicons) for the summarize context menu icon - [HideMau](https://www.flaticon.com/authors/hidemaru) for the ai summarize icon -- [Hilmy Abiyyu A.](https://www.flaticon.com/authors/hilmy-abiyyu-a) for the ai translate icon +- [Hilmy Abiyyu A.](https://www.flaticon.com/authors/hilmy-abiyyu-a) for the ai translate and context menu icons
diff --git a/images/ai_translation.png b/images/ai_translate.png similarity index 100% rename from images/ai_translation.png rename to images/ai_translate.png diff --git a/images/autotags.png b/images/menu_autotags.png similarity index 100% rename from images/autotags.png rename to images/menu_autotags.png diff --git a/images/spamfilter.png b/images/menu_spamfilter.png similarity index 100% rename from images/spamfilter.png rename to images/menu_spamfilter.png diff --git a/images/summarize.png b/images/menu_summarize.png similarity index 100% rename from images/summarize.png rename to images/menu_summarize.png diff --git a/images/menu_translate.png b/images/menu_translate.png new file mode 100644 index 0000000000000000000000000000000000000000..91abf898a457e40b6dea4b751bc447c7be0e2043 GIT binary patch literal 1201 zcmV;i1Wx;jP)o&+$;#U+FH|9GK-4l=2mk#-GKwOdh?DL@3`VL=l(oDNLAy&P+76heq{pk*-zbnd<~Iyxq_p`}H7 zAYuifYN*FqFxawn8-R48rR80#)w(DHvb#@B&&ZJUdOg!t8``9Xa5_CnLI`T!uf}3F z`z6ZF&&Ou7r7T9=+khe~vL|@Y?qFQgusiGkbbi-CozcjFPd@fbq{s?F2-$DK0tW7b zQwS!sNv5q{gTN|UJql?v8ngMWabCb!$k3BuqHZ%|w6-DFG`hV4cZlQ$c z#s=1?sm#hz8XRl5K9? zv<>=x_01U`3cE@nD-}Di1!gy)HmOa{Nxo^ZU@#aceW`3td~AG*xRtJNX$o}xAdk~wS8jW@I43v1KzcMSjd%9$#WFB}@z5b`Hful+M|)s@ z$J|)ihi(HQ&}D?2G~cz}SH2D?2TFmW%fDTvwQdY=M2&-`q9D zhQeexeHr54gXC4vzYiR-;EW93fM0~Gj1ApkrFSeV0oq@Ocmu491z7^^1-LTeGKCYA zVURR%tbrRN*nkmO_!s%YLl+0h&QC;&H@L)fifiUE5g@tGm0p2310;COS{no7X*lr% zGEfCrmY`2Wm`W!>JXklUBea7EQHw}i<%;QK?7*%Wvc)|8=3}k9`CG*>H3Vbf!>nk3 z4Z?Q=un#Z-fBXa8Md2|ugo%x*o>kfUUkT4K5gy8|FqKgt=KnCTjDYwX5EOF;qHdZ6 P00000NkvXXu0mjf5YQ$W literal 0 HcmV?d00001 diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 58eecbbb..831e7654 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1207,7 +1207,7 @@ switch (message.command) { 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_translation.png"); + translationIcon.src = browser.runtime.getURL("/images/ai_translate.png"); translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`; const translationTitleSpan = document.createElement('span'); @@ -1324,7 +1324,7 @@ switch (message.command) { genContainer.style.cssText = `background-color: ${colors.translation.bg}; color: ${colors.translation.text}; padding: 0.5rem; border-radius: 4px; border: 1px solid ${colors.translation.border}; font-size: 14px; display: flex; align-items: center; gap: 10px;`; const genIcon = document.createElement('img'); - genIcon.src = browser.runtime.getURL("/images/ai_translation.png"); + genIcon.src = browser.runtime.getURL("/images/ai_translate.png"); genIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${colors.isDark ? ' filter: invert(1);' : ''}`; const genLoading = document.createElement('img'); @@ -1352,7 +1352,7 @@ switch (message.command) { triggerBtn.style.cssText = `background-color: ${colors.translation.bg}; border: 1px solid ${colors.translation.border}; border-radius: 4px; padding: 4px 8px; cursor: pointer; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${colors.translation.text}; display: inline-flex; align-items: center; gap: 6px;`; const triggerIcon = document.createElement('img'); - triggerIcon.src = browser.runtime.getURL("/images/ai_translation.png"); + triggerIcon.src = browser.runtime.getURL("/images/ai_translate.png"); triggerIcon.style.cssText = `height: 14px; width: 14px;${colors.isDark ? ' filter: invert(1);' : ''}`; triggerBtn.appendChild(triggerIcon); diff --git a/js/mzta-utils.js b/js/mzta-utils.js index 74d62eb4..30139131 100644 --- a/js/mzta-utils.js +++ b/js/mzta-utils.js @@ -29,10 +29,10 @@ export const contextMenuID_Spamfilter = 'mzta-spamfilter'; export const contextMenuID_Summarize = 'mzta-summarize'; export const contextMenuID_Translate = 'mzta-translate'; export const contextMenuIconsPath = { - [contextMenuID_AddTags]: 'moz-extension:images/autotags.png', - [contextMenuID_Spamfilter]: 'moz-extension:images/spamfilter.png', - [contextMenuID_Summarize]: 'moz-extension:images/summarize.png', - [contextMenuID_Translate]: 'moz-extension:images/ai_translation.png', + [contextMenuID_AddTags]: 'moz-extension:images/menu_autotags.png', + [contextMenuID_Spamfilter]: 'moz-extension:images/menu_spamfilter.png', + [contextMenuID_Summarize]: 'moz-extension:images/menu_summarize.png', + [contextMenuID_Translate]: 'moz-extension:images/menu_translate.png', }; export function getLanguageDisplayName(languageCode) { From 38f6b1987dd107180aad61bab0f960a62fcd2f1c Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 00:19:35 +0200 Subject: [PATCH 25/32] the translation prompt now returns a JSON. see #247 --- _locales/en/messages.json | 6 +++++- js/mzta-compose-script.js | 10 +++++++++- js/mzta-storage.js | 20 ++++++++++++++++++-- js/mzta-translationstore.js | 4 +++- js/mzta-utils-prompt.js | 19 +++++++++++++------ mzta-background.js | 16 +++++++++++++++- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 12bdb601..2e9377f2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -821,7 +821,7 @@ "description": "" }, "prompt_translate_this_full_text": { - "message": "Translate the following email in", + "message": "Translate the email below into {%thunderai_translate_lang%}.\n\nRules:\n- Translate both the subject and the body.\n- Return the result as a JSON object with three fields: \"subject\", \"body\" and \"status\".\n- If the translation has been done the status is equal to 1.\n- If the email is written in one of these languages \"{%thunderai_translate_exclude_lang%}\" or in the {%thunderai_translate_lang%} language, return an empty string for the body and the subject and set the status to -1.\n- Do not add explanations, notes, or any text outside the JSON.\n\nMail subject: {%mail_subject%}\n\nMail body: {%mail_html_body%}\n\nGenerate a response in JSON format only. The output should be only a JSON object. Here is an example of the JSON format to be used:\n{\n\"subject\": \"subject translation\",\n\"body\": \"body translation\",\n\"status\": \"status result\"\n}", "description": "" }, "prompt_this_full_text": { @@ -2189,6 +2189,10 @@ "message": "Translation language is not configured. Please set a language in the Translation settings or set a default language in the General settings.", "description": "" }, + "translate_skipped": { + "message": "Translation skipped: Language excluded or identical to target.", + "description": "Shown in the translation banner when the email language matches the excluded or target language" + }, "antispam_by": { "message": "Antispam by", "description": "" diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 831e7654..a3183507 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1255,14 +1255,22 @@ switch (message.command) { translationText.style.cssText = 'white-space: pre-wrap; line-height: 1.5;'; if (translationData.error) { translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error"); + } else if (translationData.translation_status === '-1') { + translationText.textContent = browser.i18n.getMessage("translate_skipped"); } else { + if (translationData.translated_subject) { + const subjectEl = document.createElement('div'); + subjectEl.style.cssText = 'font-weight: bold; margin-bottom: 4px;'; + subjectEl.textContent = translationData.translated_subject; + translationTextWrapper.appendChild(subjectEl); + } translationText.textContent = translationData.translated_text || ''; } translationTextWrapper.appendChild(translationText); const maxLenTranslation = translationData.maxDisplayLength || 0; const fullTranslationText = translationData.translated_text || ''; - if (!translationData.error && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) { + if (!translationData.error && translationData.translation_status !== '-1' && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) { translationText.style.overflow = 'hidden'; translationText.style.transition = 'max-height 0.2s ease'; diff --git a/js/mzta-storage.js b/js/mzta-storage.js index 9b29c6b5..ba332f35 100644 --- a/js/mzta-storage.js +++ b/js/mzta-storage.js @@ -278,7 +278,15 @@ 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, error = false, error_message = '') { + async writeTranslation(messageId, data, force = true) { + const { + translated_text = '', + translated_subject = '', + translation_status = '', + lang = '', + error = false, + message = '', + } = data || {}; this.taLog.log('[writeTranslation] messageId: ' + messageId + ', lang: ' + lang + ', force: ' + force); try { let key = this._buildKey(messageId); @@ -288,7 +296,15 @@ export class taStorage { return; } let now = Date.now(); - record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, error: error, message: error_message, ts: now }; + record[taStorage.FIELD_TRANSLATION] = { + translated_text, + translated_subject, + translation_status, + lang, + error, + message, + ts: now + }; record.ts = now; await messenger.storage.local.set({ [key]: record }); } catch (e) { diff --git a/js/mzta-translationstore.js b/js/mzta-translationstore.js index 4f3dbe48..1307596a 100644 --- a/js/mzta-translationstore.js +++ b/js/mzta-translationstore.js @@ -49,7 +49,7 @@ export class taTranslationStore { 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 this._storage.writeTranslation(data_id, data, true); await browser.storage.session.remove(this._processing_prefix + data_id); } catch (e) { this.taLog.error("[saveTranslation] error: " + e); @@ -81,6 +81,8 @@ export class taTranslationStore { return { headerMessageId: data_id, translated_text: translation.translated_text || '', + translated_subject: translation.translated_subject || '', + translation_status: translation.translation_status || '', lang: translation.lang || '', error: translation.error || false, message: translation.message || '', diff --git a/js/mzta-utils-prompt.js b/js/mzta-utils-prompt.js index b123ff29..a100e265 100644 --- a/js/mzta-utils-prompt.js +++ b/js/mzta-utils-prompt.js @@ -173,7 +173,7 @@ export const taPromptUtils = { return { promptText, promptInfo: prompt }; }, - async buildTranslationPrompt(fullMessage, lang) { + async buildTranslationPrompt(fullMessage) { const specialPrompts = await getSpecialPrompts(); const prompt = specialPrompts.find(p => p.id === 'prompt_translate_this'); @@ -183,12 +183,19 @@ export const taPromptUtils = { } const bodyHtml = getMailBody(fullMessage); - let bodyText = htmlBodyToPlainText(bodyHtml.html); - if (bodyText.length === 0) { - bodyText = bodyHtml.text || ''; - } + const mailSubject = fullMessage.headers?.subject?.[0] || ''; - const fullPrompt = promptText + " " + lang + ". \"" + bodyText + "\""; + const finalSubs = await placeholdersUtils.getPlaceholdersValues({ + prompt_text: promptText, + msg_text: { html: bodyHtml.html, text: bodyHtml.text }, + mail_subject: mailSubject, + }); + + const fullPrompt = placeholdersUtils.replacePlaceholders({ + text: promptText, + replacements: finalSubs, + use_default_value: false, + }); return { promptText: fullPrompt, promptInfo: prompt }; }, diff --git a/mzta-background.js b/mzta-background.js index ffb87c08..a78699f7 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -789,8 +789,22 @@ async function _generateTranslationForMessage(headerMessageId, tabId = null, opt await cmd.initWorker(); const aiResponse = await cmd.sendPrompt(); + let translatedBody = ''; + let translatedSubject = ''; + let translationStatus = ''; + try { + const parsed = JSON.parse(aiResponse); + translatedBody = parsed.body || ''; + translatedSubject = parsed.subject || ''; + translationStatus = String(parsed.status || ''); + } catch (e) { + translatedBody = aiResponse; + } + const translationData = { - translated_text: aiResponse, + translated_text: translatedBody, + translated_subject: translatedSubject, + translation_status: translationStatus, lang: lang, headerMessageId: headerMessageId }; From c0ebc646b862829294439b66bd0769a9727fce43 Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 00:27:47 +0200 Subject: [PATCH 26/32] some fixes on JSON translation. see #247 --- js/mzta-compose-script.js | 114 +++++++++++++++++++++++++++----------- js/mzta-storage.js | 9 ++- mzta-background.js | 21 +++++-- 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index a3183507..b60d006b 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -177,6 +177,21 @@ function _updatePanelMargins() { if (lastPanel) lastPanel.style.marginBottom = '1rem'; } +function _isHtml(text) { + return /<[a-z][^>]*>/i.test(text); +} + +function _renderSafeHtml(container, html) { + container.textContent = ''; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + doc.querySelectorAll('script, img').forEach(el => el.remove()); + while (doc.body.firstChild) { + container.appendChild(doc.body.firstChild); + } + container.querySelectorAll('p').forEach(p => { p.style.marginBlockStart = '0'; }); +} + function createThreeDotsMenu(isDark, menuItems, panelColors) { const wrapper = document.createElement('div'); wrapper.style.cssText = 'position: relative; display: inline-flex; align-items: center;'; @@ -1264,54 +1279,89 @@ switch (message.command) { subjectEl.textContent = translationData.translated_subject; translationTextWrapper.appendChild(subjectEl); } - translationText.textContent = translationData.translated_text || ''; + const bodyText = translationData.translated_text || ''; + const bodyIsHtml = _isHtml(bodyText); + if (bodyIsHtml) { + translationText.style.whiteSpace = ''; + _renderSafeHtml(translationText, bodyText); + } else { + translationText.textContent = bodyText; + } } translationTextWrapper.appendChild(translationText); const maxLenTranslation = translationData.maxDisplayLength || 0; const fullTranslationText = translationData.translated_text || ''; + const fullTranslationIsHtml = _isHtml(fullTranslationText); if (!translationData.error && translationData.translation_status !== '-1' && maxLenTranslation > 0 && fullTranslationText.length > maxLenTranslation) { translationText.style.overflow = 'hidden'; translationText.style.transition = 'max-height 0.2s ease'; - let cutPos = fullTranslationText.lastIndexOf(' ', maxLenTranslation); - if (cutPos <= 0) cutPos = maxLenTranslation; - const truncatedTranslation = fullTranslationText.substring(0, cutPos) + '\u2026'; - translationText.textContent = truncatedTranslation; - - requestAnimationFrame(() => { - translationText.style.maxHeight = translationText.scrollHeight + 'px'; - }); - const toggleLink = document.createElement('a'); toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; toggleLink.href = '#'; toggleLink.style.cssText = `display: inline-block; margin-top: 4px; font-size: 13px; color: ${colors.linkColor}; cursor: pointer; text-decoration: underline;`; - let expanded = false; - toggleLink.addEventListener('click', (e) => { - e.preventDefault(); - if (!expanded) { - translationText.textContent = fullTranslationText; + if (fullTranslationIsHtml) { + const collapsedMaxHeight = '4.2em'; + translationText.style.maxHeight = collapsedMaxHeight; + + let expanded = false; + toggleLink.addEventListener('click', (e) => { + e.preventDefault(); + if (!expanded) { + translationText.style.maxHeight = translationText.scrollHeight + 'px'; + toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; + } else { + translationText.style.maxHeight = collapsedMaxHeight; + toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + } + expanded = !expanded; + }); + + requestAnimationFrame(() => { + if (translationText.scrollHeight > translationText.clientHeight) { + translationTextWrapper.appendChild(toggleLink); + } else { + translationText.style.maxHeight = ''; + translationText.style.overflow = ''; + } + }); + } else { + let cutPos = fullTranslationText.lastIndexOf(' ', maxLenTranslation); + if (cutPos <= 0) cutPos = maxLenTranslation; + const truncatedTranslation = fullTranslationText.substring(0, cutPos) + '\u2026'; + translationText.textContent = truncatedTranslation; + + requestAnimationFrame(() => { translationText.style.maxHeight = translationText.scrollHeight + 'px'; - toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; - } else { - translationText.textContent = truncatedTranslation; - const collapsedHeight = translationText.scrollHeight; - translationText.textContent = fullTranslationText; - translationText.style.maxHeight = translationText.scrollHeight + 'px'; - requestAnimationFrame(() => { - translationText.style.maxHeight = collapsedHeight + 'px'; - }); - translationText.addEventListener('transitionend', function handler() { - translationText.removeEventListener('transitionend', handler); + }); + + let expanded = false; + toggleLink.addEventListener('click', (e) => { + e.preventDefault(); + if (!expanded) { + translationText.textContent = fullTranslationText; + translationText.style.maxHeight = translationText.scrollHeight + 'px'; + toggleLink.textContent = browser.i18n.getMessage("translate_see_less") || "See less"; + } else { translationText.textContent = truncatedTranslation; - }); - toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; - } - expanded = !expanded; - }); - translationTextWrapper.appendChild(toggleLink); + const collapsedHeight = translationText.scrollHeight; + translationText.textContent = fullTranslationText; + translationText.style.maxHeight = translationText.scrollHeight + 'px'; + requestAnimationFrame(() => { + translationText.style.maxHeight = collapsedHeight + 'px'; + }); + translationText.addEventListener('transitionend', function handler() { + translationText.removeEventListener('transitionend', handler); + translationText.textContent = truncatedTranslation; + }); + toggleLink.textContent = browser.i18n.getMessage("translate_see_more") || "See more"; + } + expanded = !expanded; + }); + translationTextWrapper.appendChild(toggleLink); + } } translationContainer.appendChild(translationTextWrapper); diff --git a/js/mzta-storage.js b/js/mzta-storage.js index ba332f35..ce3bfa08 100644 --- a/js/mzta-storage.js +++ b/js/mzta-storage.js @@ -274,8 +274,13 @@ export class taStorage { /** * Write the translation field for a given Message-ID. * @param {string} messageId - The Message-ID header string. - * @param {string} translated_text - The translated text. - * @param {string} lang - Target language code. + * @param {Object} data - Translation data object. + * @param {string} [data.translated_text=''] - The translated body text. + * @param {string} [data.translated_subject=''] - The translated subject. + * @param {string} [data.translation_status=''] - Status: "1" = ok, "-1" = skipped. + * @param {string} [data.lang=''] - Target language code. + * @param {boolean} [data.error=false] - Whether an error occurred. + * @param {string} [data.message=''] - Error message. * @param {boolean} [force=true] - If true, overwrite existing translation data. */ async writeTranslation(messageId, data, force = true) { diff --git a/mzta-background.js b/mzta-background.js index a78699f7..5e9344b1 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -430,7 +430,18 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { case 'chatgpt_saveTranslation': async function _saveTranslationFromWebchat(msg) { try { - let translatedText = msg.text.trim(); + let rawText = msg.text.trim(); + let translatedBody = ''; + let translatedSubject = ''; + let translationStatus = ''; + try { + const parsed = JSON.parse(rawText); + translatedBody = parsed.body || ''; + translatedSubject = parsed.subject || ''; + translationStatus = String(parsed.status || ''); + } catch (e) { + translatedBody = rawText; + } let prefs_tr = await browser.storage.sync.get({ translate_lang: prefs_default.translate_lang, default_chatgpt_lang: prefs_default.default_chatgpt_lang, @@ -438,7 +449,9 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { }); let lang = prefs_tr.translate_lang || prefs_tr.default_chatgpt_lang || ''; const translationData = { - translated_text: translatedText, + translated_text: translatedBody, + translated_subject: translatedSubject, + translation_status: translationStatus, lang: lang, headerMessageId: msg.headerMessageId }; @@ -777,7 +790,7 @@ async function _generateTranslationForMessage(headerMessageId, tabId = null, opt taWorkingStatus.stopWorking(); return; } - const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage, lang); + const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage); const cmd = new mzta_specialCommand({ prompt: promptText, @@ -996,7 +1009,7 @@ async function _openTranslationWebchat(headerMessageId, tabId) { taLog.warn("Translation skipped: no language configured (translate_lang and default_chatgpt_lang are both empty)."); return; } - const { promptText, promptInfo } = await taPromptUtils.buildTranslationPrompt(curr_message_full, lang); + const { promptText, promptInfo } = await taPromptUtils.buildTranslationPrompt(curr_message_full); promptInfo.headerMessageId = headerMessageId; promptInfo.translationTabId = tabId; From 83f856d1ae6ce24b37225a0a83c75a3a3a29eb4a Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 00:34:26 +0200 Subject: [PATCH 27/32] trasnlate webchat removed. see #247 --- _locales/en/messages.json | 12 --- api_webchat/messagesArea.js | 23 ------ claude-spec/01-architecture.md | 17 ++-- claude-spec/02-prompts.md | 18 ++--- claude-spec/05-options.md | 11 +-- js/mzta-compose-script.js | 2 +- mzta-background.js | 115 +--------------------------- options/mzta-options-default.js | 1 - pages/translate/mzta-translate.html | 12 --- pages/translate/mzta-translate.js | 21 ----- 10 files changed, 21 insertions(+), 211 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2e9377f2..92e9bad0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -239,10 +239,6 @@ "message": "Save as Summary", "description": "Button label in the webchat window to save the AI response as a message summary" }, - "webchat_save_as_translation": { - "message": "Save as Translation", - "description": "Button label in the webchat window to save the AI response as a message translation" - }, "chatgpt_textarea_not_found_error": { "message": "It seems that the ChatGPT page is taking too long to load. If it finishes loading, click the button on the right. If the problem persists, please check the service status.", "description": "" @@ -2101,10 +2097,6 @@ "message": "Choose when to translate messages: disabled, only when clicking the button, or automatically when opening a message.", "description": "" }, - "prefs_OptionText_translate_display_mode": { - "message": "Display mode for translations", - "description": "" - }, "prefs_OptionText_display_mode_inline": { "message": "Message pane (inline)", "description": "" @@ -2113,10 +2105,6 @@ "message": "Chat window", "description": "" }, - "prefs_OptionText_translate_display_mode_Info": { - "message": "Choose where to display translations. Note: automatic mode always uses inline display.", - "description": "" - }, "prefs_OptionText_translate_max_display_length": { "message": "Maximum length of displayed translation", "description": "" diff --git a/api_webchat/messagesArea.js b/api_webchat/messagesArea.js index 62fffa42..e2611801 100644 --- a/api_webchat/messagesArea.js +++ b/api_webchat/messagesArea.js @@ -481,29 +481,6 @@ class MessagesArea extends HTMLElement { selectionInfo.style.display = "block"; } - // Save as Translation button (only shown for translation webchat sessions) - if(promptData.prompt_info?.headerMessageId && promptData.prompt_info?.translationTabId) { - const saveTranslationButton = document.createElement('button'); - saveTranslationButton.textContent = browser.i18n.getMessage("webchat_save_as_translation"); - saveTranslationButton.classList.add('action_btn'); - saveTranslationButton.addEventListener('click', async () => { - let finalText = removeAloneBRs(fullTextHTMLAtAssignment); - const selectedHTML = this.getCurrentSelectionHTML(); - if(selectedHTML != "") { - finalText = removeAloneBRs(selectedHTML); - } - await browser.runtime.sendMessage({ - command: "chatgpt_saveTranslation", - text: finalText, - headerMessageId: promptData.prompt_info.headerMessageId, - tabId: promptData.prompt_info.translationTabId || promptData.tabId, - }); - browser.runtime.sendMessage({command: "chatgpt_close", window_id: (await browser.windows.getCurrent()).id}); - }); - actionButtons.appendChild(saveTranslationButton); - selectionInfo.style.display = "block"; - } - // diff viewer button if(promptData.prompt_info?.use_diff_viewer == "1") { const diffvButton = document.createElement('button'); diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index 8f5e3e42..636bda65 100644 --- a/claude-spec/01-architecture.md +++ b/claude-spec/01-architecture.md @@ -82,13 +82,8 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs) ### Data Flow: Inline Translation on Message Display -The `translate_display_mode` preference (`'inline'` or `'webchat'`) controls where -the translation is displayed. The `translate_auto` preference controls when it is triggered. - -- `translate_auto = 2` (automatic) always generates inline, regardless of `translate_display_mode`. -- `translate_auto = 1` (manual button) respects `translate_display_mode`: - - `'inline'` → button click triggers inline generation - - `'webchat'` → button click opens the AI chat window via `_openTranslationWebchat()` +The `translate_auto` preference controls when translation is triggered. +Translation always renders inline (webchat mode has been removed). The target language is determined by `translate_lang` (fallback on `default_chatgpt_lang`). @@ -97,16 +92,14 @@ User opens/selects a message in Thunderbird ↓ mzta-compose-script.js (sends "initTranslation" to background) ↓ -mzta-background.js (checks translate + translate_auto + translate_display_mode prefs) +mzta-background.js (checks translate + translate_auto prefs) ↓ ┌──────────────────────────────────────────────────────────┐ │ translate_auto = 0 → do nothing │ │ translate_auto = 1 → show "click to translate" button │ - │ display_mode = inline → click triggers inline gen │ - │ display_mode = webchat → click opens chat window │ - │ translate_auto = 2 → generate immediately (always inline)│ + │ translate_auto = 2 → generate immediately │ └──────────────────────────────────────────────────────────┘ - ↓ (if generating inline) + ↓ taTranslationStore (check cache / set processing) ↓ (cache miss) mzta-special-commands (via Web Worker, NOT chatgpt_web) diff --git a/claude-spec/02-prompts.md b/claude-spec/02-prompts.md index c2c3eaae..60b1624f 100644 --- a/claude-spec/02-prompts.md +++ b/claude-spec/02-prompts.md @@ -88,24 +88,24 @@ The summarize feature uses two distinct prompt pathways: ### Translate: Inline-Only Prompt System -The translate feature uses a single special prompt (`prompt_translate_this`) for translating emails. It supports both inline display and webchat mode, but has no context menu entry. +The translate feature uses a single special prompt (`prompt_translate_this`) for translating emails. Translation always renders inline (no webchat mode). -**Inline Translation on Message Display** (controlled by `translate_auto` and `translate_display_mode` prefs): +**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 + "\""` +- The prompt uses placeholders (`{%mail_subject%}`, `{%mail_html_body%}`, `{%thunderai_translate_lang%}`, `{%thunderai_translate_exclude_lang%}`) resolved via the standard placeholder system +- The AI response is a JSON object: `{ "subject": "...", "body": "...", "status": "1"|"-1" }` + - `status = "1"`: translation completed, subject and body are displayed + - `status = "-1"`: translation skipped (excluded/target language), a "skipped" message is shown - 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) -- `translate_display_mode = 'inline'`: result is rendered as a styled banner (green/teal theme) in the message body via `mzta-compose-script.js` -- `translate_display_mode = 'webchat'`: opens AI chat window; webchat shows a "Save as Translation" button to persist the result inline -- `translate_auto = 2` (automatic) always generates inline regardless of `translate_display_mode` +- 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)`: +**Prompt Building** — `taPromptUtils.buildTranslationPrompt(fullMessage)`: - Retrieves the `prompt_translate_this` special prompt text -- Extracts the email body from the full message -- Combines prompt + language + body text +- Resolves placeholders via `placeholdersUtils.getPlaceholdersValues()` + `replacePlaceholders()` - Returns `{ promptText, promptInfo }` ## Prompt Types Reference diff --git a/claude-spec/05-options.md b/claude-spec/05-options.md index 06c0d250..d8d14ef3 100644 --- a/claude-spec/05-options.md +++ b/claude-spec/05-options.md @@ -100,7 +100,6 @@ These are generated programmatically at the bottom of `mzta-options-default.js` | `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), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) | -| `translate_display_mode` | `'inline'` | Where to display translations: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `translate_auto = 2` and `translate_auto = 3` always use inline regardless of this setting. | | `translate_max_display_length` | `0` | Maximum characters shown in inline translation 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_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. | @@ -134,13 +133,9 @@ The translate settings page provides: - `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. **Display mode dropdown** (`translate_display_mode`) — controls where translations are shown: - - `'inline'` — translation banner in the message pane (default) - - `'webchat'` — opens the AI chat window with a "Save as Translation" button - - Note: `translate_auto = 2` always generates inline regardless of this setting. -4. **Max display length** (`translate_max_display_length`) — number input, limits inline translation text to N characters. `0` = no limit. When truncated, a "See more"/"See less" toggle link is appended. -5. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`. -6. **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`. +3. **Max display length** (`translate_max_display_length`) — number input, limits inline translation text to N characters. `0` = no limit. When truncated, a "See more"/"See less" toggle link is appended. +4. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`. +5. **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`. ## Adding a New Preference diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index b60d006b..46e344a4 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1423,7 +1423,7 @@ switch (message.command) { triggerBtn.onclick = () => { _removeToolbarItem('mzta-toolbar-translation'); browser.runtime.sendMessage({ - command: message.webchat ? "triggerTranslationWebchat" : "triggerTranslationGeneration", + command: "triggerTranslationGeneration", headerMessageId: message.headerMessageId }); }; diff --git a/mzta-background.js b/mzta-background.js index 5e9344b1..7a4b0a27 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -335,7 +335,7 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { 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, translate_display_mode: prefs_default.translate_display_mode, translate_max_display_length: prefs_default.translate_max_display_length }); + let prefs = await browser.storage.sync.get({ translate: prefs_default.translate, translate_auto: prefs_default.translate_auto, translate_max_display_length: prefs_default.translate_max_display_length }); if (!prefs.translate) return; @@ -364,11 +364,7 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { } // Manual button mode (translate_auto === 1) - if (prefs.translate_display_mode === 'inline') { - browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId }); - } else { - browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId, webchat: true }); - } + browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId }); } catch (e) { taLog.error("Error in initTranslation: " + e); } @@ -393,83 +389,17 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { } _triggerTranslationGeneration(message); break; - case 'triggerTranslationWebchat': - async function _triggerTranslationWebchat(message) { - let tabId = sender.tab.id; - let prefs_tw = await browser.storage.sync.get({ - translate_lang: prefs_default.translate_lang, - default_chatgpt_lang: prefs_default.default_chatgpt_lang - }); - const lang_tw = prefs_tw.translate_lang || prefs_tw.default_chatgpt_lang || ''; - if (!lang_tw) { - let tabs = await browser.tabs.query({ active: true, currentWindow: true }); - browser.tabs.sendMessage(tabId, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('translate_no_language_configured') }); - browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId, webchat: true }); - return; - } - await _openTranslationWebchat(message.headerMessageId, tabId); - } - _triggerTranslationWebchat(message); - break; case 'refreshTranslation': async function _refreshTranslation(message) { let tabId = sender.tab.id; await translationStore.removeTranslation(message.headerMessageId); - let prefs_refresh_tr = await browser.storage.sync.get({ translate_display_mode: prefs_default.translate_display_mode }); - if (prefs_refresh_tr.translate_display_mode === 'webchat') { - await _openTranslationWebchat(message.headerMessageId, tabId); - } else { - await _generateTranslationForMessage(message.headerMessageId, tabId); - } + await _generateTranslationForMessage(message.headerMessageId, tabId); } _refreshTranslation(message); break; case 'removeTranslation': translationStore.removeTranslation(message.headerMessageId); break; - case 'chatgpt_saveTranslation': - async function _saveTranslationFromWebchat(msg) { - try { - let rawText = msg.text.trim(); - let translatedBody = ''; - let translatedSubject = ''; - let translationStatus = ''; - try { - const parsed = JSON.parse(rawText); - translatedBody = parsed.body || ''; - translatedSubject = parsed.subject || ''; - translationStatus = String(parsed.status || ''); - } catch (e) { - translatedBody = rawText; - } - let prefs_tr = await browser.storage.sync.get({ - translate_lang: prefs_default.translate_lang, - default_chatgpt_lang: prefs_default.default_chatgpt_lang, - translate_max_display_length: prefs_default.translate_max_display_length - }); - let lang = prefs_tr.translate_lang || prefs_tr.default_chatgpt_lang || ''; - const translationData = { - translated_text: translatedBody, - translated_subject: translatedSubject, - translation_status: translationStatus, - lang: lang, - headerMessageId: msg.headerMessageId - }; - await translationStore.saveTranslation(translationData, msg.headerMessageId); - try { - browser.tabs.sendMessage(msg.tabId, { - command: "showTranslation", - data: { ...translationData, maxDisplayLength: prefs_tr.translate_max_display_length } - }); - } catch (e) { - taLog.error("Error sending showTranslation to tab: " + e); - } - } catch (error) { - console.error("[ThunderAI] Error saving translation from webchat:", error); - } - } - _saveTranslationFromWebchat(message); - 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}); @@ -980,45 +910,6 @@ async function _openSummaryWebchat(headerMessageId, tabId) { } } -async function _openTranslationWebchat(headerMessageId, tabId) { - try { - const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); - if (!messageResult || messageResult.messages.length === 0) { - console.error("[ThunderAI] _openTranslationWebchat: Message not found for headerMessageId:", headerMessageId); - return; - } - - const curr_message = messageResult.messages[0]; - const curr_message_full = await browser.messages.getFull(curr_message.id); - - const prefs = await browser.storage.sync.get({ - ...prefs_default, - translate_lang: prefs_default.translate_lang, - default_chatgpt_lang: prefs_default.default_chatgpt_lang - }); - 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 } }); - return; - } - - const lang = prefs.translate_lang || prefs.default_chatgpt_lang || ''; - if (!lang) { - taLog.warn("Translation skipped: no language configured (translate_lang and default_chatgpt_lang are both empty)."); - return; - } - const { promptText, promptInfo } = await taPromptUtils.buildTranslationPrompt(curr_message_full); - promptInfo.headerMessageId = headerMessageId; - promptInfo.translationTabId = tabId; - - openChatGPT(promptText, promptInfo.action, tabId, promptInfo.name, promptInfo.need_custom_text, promptInfo); - } catch (error) { - console.error("[ThunderAI] Error opening translation webchat:", error); - } -} - // Listen for messages from ThunderAI-Sparks browser.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { switch (message.action) { diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index c1015507..4ac38c96 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -143,7 +143,6 @@ export const prefs_default = { 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 on message open, 3: generate on email receive - translate_display_mode: 'inline', // 'inline' or 'webchat' translate_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline translate_lang: '', // target language, fallback on default_chatgpt_lang translate_exclude_lang: '', // languages to do not translate diff --git a/pages/translate/mzta-translate.html b/pages/translate/mzta-translate.html index dc7a4e9d..b582b36e 100644 --- a/pages/translate/mzta-translate.html +++ b/pages/translate/mzta-translate.html @@ -40,18 +40,6 @@ - - __MSG_prefs_OptionText_translate_display_mode__ - - - - __MSG_prefs_OptionText_translate_max_display_length__ diff --git a/pages/translate/mzta-translate.js b/pages/translate/mzta-translate.js index a4812808..df415879 100644 --- a/pages/translate/mzta-translate.js +++ b/pages/translate/mzta-translate.js @@ -74,8 +74,6 @@ document.addEventListener("DOMContentLoaded", async () => { document.querySelectorAll(".option-input").forEach(element => { element.addEventListener("change", saveOptions); }); - document.getElementById('translate_auto').addEventListener('change', updateDisplayModeConstraint); - 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"); @@ -119,21 +117,6 @@ document.addEventListener("DOMContentLoaded", async () => { // Methods to manage options, derived from: /options/mzta-options.js -function updateDisplayModeConstraint() { - const translate_auto_el = document.getElementById('translate_auto'); - const display_mode_el = document.getElementById('translate_display_mode'); - const autoVal = String(translate_auto_el.value); - if (autoVal === '2' || autoVal === '3') { - display_mode_el.value = 'inline'; - display_mode_el.disabled = true; - browser.storage.sync.set({ translate_display_mode: 'inline' }); - } else if (autoVal === '0') { - display_mode_el.disabled = true; - } else { - display_mode_el.disabled = false; - } -} - function saveOptions(e) { e.preventDefault(); let options = {}; @@ -191,9 +174,6 @@ async function restoreOptions() { if (element.id === 'translate_auto') { default_select_value = prefs_default.translate_auto; } - if (element.id === 'translate_display_mode') { - default_select_value = prefs_default.translate_display_mode; - } const restoreValue = result[element.id] ?? default_select_value; let optionExists = Array.from(element.options).some(opt => opt.value === String(restoreValue)); if (element.tomselect) { @@ -248,5 +228,4 @@ async function restoreOptions() { } setCurrentChoice(getting); - updateDisplayModeConstraint(); } From f1f8d4d3eb614383545241fdfe06758a32b3bd7a Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 23:10:18 +0200 Subject: [PATCH 28/32] typo fixed --- CHANGELOG.md | 2 +- options/mzta-release-notes.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0772a115..8c45aef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@

Version 4.1.0 - ??/??/2026

  • Antispam information are now permanently saved for each message [#675].
  • -
  • [All APIs] A summaru has been added above the mail content [#580].
  • +
  • [All APIs] A summary has been added above the mail content [#580].
  • ...

Version 4.0.3 - 20/03/2026

diff --git a/options/mzta-release-notes.html b/options/mzta-release-notes.html index 3f21a98c..a27a7b28 100644 --- a/options/mzta-release-notes.html +++ b/options/mzta-release-notes.html @@ -10,7 +10,7 @@

Version 4.1.0 - ??/??/2026

  • Antispam information are now permanently saved for each message [#675].
  • -
  • [All APIs] A summaru has been added above the mail content [#580].
  • +
  • [All APIs] A summary has been added above the mail content [#580].
  • ...

Version 4.0.3 - 20/03/2026

From c50406bae025a5b3d0c1e53cf576bf54a9bb9e86 Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 23:03:34 +0200 Subject: [PATCH 29/32] normalizing also accented characters when creating new tags. see #732 --- js/mzta-utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/mzta-utils.js b/js/mzta-utils.js index 30139131..ce5dc9d4 100644 --- a/js/mzta-utils.js +++ b/js/mzta-utils.js @@ -519,6 +519,8 @@ function getTagsKeyFromLabel(tag_names, all_tags_list) { } function sanitizeString(input) { + // Replaces accented characters with their non-accented version + input = input.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // Define the regex to match valid characters const validChar = /^[^ ()/{%*<>"]+$/; // Filter out invalid characters from the string From e33095ca23c344cb9629647262123cf685e64935 Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 23:19:58 +0200 Subject: [PATCH 30/32] translated subject visualization improved. see #247 --- js/mzta-compose-script.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 46e344a4..849d514c 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -1228,10 +1228,7 @@ switch (message.command) { const translationTitleSpan = document.createElement('span'); translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${tc.text}; flex-grow: 1;`; translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation"; - if (translationData.lang) { - translationTitleSpan.textContent += ' (' + translationData.lang + ')'; - } - + const translationMenu = createThreeDotsMenu(colors.isDark, [ { icon: '\u21BB', @@ -1274,10 +1271,13 @@ switch (message.command) { translationText.textContent = browser.i18n.getMessage("translate_skipped"); } else { if (translationData.translated_subject) { - const subjectEl = document.createElement('div'); - subjectEl.style.cssText = 'font-weight: bold; margin-bottom: 4px;'; - subjectEl.textContent = translationData.translated_subject; - translationTextWrapper.appendChild(subjectEl); + // const subjectEl = document.createElement('div'); + // subjectEl.style.cssText = 'font-weight: bold; margin-bottom: 4px;'; + if (translationData.lang) { + translationTitleSpan.textContent = '[' + translationData.lang + '] '; + } + translationTitleSpan.textContent += translationData.translated_subject; + // translationTextWrapper.appendChild(subjectEl); } const bodyText = translationData.translated_text || ''; const bodyIsHtml = _isHtml(bodyText); From 78dfaae3f1a786a27b02eb6db4bd7f6cde6675d6 Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 23:24:16 +0200 Subject: [PATCH 31/32] release notes updated --- CHANGELOG.md | 1 + options/mzta-release-notes.html | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c45aef8..73d65735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@
  • Antispam information are now permanently saved for each message [#675].
  • [All APIs] A summary has been added above the mail content [#580].
  • +
  • [All APIs] Inline auto translation for emails added [#247].
  • ...

Version 4.0.3 - 20/03/2026

diff --git a/options/mzta-release-notes.html b/options/mzta-release-notes.html index a27a7b28..7a4c2276 100644 --- a/options/mzta-release-notes.html +++ b/options/mzta-release-notes.html @@ -11,6 +11,7 @@
  • Antispam information are now permanently saved for each message [#675].
  • [All APIs] A summary has been added above the mail content [#580].
  • +
  • [All APIs] Inline auto translation for emails added [#247].
  • ...

Version 4.0.3 - 20/03/2026

From 6f74c32c2e8330c69d58acb7080d1047ee081921 Mon Sep 17 00:00:00 2001 From: mic Date: Wed, 1 Apr 2026 23:24:28 +0200 Subject: [PATCH 32/32] version set to 4.1.0pre1 --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0ea13754..8bc70f4f 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "ThunderAI", "description": "__MSG_extensionDescription__", - "version": "4.1.0", + "version": "4.1.0pre1", "author": "Mic (m@micz.it)", "homepage_url": "https://micz.it/thunderbird-addon-thunderai/", "browser_specific_settings": {