diff --git a/CHANGELOG.md b/CHANGELOG.md index 0772a115..73d65735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@

Version 4.1.0 - ??/??/2026

Version 4.0.3 - 20/03/2026

diff --git a/README.md b/README.md index 563205bc..f52bf24a 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 and context menu icons
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/en/messages.json b/_locales/en/messages.json index 88e4357a..92e9bad0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -332,8 +332,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", @@ -813,7 +817,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": { @@ -1308,10 +1312,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": "" @@ -1480,6 +1492,10 @@ "message": "Summarize", "description": "" }, + "context_menu_mzta-translate": { + "message": "Translate", + "description": "" + }, "noActiveCalendar": { "message": "No editable calendar found!", "description": "" @@ -1957,18 +1973,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": "When the email is opened", - "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 +1981,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": "" @@ -2049,14 +2045,158 @@ "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_action_auto_disabled": { + "message": "Disabled", + "description": "" + }, + "prefs_OptionText_action_auto_manual": { + "message": "Manual button only", + "description": "" + }, + "prefs_OptionText_action_auto_automatic": { + "message": "When the email is opened", + "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_display_mode_inline": { + "message": "Message pane (inline)", + "description": "" + }, + "prefs_OptionText_display_mode_webchat": { + "message": "Chat window", + "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": "" + }, + "prefs_OptionText_translate_lang_Info": { + "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": "" + }, + "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": "" + }, + "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": "" + }, + "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": "" }, + "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": "" }, + "translate_by": { + "message": "Translation by", + "description": "" + }, "prefs_THStats_1": { "message": "Do you want beautiful statistics about your emails?", "description": "" @@ -2088,5 +2228,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/_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 5a7071cd..039877e5 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": { diff --git a/api_webchat/messagesArea.js b/api_webchat/messagesArea.js index 6c01ddca..e2611801 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'); diff --git a/claude-spec/01-architecture.md b/claude-spec/01-architecture.md index a5b08756..13209150 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 @@ -80,6 +80,34 @@ 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. +Translation always renders inline (webchat mode has been removed). + +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 │ + └──────────────────────────────────────────────────────────┘ + ↓ + 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) +``` ### 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`: @@ -92,7 +120,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 ↓ @@ -101,6 +129,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 | @@ -110,8 +159,8 @@ taSummaryStore.saveSummary() | `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-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-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 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 | @@ -120,6 +169,7 @@ taSummaryStore.saveSummary() | `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 | @@ -162,6 +212,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 | @@ -174,3 +225,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..60b1624f 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,28 @@ 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 translating emails. Translation always renders inline (no webchat mode). + +**Inline Translation on Message Display** (controlled by `translate_auto` pref): +- Uses a single special prompt: `prompt_translate_this` +- 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) +- 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)`: +- Retrieves the `prompt_translate_this` special prompt 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 d929ceb9..d8d14ef3 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,10 @@ 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), `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), `3` = generate on email receive (background pre-cache via `onNewMailReceived`, no UI during generation) | +| `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/`) @@ -120,6 +124,19 @@ 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. **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 1. Add the key and default value to `prefs_default` in `options/mzta-options-default.js` diff --git a/images/ai_translate.png b/images/ai_translate.png new file mode 100644 index 00000000..4b5d6cd7 Binary files /dev/null and b/images/ai_translate.png differ 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 00000000..91abf898 Binary files /dev/null and b/images/menu_translate.png differ diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 1eb3de51..849d514c 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,6 +44,154 @@ function getCleanBodyHtml() { return clone; } +// ── 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'; + // 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' : ''; +} + +const _TOOLBAR_SLOT_ORDER = ['mzta-toolbar-spam', 'mzta-toolbar-summary', 'mzta-toolbar-translation']; + +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); + + _updateToolbarVisibility(); +} + +function _removeToolbarItem(id) { + const el = document.getElementById(id); + if (el) el.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'; +} + +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;'; @@ -725,97 +872,69 @@ 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 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'; - - 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;`; - - 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 = '4px'; - 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.appendChild(existingFixedTriggerProgress); - document.body.insertBefore(reposTriggerWrapperProgress, containerProgress.nextSibling); - } + const colors = _getThemeColors(); + // 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); 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.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) { - 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; @@ -823,115 +942,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); - - 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 = '4px'; - 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.appendChild(existingFixedTrigger); - document.body.insertBefore(reposTriggerWrapper, container.nextSibling); - } + panel.appendChild(reasonText); + panel.appendChild(rightGroup); + _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(); - - 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; 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: ${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"; @@ -944,18 +1024,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(); @@ -973,57 +1051,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; @@ -1032,18 +1098,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) => { @@ -1058,7 +1121,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); @@ -1076,110 +1138,299 @@ switch (message.command) { summaryBody.appendChild(summaryTextWrapper); summaryContainer.appendChild(summaryBody); - const spamBanner = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - document.body.insertBefore(summaryContainer, spamBanner ? spamBanner.nextSibling : document.body.firstChild); + _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;`; - 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 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 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;`; + const genLoading = document.createElement('img'); + genLoading.src = browser.runtime.getURL("/images/loading.gif"); + genLoading.style.cssText = "height: 16px; width: 16px;"; - 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);' : ''}`; + const genTitle = document.createElement('span'); + genTitle.className = 'thunderai-summary-title'; + genTitle.textContent = browser.i18n.getMessage("summarize_generating"); + genTitle.style.cssText = 'font-size: 14px;'; - const generatingLoadingImg = document.createElement('img'); - generatingLoadingImg.src = browser.runtime.getURL("/images/loading.gif"); - generatingLoadingImg.style.cssText = "height: 16px; width: 16px;"; + genContainer.appendChild(genIcon); + genContainer.appendChild(genLoading); + genContainer.appendChild(genTitle); - 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 spamBannerGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress'); - document.body.insertBefore(generatingContainer, spamBannerGen ? spamBannerGen.nextSibling : document.body.firstChild); + _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); - } + _addToolbarItem('mzta-toolbar-summary', triggerBtn); return Promise.resolve(true); + } + + 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.className = 'thunderai-translation-pane'; + 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_translate.png"); + 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: ${tc.text}; flex-grow: 1;`; + translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation"; + + const translationMenu = createThreeDotsMenu(colors.isDark, [ + { + icon: '\u21BB', + label: browser.i18n.getMessage("translate_refresh") || 'Refresh translation', + hoverColor: colors.isDark ? '#4d9de0' : '#1a5fa8', + disableAfterClick: true, + onClick: () => { + browser.runtime.sendMessage({ command: "refreshTranslation", headerMessageId: translationData.headerMessageId }); + } + }, + { + icon: '\u00D7', + label: browser.i18n.getMessage("translate_delete") || 'Delete translation', + hoverColor: '#cc0000', + onClick: () => { + _removePanel('mzta-translation-banner'); + browser.runtime.sendMessage({ command: "removeTranslation", headerMessageId: translationData.headerMessageId }); + } + } + ], { bg: tc.bg, border: tc.border, text: tc.text }); + + const translationBranding = document.createElement('span'); + translationBranding.textContent = browser.i18n.getMessage("translate_by") + " ThunderAI"; + translationBranding.style.cssText = 'font-style: italic; font-size: 10px; opacity: 0.5; white-space: nowrap;'; + + translationHeader.appendChild(translationIcon); + translationHeader.appendChild(translationTitleSpan); + translationHeader.appendChild(translationBranding); + 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) { + 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;'; + if (translationData.lang) { + translationTitleSpan.textContent = '[' + translationData.lang + '] '; + } + translationTitleSpan.textContent += translationData.translated_subject; + // translationTextWrapper.appendChild(subjectEl); + } + 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'; + + 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;`; + + 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'; + }); + + 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; + 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); + + _addPanel('mzta-translation-banner', translationContainer); + return Promise.resolve(true); + } + + case "showTranslationGenerating": { + if (document.getElementById('mzta-translation-generating')) return Promise.resolve(true); + + _removePanel('mzta-translation-banner'); + _removeToolbarItem('mzta-toolbar-translation'); + + 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 genIcon = document.createElement('img'); + 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'); + genLoading.src = browser.runtime.getURL("/images/loading.gif"); + genLoading.style.cssText = "height: 16px; width: 16px;"; + + const genTitle = document.createElement('span'); + genTitle.textContent = browser.i18n.getMessage("translate_generating") || "Translating..."; + genTitle.style.cssText = 'font-size: 14px;'; + + genContainer.appendChild(genIcon); + genContainer.appendChild(genLoading); + genContainer.appendChild(genTitle); + + _addPanel('mzta-translation-generating', genContainer); + return Promise.resolve(true); + } + + case "showTranslationButton": { + if (document.getElementById('mzta-toolbar-translation')) return Promise.resolve(true); + + 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;`; + + const triggerIcon = document.createElement('img'); + triggerIcon.src = browser.runtime.getURL("/images/ai_translate.png"); + 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_translation") || "Get AI Translation"; + triggerBtn.appendChild(triggerLabel); + + triggerBtn.onmouseover = () => { triggerBtn.style.opacity = '1'; }; + triggerBtn.onmouseout = () => { triggerBtn.style.opacity = '0.7'; }; + triggerBtn.onclick = () => { + _removeToolbarItem('mzta-toolbar-translation'); + browser.runtime.sendMessage({ + command: "triggerTranslationGeneration", + headerMessageId: message.headerMessageId + }); + }; + + _addToolbarItem('mzta-toolbar-translation', triggerBtn); + return Promise.resolve(true); + } default: // do nothing @@ -1189,4 +1440,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-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); 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..ce3bfa08 100644 --- a/js/mzta-storage.js +++ b/js/mzta-storage.js @@ -274,11 +274,24 @@ 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, translated_text, lang, force = true) { + 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 +301,15 @@ 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_subject, + translation_status, + lang, + error, + message, + ts: now + }; record.ts = now; await messenger.storage.local.set({ [key]: record }); } catch (e) { @@ -296,6 +317,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..1307596a --- /dev/null +++ b/js/mzta-translationstore.js @@ -0,0 +1,152 @@ +/* + * 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, true); + 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 || '', + translated_subject: translation.translated_subject || '', + translation_status: translation.translation_status || '', + 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..a100e265 100644 --- a/js/mzta-utils-prompt.js +++ b/js/mzta-utils-prompt.js @@ -173,6 +173,33 @@ export const taPromptUtils = { return { promptText, promptInfo: prompt }; }, + async buildTranslationPrompt(fullMessage) { + 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); + const mailSubject = fullMessage.headers?.subject?.[0] || ''; + + 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 }; + }, + /** * Extracts tags from the response text. * @param {string} response_text - The response text from which to extract tags. diff --git a/js/mzta-utils.js b/js/mzta-utils.js index 8aad2068..ce5dc9d4 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_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) { @@ -517,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 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": { diff --git a/mzta-background.js b/mzta-background.js index 27d99f62..7a4b0a27 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, @@ -62,6 +63,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 +97,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 +331,75 @@ 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, 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, maxDisplayLength: prefs.translate_max_display_length } }); + return; + } + + if (await translationStore.isProcessing(message.headerMessageId)) { + browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + 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 }); + } catch (e) { + taLog.error("Error in initTranslation: " + e); + } + } + _initTranslation(); + break; + 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); + 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}); @@ -591,6 +663,106 @@ async function _generateSummaryForMessage(headerMessageId, tabId = null, options } } +// 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, + 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) { + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation, maxDisplayLength: prefs.translate_max_display_length } }); + 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; + } + + await translationStore.setProcessing(headerMessageId); + taWorkingStatus.startWorking(); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" }); + + 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 connectionType = getConnectionType(prefs, {}, 'translate'); + + if (connectionType === 'chatgpt_web') { + const errorMsg = browser.i18n.getMessage('translate_chatgpt_web_not_supported'); + await translationStore.saveError(headerMessageId, errorMsg); + if (tabId) browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: errorMsg } }); + taWorkingStatus.stopWorking(); + return; + } + const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage); + + const cmd = new mzta_specialCommand({ + prompt: promptText, + llm: connectionType, + do_debug: prefs.do_debug, + config: {} + }); + + 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: translatedBody, + translated_subject: translatedSubject, + translation_status: translationStatus, + lang: lang, + headerMessageId: headerMessageId + }; + await translationStore.saveTranslation(translationData, headerMessageId); + 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)); + if (tabId) 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) @@ -1171,13 +1343,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(); } @@ -1348,6 +1522,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); @@ -1366,6 +1547,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; } @@ -1375,12 +1557,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 }); } }); @@ -1405,7 +1591,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){ @@ -1439,15 +1626,17 @@ async function processEmails(args) { addTagsAuto = false, spamFilter = false, summarize = false, - summarizeOnReceive = false + summarizeOnReceive = false, + translateOnReceive = false, + translate = 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 || translate) { let prefs_aats = await browser.storage.sync.get({ add_tags_maxnum: prefs_default.add_tags_maxnum, connection_type: prefs_default.connection_type, @@ -1552,6 +1741,21 @@ async function processEmails(args) { messageData: { message, fullMessage: curr_fullMessage } }); } + + if (translateOnReceive || translate) { + if (!curr_fullMessage) { + curr_fullMessage = await browser.messages.getFull(message.id); + } + 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 } + }); + } } } diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index 76510994..4ac38c96 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,11 @@ export const prefs_default = { 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, + translate_auto: 0, // 0: disabled, 1: manual button, 2: automatic on message open, 3: generate on email receive + 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/options/mzta-options.html b/options/mzta-options.html index 3e23111e..2d247247 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__
@@ -242,10 +253,11 @@ - __MSG_prefs_storage_title__ + ThunderAI __MSG_prefs_storage_title__ __MSG_prefs_storage_size__:   +
__MSG_prefs_storage_info__ diff --git a/options/mzta-options.js b/options/mzta-options.js index eac61c34..eda0c48f 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/options/mzta-release-notes.html b/options/mzta-release-notes.html index 3f21a98c..7a4c2276 100644 --- a/options/mzta-release-notes.html +++ b/options/mzta-release-notes.html @@ -10,7 +10,8 @@

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].
  • +
  • [All APIs] Inline auto translation for emails added [#247].
  • ...

Version 4.0.3 - 20/03/2026

diff --git a/pages/summarize/mzta-summarize.html b/pages/summarize/mzta-summarize.html index 2bf1d486..f5f369c3 100644 --- a/pages/summarize/mzta-summarize.html +++ b/pages/summarize/mzta-summarize.html @@ -31,9 +31,9 @@