mail translation first try. see #247

This commit is contained in:
mic 2026-03-27 23:59:17 +01:00
parent 681f0f0f18
commit 32abc00693
17 changed files with 1316 additions and 27 deletions

View file

@ -2049,6 +2049,102 @@
"message": "Delete summary",
"description": ""
},
"prefs_OptionText_translate": {
"message": "Translate email",
"description": ""
},
"prefs_OptionText_translate_use_specific_integration_Info": {
"message": "If checked, the Model and API specified below will be used for translating email(s), regardless the one chosen in the ThunderAI options page.",
"description": ""
},
"prefs_OptionText_translate_Info": {
"message": "If checked, adds a translate button in the message body.",
"description": ""
},
"prefs_OptionText_btnManageTranslateInfo": {
"message": "Manage translate settings",
"description": ""
},
"Translate_PageTitle": {
"message": "Manage Translation Settings",
"description": ""
},
"Translate_info_default": {
"message": "In this page you can modify the default prompt used to translate emails.",
"description": ""
},
"Translate_prompt_text_title": {
"message": "Current prompt text",
"description": ""
},
"Translate_prompt_prefs_title": {
"message": "Translation Options",
"description": ""
},
"prefs_OptionText_translate_auto": {
"message": "Auto-translate messages",
"description": ""
},
"prefs_OptionText_translate_auto_disabled": {
"message": "Disabled",
"description": ""
},
"prefs_OptionText_translate_auto_manual": {
"message": "Manual button only",
"description": ""
},
"prefs_OptionText_translate_auto_automatic": {
"message": "Automatic",
"description": ""
},
"prefs_OptionText_translate_auto_Info": {
"message": "Choose when to translate messages: disabled, only when clicking the button, or automatically when opening a message.",
"description": ""
},
"prefs_OptionText_translate_lang": {
"message": "Translation target language",
"description": ""
},
"prefs_OptionText_translate_lang_Info": {
"message": "Language to translate emails into. If empty, uses the default language setting.",
"description": ""
},
"prefs_OptionText_Translate_main_prompt": {
"message": "The prompt describing the translation task:",
"description": ""
},
"translate_generating": {
"message": "Translating...",
"description": ""
},
"translate_click_to_generate": {
"message": "Click here to translate this email",
"description": ""
},
"get_ai_translation": {
"message": "AI Translation",
"description": ""
},
"translate_chatgpt_web_not_supported": {
"message": "Auto-translation requires an API-based connection. Please configure an API connection in ThunderAI settings.",
"description": ""
},
"translate_refresh": {
"message": "Refresh translation",
"description": ""
},
"translate_delete": {
"message": "Delete translation",
"description": ""
},
"translate_banner_title": {
"message": "AI Translation",
"description": ""
},
"translate_error": {
"message": "Translation failed.",
"description": ""
},
"antispam_by": {
"message": "Antispam by",
"description": ""

View file

@ -78,6 +78,38 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs)
mzta-compose-script.js (render summary banner in message body)
```
### Data Flow: Inline Translation on Message Display
The `translate_auto` preference controls when translation is triggered. Unlike summarize, translation has no `display_mode` option (always inline).
- `translate_auto = 0` (disabled) → do nothing
- `translate_auto = 1` (manual button) → show "Get AI Translation" button in message body
- `translate_auto = 2` (automatic) → generate translation immediately on message open
The target language is determined by `translate_lang` (fallback on `default_chatgpt_lang`).
```
User opens/selects a message in Thunderbird
mzta-compose-script.js (sends "initTranslation" to background)
mzta-background.js (checks translate + translate_auto prefs)
┌──────────────────────────────────────────────────────────┐
│ translate_auto = 0 → do nothing │
│ translate_auto = 1 → show "click to translate" button │
│ translate_auto = 2 → generate immediately (always inline)│
└──────────────────────────────────────────────────────────┘
↓ (if generating)
taTranslationStore (check cache / set processing)
↓ (cache miss)
mzta-special-commands (via Web Worker, NOT chatgpt_web)
taTranslationStore (save result via taStorage)
mzta-compose-script.js (render translation banner in message body)
```
## Key Modules
| File | Role |
@ -87,7 +119,7 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs)
| `js/mzta-prompts.js` | Prompt definitions (built-in) and custom prompt loading |
| `js/mzta-placeholders.js` | Placeholder definitions and resolution logic |
| `js/mzta-utils.js` | General utilities (email parsing, storage helpers, etc.) |
| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly) |
| `js/mzta-utils-prompt.js` | Prompt-specific utilities (text truncation, lang injection, `buildSummaryPrompt()` for unified summary prompt assembly, `buildTranslationPrompt()` for translation prompt assembly) |
| `js/mzta-compose-script.js` | Content script for compose and message display: injects AI response into compose window, renders summary/spam banners in message display |
| `js/mzta-chatgpt.js` | ChatGPT Web integration (opens browser window, reads DOM) |
| `js/mzta-special-commands.js` | Handles special prompt actions (add_tags, calendar, task) |
@ -97,6 +129,7 @@ mzta-background.js (checks summarize_auto + summarize_display_mode prefs)
| `js/mzta-store.js` | Storage abstraction helpers |
| `js/mzta-storage.js` | Unified per-message storage layer (`taStorage` class) for summary, spam, and translation data |
| `js/mzta-summarystore.js` | Summary-specific storage wrapper (`taSummaryStore` class) with caching, truncation, and processing-state tracking |
| `js/mzta-translationstore.js` | Translation-specific storage wrapper (`taTranslationStore` class) with caching, truncation, and processing-state tracking |
| `js/mzta-working-status.js` | Visual status indicator during AI processing |
| `js/mzta-addatags-exclusion-list.js` | Tag exclusion list management |
| `js/mzta-placeholders-autocomplete.js` | Autocomplete for placeholders in prompt editor |
@ -139,6 +172,7 @@ Each subdirectory is a self-contained settings/UI page for a specific feature:
| `get-task/` | Task creation settings |
| `spamfilter/` | Spam filter settings |
| `summarize/` | Email summarization settings |
| `translate/` | Email translation settings |
| `onboarding/` | First-run welcome page |
| `_lib/` | Shared libraries used by pages |
@ -151,3 +185,5 @@ All preferences are stored via `browser.storage.local`. The keys and default val
Per-message data (summaries, spam reports, translations) is stored via `js/mzta-storage.js` (`taStorage` class). Each record is keyed by `msg:<headerMessageId>` 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.

View file

@ -55,6 +55,7 @@ Some prompts trigger additional Thunderbird actions beyond just sending text to
| `summarize` | Summarize email content |
| `get_calendar_event` | Extract and create a calendar event |
| `get_task` | Extract and create a task |
| `translate` | Translate email content into a target language |
These special prompts can have their own dedicated API integration settings (configured in the Options page). The list of these special prompts is in `options/mzta-options-default.js` as `special_prompts_with_integration`.
@ -85,6 +86,26 @@ The summarize feature uses two distinct prompt pathways:
- Accepts an array of `{ message, fullMessage }` entries
- Returns `{ promptText, promptInfo }` where `promptInfo` is the `prompt_summarize` prompt object
### Translate: Inline-Only Prompt System
The translate feature uses a single special prompt (`prompt_translate_this`) for inline translation in the message body. Unlike summarize, it has no context menu entry and no webchat mode.
**Inline Translation on Message Display** (controlled by `translate_auto` pref):
- Uses a single special prompt: `prompt_translate_this`
- The prompt text is appended with the target language and the email body: `prompt_text + " " + lang + ". \"" + body_text + "\""`
- Target language is determined by `translate_lang` pref, falling back to `default_chatgpt_lang`
- Does **not** support `chatgpt_web` connection type (shows error if configured)
- Result is rendered as a styled banner (green/teal theme) in the message body via `mzta-compose-script.js`
- Banner includes refresh (↻) and delete (×) buttons
- Cached per-message via `taTranslationStore` / `taStorage` (max 100 entries)
- The prompt was originally a regular prompt (`defaultPrompts`) and was moved to `specialPrompts` with `is_special: "1"` and `type: "1"` (reading email only)
**Prompt Building** — `taPromptUtils.buildTranslationPrompt(fullMessage, lang)`:
- Retrieves the `prompt_translate_this` special prompt text
- Extracts the email body from the full message
- Combines prompt + language + body text
- Returns `{ promptText, promptInfo }`
## Prompt Types Reference
```

View file

@ -35,7 +35,7 @@ use_specific_integration (default: false)
### Special Prompt Integration Overrides
The 5 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`) each get their own `use_specific_integration` and `connection_type` keys:
The 6 special prompts (`add_tags`, `spamfilter`, `summarize`, `get_calendar_event`, `get_task`, `translate`) each get their own `use_specific_integration` and `connection_type` keys:
```
{prefix}_use_specific_integration (default: false)
@ -98,6 +98,9 @@ These are generated programmatically at the bottom of `mzta-options-default.js`
| `summarize_auto` | `1` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open) |
| `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` always uses inline regardless of this setting. |
| `summarize_max_display_length` | `0` | Maximum characters shown in inline summary before truncation. `0` = no limit (show full text). When set, text is truncated at a word boundary and a "See more"/"See less" toggle link is shown. |
| `translate` | `true` | Enable email translation |
| `translate_auto` | `0` | Auto-translate mode: `0` = disabled, `1` = manual (show button), `2` = automatic (translate on message open) |
| `translate_lang` | `''` | Target language for translation. Falls back to `default_chatgpt_lang` if empty. |
### Summarize Settings Page (`pages/summarize/`)
@ -120,6 +123,20 @@ The summarize settings page provides:
- Each has Save/Reset buttons and placeholder autocomplete
- Default text comes from i18n strings (`prompt_summarize_full_text`, etc.)
### Translate Settings Page (`pages/translate/`)
The translate settings page provides:
1. **Specific integration checkbox** — enables per-feature API override (like other special prompts)
2. **Auto-translate dropdown** (`translate_auto`) — three modes:
- `0` (Disabled) — no inline translations
- `1` (Manual) — shows a "Get AI Translation" button in message display
- `2` (Automatic) — generates translation immediately when message is opened
3. **Target language** (`translate_lang`) — text input for the destination language. If empty, falls back to `default_chatgpt_lang`.
4. **One editable prompt** — the translation instruction prompt (`prompt_translate_this`) with Save/Reset buttons and placeholder autocomplete. Default text comes from i18n string `prompt_translate_this_full_text`.
Unlike summarize, translation has no `display_mode` option (always inline) and no max display length setting.
## Adding a New Preference
1. Add the key and default value to `prefs_default` in `options/mzta-options-default.js`

View file

@ -1181,6 +1181,209 @@ switch (message.command) {
}
return Promise.resolve(true);
case "showTranslation":
const existingTranslationGenerating = document.getElementById('mzta-translation-generating');
if(existingTranslationGenerating) existingTranslationGenerating.remove();
const existingTranslationTriggerWrapper = document.getElementById('mzta-translation-trigger-wrapper');
if(existingTranslationTriggerWrapper) existingTranslationTriggerWrapper.remove();
const existingTranslationTriggerBtn = document.getElementById('mzta-translation-trigger');
if(existingTranslationTriggerBtn) existingTranslationTriggerBtn.remove();
const existingTranslationBanner = document.getElementById('mzta-translation-banner');
if(existingTranslationBanner) existingTranslationBanner.remove();
const translationData = message.data;
const translationContainer = document.createElement('div');
translationContainer.id = 'mzta-translation-banner';
const isDarkTranslation = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorTranslation = isDarkTranslation ? '#1a2e2a' : '#e8f5e9';
let textColorTranslation = isDarkTranslation ? '#c8e6c9' : '#1b5e20';
let borderColorTranslation = isDarkTranslation ? '#2e5740' : '#a5d6a7';
if (translationData.error) {
bgColorTranslation = isDarkTranslation ? '#3a1a1a' : '#f7e6e6';
textColorTranslation = isDarkTranslation ? '#ffcccc' : '#660000';
borderColorTranslation = '#660000';
}
translationContainer.className = 'thunderai-translation-pane';
translationContainer.style.cssText = `background-color: ${bgColorTranslation}; color: ${textColorTranslation}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorTranslation}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`;
const translationHeader = document.createElement('div');
translationHeader.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 6px;';
const translationIcon = document.createElement('img');
translationIcon.src = browser.runtime.getURL("/images/ai_summary.png");
translationIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslation ? ' filter: invert(1);' : ''}`;
const translationTitleSpan = document.createElement('span');
translationTitleSpan.style.cssText = `font-weight: bold; font-size: 14px; color: ${textColorTranslation}; flex-grow: 1;`;
translationTitleSpan.textContent = browser.i18n.getMessage("translate_banner_title") || "AI Translation";
if (translationData.lang) {
translationTitleSpan.textContent += ' (' + translationData.lang + ')';
}
const translationMenu = createThreeDotsMenu(isDarkTranslation, [
{
icon: '↻',
label: browser.i18n.getMessage("translate_refresh") || 'Refresh translation',
hoverColor: isDarkTranslation ? '#4d9de0' : '#1a5fa8',
disableAfterClick: true,
onClick: () => {
browser.runtime.sendMessage({
command: "refreshTranslation",
headerMessageId: translationData.headerMessageId
});
}
},
{
icon: '×',
label: browser.i18n.getMessage("translate_delete") || 'Delete translation',
hoverColor: '#cc0000',
onClick: () => {
translationContainer.remove();
browser.runtime.sendMessage({
command: "removeTranslation",
headerMessageId: translationData.headerMessageId
});
}
}
], { bg: bgColorTranslation, border: borderColorTranslation, text: textColorTranslation });
translationHeader.appendChild(translationIcon);
translationHeader.appendChild(translationTitleSpan);
translationHeader.appendChild(translationMenu);
translationContainer.appendChild(translationHeader);
const translationText = document.createElement('div');
translationText.style.cssText = 'white-space: pre-wrap; line-height: 1.5;';
if (translationData.error) {
translationText.textContent = translationData.message || browser.i18n.getMessage("translate_error") || "Translation failed.";
} else {
translationText.textContent = translationData.translated_text || '';
}
translationContainer.appendChild(translationText);
const summaryBannerForTranslation = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-generating');
const spamBannerForTranslation = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
const insertAfterTranslation = summaryBannerForTranslation || spamBannerForTranslation;
if (insertAfterTranslation) {
document.body.insertBefore(translationContainer, insertAfterTranslation.nextSibling);
} else {
document.body.insertBefore(translationContainer, document.body.firstChild);
}
return Promise.resolve(true);
case "showTranslationGenerating":
const existingTranslationGen = document.getElementById('mzta-translation-generating');
if(existingTranslationGen) return Promise.resolve(true);
const existingTranslationBannerGen = document.getElementById('mzta-translation-banner');
if(existingTranslationBannerGen) existingTranslationBannerGen.remove();
const existingTranslationTrigWrap = document.getElementById('mzta-translation-trigger-wrapper');
if(existingTranslationTrigWrap) existingTranslationTrigWrap.remove();
const existingTranslationTrig = document.getElementById('mzta-translation-trigger');
if(existingTranslationTrig) existingTranslationTrig.remove();
const isDarkTranslationGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorTranslationGen = isDarkTranslationGen ? '#1a2e2a' : '#e8f5e9';
let textColorTranslationGen = isDarkTranslationGen ? '#c8e6c9' : '#1b5e20';
let borderColorTranslationGen = isDarkTranslationGen ? '#2e5740' : '#a5d6a7';
const translationGenContainer = document.createElement('div');
translationGenContainer.id = 'mzta-translation-generating';
translationGenContainer.className = 'thunderai-translation-pane';
translationGenContainer.style.cssText = `background-color: ${bgColorTranslationGen}; color: ${textColorTranslationGen}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorTranslationGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; display: flex; align-items: center; gap: 10px;`;
const translationGenIcon = document.createElement('img');
translationGenIcon.src = browser.runtime.getURL("/images/ai_summary.png");
translationGenIcon.style.cssText = `height: 16px; width: 16px; flex-shrink: 0;${isDarkTranslationGen ? ' filter: invert(1);' : ''}`;
const translationGenLoadingImg = document.createElement('img');
translationGenLoadingImg.src = browser.runtime.getURL("/images/loading.gif");
translationGenLoadingImg.style.cssText = "height: 16px; width: 16px;";
const translationGenTitle = document.createElement('span');
translationGenTitle.textContent = browser.i18n.getMessage("translate_generating") || "Translating...";
translationGenTitle.style.cssText = `font-size: 14px;`;
translationGenContainer.appendChild(translationGenIcon);
translationGenContainer.appendChild(translationGenLoadingImg);
translationGenContainer.appendChild(translationGenTitle);
const summaryBannerForGen = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-generating');
const spamBannerForGen = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
const insertAfterGen = summaryBannerForGen || spamBannerForGen;
if (insertAfterGen) {
document.body.insertBefore(translationGenContainer, insertAfterGen.nextSibling);
} else {
document.body.insertBefore(translationGenContainer, document.body.firstChild);
}
return Promise.resolve(true);
case "showTranslationButton":
const existingTranslationButton = document.getElementById('mzta-translation-trigger');
if(existingTranslationButton) return Promise.resolve(true);
const isDarkTranslationBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let bgColorTranslationBtn = isDarkTranslationBtn ? '#1a2e2a' : '#e8f5e9';
let textColorTranslationBtn = isDarkTranslationBtn ? '#c8e6c9' : '#1b5e20';
let borderColorTranslationBtn = isDarkTranslationBtn ? '#2e5740' : '#a5d6a7';
const spamBannerTranslationTrigger = document.getElementById('mzta-spam-report-banner') || document.getElementById('mzta-spam-check-progress');
const summaryBannerTranslationTrigger = document.getElementById('mzta-summary-banner') || document.getElementById('mzta-summary-trigger-wrapper') || document.getElementById('mzta-summary-trigger');
const translationTriggerBtn = document.createElement('div');
translationTriggerBtn.id = 'mzta-translation-trigger';
translationTriggerBtn.title = browser.i18n.getMessage("translate_click_to_generate") || "Click to translate this email";
const translationTriggerBtnBase = `background-color: ${bgColorTranslationBtn}; border: 1px solid ${borderColorTranslationBtn}; border-radius: 4px; padding: 6px 10px; cursor: pointer; font-family: system-ui, -apple-system, sans-serif; font-size: 12px; font-style: italic; opacity: 0.7; transition: opacity 0.2s; color: ${textColorTranslationBtn}; display: inline-flex; align-items: center; gap: 6px; width: fit-content;`;
const insertAfterTranslationBtn = summaryBannerTranslationTrigger || spamBannerTranslationTrigger;
if (insertAfterTranslationBtn) {
translationTriggerBtn.style.cssText = translationTriggerBtnBase + ' margin-left: auto; margin-top: 4px;';
} else {
translationTriggerBtn.style.cssText = translationTriggerBtnBase + ' position: fixed; top: 8px; right: 8px; z-index: 9997;';
}
const translationTriggerIcon = document.createElement('img');
translationTriggerIcon.src = browser.runtime.getURL("/images/ai_summary.png");
translationTriggerIcon.style.cssText = `height: 14px; width: 14px;${isDarkTranslationBtn ? ' filter: invert(1);' : ''}`;
translationTriggerBtn.appendChild(translationTriggerIcon);
const translationTriggerLabel = document.createElement('span');
translationTriggerLabel.textContent = browser.i18n.getMessage("get_ai_translation") || "Get AI Translation";
translationTriggerBtn.appendChild(translationTriggerLabel);
translationTriggerBtn.onmouseover = () => { translationTriggerBtn.style.opacity = '1'; };
translationTriggerBtn.onmouseout = () => { translationTriggerBtn.style.opacity = '0.7'; };
translationTriggerBtn.onclick = async () => {
translationTriggerBtn.onclick = null;
translationTriggerBtn.style.cursor = 'default';
translationTriggerBtn.style.opacity = '0.7';
translationTriggerBtn.onmouseover = null;
translationTriggerBtn.onmouseout = null;
const wrapper = document.getElementById('mzta-translation-trigger-wrapper');
if (wrapper) wrapper.remove(); else translationTriggerBtn.remove();
browser.runtime.sendMessage({
command: "triggerTranslationGeneration",
headerMessageId: message.headerMessageId
});
};
if (insertAfterTranslationBtn) {
const translationTriggerWrapper = document.createElement('div');
translationTriggerWrapper.id = 'mzta-translation-trigger-wrapper';
translationTriggerWrapper.style.cssText = 'display: flex; justify-content: flex-end; padding: 4px 0.5rem;';
translationTriggerWrapper.appendChild(translationTriggerBtn);
document.body.insertBefore(translationTriggerWrapper, insertAfterTranslationBtn.nextSibling);
} else {
document.body.appendChild(translationTriggerBtn);
}
return Promise.resolve(true);
default:
// do nothing
return Promise.resolve(false);
@ -1189,4 +1392,5 @@ switch (message.command) {
});
browser.runtime.sendMessage({ command: "checkSpamReport" });
browser.runtime.sendMessage({ command: "initSummary" });
browser.runtime.sendMessage({ command: "initSummary" });
browser.runtime.sendMessage({ command: "initTranslation" });

View file

@ -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());
}

View file

@ -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",
}
];

View file

@ -278,7 +278,7 @@ export class taStorage {
* @param {string} lang - Target language code.
* @param {boolean} [force=true] - If true, overwrite existing translation data.
*/
async writeTranslation(messageId, translated_text, lang, force = true) {
async writeTranslation(messageId, translated_text, lang, force = true, error = false, error_message = '') {
this.taLog.log('[writeTranslation] messageId: ' + messageId + ', lang: ' + lang + ', force: ' + force);
try {
let key = this._buildKey(messageId);
@ -288,7 +288,7 @@ export class taStorage {
return;
}
let now = Date.now();
record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, ts: now };
record[taStorage.FIELD_TRANSLATION] = { translated_text: translated_text, lang: lang, error: error, message: error_message, ts: now };
record.ts = now;
await messenger.storage.local.set({ [key]: record });
} catch (e) {
@ -296,6 +296,64 @@ export class taStorage {
}
}
/**
* Get all records that have a translation field.
* @returns {Promise<Object>} 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.

150
js/mzta-translationstore.js Normal file
View file

@ -0,0 +1,150 @@
/*
* ThunderAI [https://micz.it/thunderbird-addon-thunderai/]
* Copyright (C) 2024 - 2026 Mic (m@micz.it)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { taStorage } from './mzta-storage.js';
import { taLogger } from './mzta-logger.js';
export class taTranslationStore {
_processing_prefix = 'mzta-translation-processing-';
_max_translations = 100;
_storage = null;
taLog = null;
constructor(do_debug = false) {
this._storage = new taStorage(do_debug);
this.taLog = new taLogger('mzta-translationstore', do_debug);
}
async setProcessing(data_id) {
this.taLog.log("[setProcessing] data_id: " + data_id);
const key = this._processing_prefix + data_id;
await browser.storage.session.set({ [key]: true });
}
async isProcessing(data_id) {
this.taLog.log("[isProcessing] data_id: " + data_id);
const key = this._processing_prefix + data_id;
let output = await browser.storage.session.get(key);
let result = output[key] || false;
this.taLog.log("[isProcessing] result: " + result);
return result;
}
async saveTranslation(data, data_id) {
this.taLog.log("[saveTranslation] data_id: " + data_id);
try {
await this._storage.writeTranslation(data_id, data.translated_text || '', data.lang || '', true, data.error || false, data.message || '');
await browser.storage.session.remove(this._processing_prefix + data_id);
} catch (e) {
this.taLog.error("[saveTranslation] error: " + e);
throw e;
}
}
async saveError(data_id, error_message) {
this.taLog.log("[saveError] data_id: " + data_id + ", error_message: " + error_message);
let data = {
translated_text: '',
lang: '',
error: true,
message: error_message,
headerMessageId: data_id
};
await this.saveTranslation(data, data_id);
return data;
}
async loadTranslation(data_id) {
this.taLog.log("[loadTranslation] data_id: " + data_id);
let record = await this._storage.getRecord(data_id);
if (!record || !this._storage.hasField(record, taStorage.FIELD_TRANSLATION)) {
this.taLog.log("[loadTranslation] no record found for data_id: " + data_id);
return null;
}
let translation = record[taStorage.FIELD_TRANSLATION];
return {
headerMessageId: data_id,
translated_text: translation.translated_text || '',
lang: translation.lang || '',
error: translation.error || false,
message: translation.message || '',
translation_date: new Date(translation.ts),
};
}
async removeTranslation(data_id) {
this.taLog.log("[removeTranslation] data_id: " + data_id);
await this._storage.deleteTranslationField(data_id);
await browser.storage.session.remove(this._processing_prefix + data_id);
}
async getAllTranslations() {
this.taLog.log("[getAllTranslations] loading all translations");
return await this._storage.getAllTranslationRecords();
}
async clearTranslations() {
this.taLog.log("[clearTranslations] clearing all translation data");
let allTranslations = await this._storage.getAllTranslationRecords();
let translationKeys = Object.keys(allTranslations);
this.taLog.log("[clearTranslations] deleting " + translationKeys.length + " translation records");
for (let messageId of translationKeys) {
await this._storage.deleteTranslationField(messageId);
}
let allSession = await browser.storage.session.get(null);
let keysToDelete = Object.keys(allSession).filter(k => k.startsWith(this._processing_prefix));
this.taLog.log("[clearTranslations] deleting " + keysToDelete.length + " session keys");
for (let key of keysToDelete) {
await browser.storage.session.remove(key);
}
}
async truncTranslations() {
this.taLog.log("[truncTranslations] checking translation count");
let data = await this._storage.getAllTranslationRecords();
let sortedData = this.sortTranslationsByDate(data);
let keys = Object.keys(sortedData);
this.taLog.log("[truncTranslations] total translations: " + keys.length + ", max: " + this._max_translations);
if (keys.length > this._max_translations) {
let toDelete = keys.length - this._max_translations;
this.taLog.log("[truncTranslations] truncating " + toDelete + " oldest translations");
for (let i = this._max_translations; i < keys.length; i++) {
await this._storage.deleteTranslationField(keys[i]);
}
}
}
sortTranslationsByDate(data) {
if (!data) return {};
const translationKeys = Object.keys(data);
translationKeys.sort((a, b) => {
const dateA = new Date(data[a].translation_date);
const dateB = new Date(data[b].translation_date);
return dateB - dateA;
});
let sortedTranslations = {};
translationKeys.forEach((key) => {
sortedTranslations[key] = data[key];
});
return sortedTranslations;
}
}

View file

@ -173,6 +173,26 @@ export const taPromptUtils = {
return { promptText, promptInfo: prompt };
},
async buildTranslationPrompt(fullMessage, lang) {
const specialPrompts = await getSpecialPrompts();
const prompt = specialPrompts.find(p => p.id === 'prompt_translate_this');
let promptText = prompt.text;
if (promptText === 'prompt_translate_this_full_text') {
promptText = browser.i18n.getMessage('prompt_translate_this_full_text');
}
const bodyHtml = getMailBody(fullMessage);
let bodyText = htmlBodyToPlainText(bodyHtml.html);
if (bodyText.length === 0) {
bodyText = bodyHtml.text || '';
}
const fullPrompt = promptText + " " + lang + ". \"" + bodyText + "\"";
return { promptText: fullPrompt, promptInfo: prompt };
},
/**
* Extracts tags from the response text.
* @param {string} response_text - The response text from which to extract tags.

View file

@ -62,6 +62,7 @@ import {
} from './js/mzta-prompts.js';
import { taSpamReport } from './js/mzta-spamreport.js';
import { taSummaryStore } from './js/mzta-summarystore.js';
import { taTranslationStore } from './js/mzta-translationstore.js';
import { taWorkingStatus } from './js/mzta-working-status.js';
import {
addTags_getExclusionList,
@ -95,6 +96,7 @@ let taLog = new taLogger("mzta-background",prefs_init.do_debug);
taWorkingStatus.taLog = taLog;
let spamReport = new taSpamReport(prefs_init.do_debug);
let summaryStore = new taSummaryStore(prefs_init.do_debug);
let translationStore = new taTranslationStore(prefs_init.do_debug);
let special_prompts_ids = getActiveSpecialPromptsIDs({
addtags: prefs_init.add_tags,
@ -328,6 +330,61 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => {
// case 'chatgpt_open':
// openChatGPT(message.prompt,message.action,message.tabId);
// return true;
case 'initTranslation':
async function _initTranslation() {
try {
let tabId = sender.tab.id;
let prefs = await browser.storage.sync.get({ translate: prefs_default.translate, translate_auto: prefs_default.translate_auto });
if (!prefs.translate) return;
let message = await browser.messageDisplay.getDisplayedMessage(tabId);
if (!message) return;
let cachedTranslation = await translationStore.loadTranslation(message.headerMessageId);
if (cachedTranslation && !cachedTranslation.error) {
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } });
return;
}
if (await translationStore.isProcessing(message.headerMessageId)) {
browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
return;
}
if (prefs.translate_auto === 0) return;
if (prefs.translate_auto === 2) {
_generateTranslationForMessage(message.headerMessageId, tabId);
return;
}
// Manual button mode (translate_auto === 1)
browser.tabs.sendMessage(tabId, { command: "showTranslationButton", headerMessageId: message.headerMessageId });
} catch (e) {
taLog.error("Error in initTranslation: " + e);
}
}
_initTranslation();
break;
case 'triggerTranslationGeneration':
async function _triggerTranslationGeneration(message) {
let tabId = sender.tab.id;
await _generateTranslationForMessage(message.headerMessageId, tabId);
}
_triggerTranslationGeneration(message);
break;
case 'refreshTranslation':
async function _refreshTranslation(message) {
let tabId = sender.tab.id;
await translationStore.removeTranslation(message.headerMessageId);
await _generateTranslationForMessage(message.headerMessageId, tabId);
}
_refreshTranslation(message);
break;
case 'removeTranslation':
translationStore.removeTranslation(message.headerMessageId);
break;
case 'chatgpt_close':
async function _closeChatGptWindow(window_id) {
let prefs_close = await browser.storage.sync.get({chatgpt_win_save_position: prefs_default.chatgpt_win_save_position});
@ -583,6 +640,81 @@ async function _generateSummaryForMessage(headerMessageId, tabId) {
}
}
async function _generateTranslationForMessage(headerMessageId, tabId) {
try {
let prefs = await browser.storage.sync.get({
connection_type: prefs_default.connection_type,
do_debug: prefs_default.do_debug,
default_chatgpt_lang: prefs_default.default_chatgpt_lang,
translate_lang: prefs_default.translate_lang,
...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type'])
});
let cachedTranslation = await translationStore.loadTranslation(headerMessageId);
if (cachedTranslation && !cachedTranslation.error) {
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...cachedTranslation } });
return;
}
if (await translationStore.isProcessing(headerMessageId)) {
browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
return;
}
await translationStore.setProcessing(headerMessageId);
taWorkingStatus.startWorking();
browser.tabs.sendMessage(tabId, { command: "showTranslationGenerating" });
const messageResult = await browser.messages.query({ headerMessageId: headerMessageId });
if (!messageResult || messageResult.messages.length === 0) {
await translationStore.saveError(headerMessageId, "Message not found");
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: "Message not found" } });
taWorkingStatus.stopWorking();
return;
}
const fullMessage = await browser.messages.getFull(messageResult.messages[0].id);
const connectionType = getConnectionType(prefs, {}, 'translate');
if (connectionType === 'chatgpt_web') {
const errorMsg = browser.i18n.getMessage('translate_chatgpt_web_not_supported');
await translationStore.saveError(headerMessageId, errorMsg);
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: errorMsg } });
taWorkingStatus.stopWorking();
return;
}
const lang = prefs.translate_lang || prefs.default_chatgpt_lang || '';
const { promptText } = await taPromptUtils.buildTranslationPrompt(fullMessage, lang);
const cmd = new mzta_specialCommand({
prompt: promptText,
llm: connectionType,
do_debug: prefs.do_debug,
config: {}
});
await cmd.initWorker();
const aiResponse = await cmd.sendPrompt();
const translationData = {
translated_text: aiResponse,
lang: lang,
headerMessageId: headerMessageId
};
await translationStore.saveTranslation(translationData, headerMessageId);
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { ...translationData } });
taWorkingStatus.stopWorking();
} catch (error) {
console.error("[ThunderAI] Error generating translation:", error);
await translationStore.saveError(headerMessageId, error.message || String(error));
browser.tabs.sendMessage(tabId, { command: "showTranslation", data: { error: true, message: error.message || "Failed to generate translation" } });
taWorkingStatus.stopWorking();
}
}
// options.messageData: { message, fullMessage, body_text, msg_text } — pass pre-fetched data to avoid re-querying
// options.prefs: pass pre-fetched prefs to avoid re-querying
// options.autoMove: if true, move spam messages to junk folder (default: false)

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task'];
const special_prompts_with_integration = ['add_tags', 'spamfilter', 'summarize', 'get_calendar_event', 'get_task', 'translate'];
export const integration_options_config = {
chatgpt: {
@ -141,6 +141,9 @@ export const prefs_default = {
summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic
summarize_display_mode: 'inline', // 'inline' or 'webchat'
summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline
translate: true,
translate_auto: 0, // 0: disabled, 1: manual button, 2: automatic
translate_lang: '', // target language, fallback on default_chatgpt_lang
spamfilter_show_msg_panel: true,
...generated_prefs
}

View file

@ -203,6 +203,17 @@
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate__</span>
<br><button id="btnManageTranslateInfo" class="btn_small">__MSG_prefs_OptionText_btnManageTranslateInfo__</button></td>
<td>
<label>
<input type="checkbox" id="translate" name="translate" class="option-input" />
__MSG_prefs_OptionText_translate_Info__
<br><span class="warn_API_needed" id="translate_warn_API_needed">__MSG_warn_API_needed__</span>
</label>
</td>
</tr>
<tr class="get_calendar_event_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_get_calendar_event__</span>
<br><button id="btnManageCalendarEventInfo" class="btn_small">__MSG_prefs_OptionText_btnManageCalendarEventInfo__</button></td>

View file

@ -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);

View file

@ -0,0 +1,180 @@
#translate_prompt_container {
width: 90%;
margin: 20px auto;
}
#translate_prompt_text {
width: 100%;
}
.infoline {
font-size: 0.8em;
font-style: italic;
}
#account_selector_container{
display: none;
}
#account_selector_checkboxes{
padding: 10px;
border: 1px solid gray;
width: 24em;
margin-bottom: 10px;
margin-top: 10px;
}
.specific_integration{
background-color: #dfeaff;
}
table {
border-collapse: separate;
border-spacing: 0;
}
.group td {
border-top: none;
border-bottom: none;
}
.specific_integration_sub td:first-child {
border-left: 10px solid #dfeaff;
}
.specific_integration_sub td:last-child {
border-right: 10px solid #dfeaff;
}
#connection_ui_end td{
height: 6px;
background-color: #dfeaff;
}
#connection_ui_end{
display: none;
}
.btn_div {
width: 100%;
display: flex;
justify-content: space-between;
}
table#miczPrefs {
padding: 10px;
border-spacing: 0px;
width: 90%;
margin: 0px auto 40px auto;
border: 1px solid #ccc;
}
.translate_table_title{
width: 90%;
margin: auto;
}
.section_title {
font-weight: bold;
}
table#miczPrefs td {
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
vertical-align: top;
padding: 2px;
}
table#miczPrefs tr:first-child td {
border-top: none;
}
table#miczPrefs tr:last-child td {
border-bottom: none;
}
.autocomplete-container {
position: relative;
}
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: white;
border: 1px solid #ccc;
z-index: 1000;
max-height: 200px;
overflow-y: auto;
padding: 0;
margin: 0;
list-style: none;
font-size: small;
}
.autocomplete-list li {
padding: 8px;
cursor: pointer;
}
.autocomplete-list li:hover {
background-color: #f0f0f0;
}
.autocomplete-list li.active {
background-color: #ddd;
}
.hidden {
display: none;
}
.unsaved {
color: red;
}
label:has(input[type="checkbox"]) {
cursor: pointer;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1c1b22;
color: rgb(251, 251, 254);
}
a:link { color: #409eff; }
a:visited { color: #409eff; }
a:hover { color: #66b1ff; }
a:active { color: #66b1ff; }
.autocomplete-list {
background-color: #2e2f36;
border: 1px solid #2e2f36;
}
.autocomplete-list li:hover {
background-color: #4c4e58;
}
.autocomplete-list li.active {
background-color: #4c4e58;
}
.specific_integration{
background-color: #2E3A4F;
}
.specific_integration_sub td:first-child {
border-left: 10px solid #2E3A4F;
}
.specific_integration_sub td:last-child {
border-right: 10px solid #2E3A4F;
}
#connection_ui_end td{
background-color: #2E3A4F;
}
}

View file

@ -0,0 +1,97 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>ThunderAI - __MSG_Translate_PageTitle__</title>
<link rel="stylesheet" href="mzta-translate.css" />
<link rel="stylesheet" href="../_lib/connection-ui.css">
<link rel="icon" href="../../images/icon-16px.png" />
</head>
<body>
<div>
<h1 class="page_title">__MSG_Translate_PageTitle__</h1>
<p>__MSG_Translate_info_default__</p>
</div>
<div class="translate_table_title section_title">__MSG_Translate_prompt_prefs_title__</div>
<table id="miczPrefs">
<tr class="translate_tr specific_integration">
<td><span class="opt_title">__MSG_prefs_OptionText_use_specific_integration__</span>
</td>
<td>
<label>
<input type="checkbox" id="translate_use_specific_integration" name="translate_use_specific_integration" class="option-input" />
__MSG_prefs_OptionText_translate_use_specific_integration_Info__
</label>
</td>
</tr>
<tr id="connection_ui_anchor"></tr>
<tr id="connection_ui_end"><td colspan="2"></td></tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_auto__</span></td>
<td>
<label>
<select id="translate_auto" name="translate_auto" class="option-input">
<option value="0">__MSG_prefs_OptionText_translate_auto_disabled__</option>
<option value="1">__MSG_prefs_OptionText_translate_auto_manual__</option>
<option value="2">__MSG_prefs_OptionText_translate_auto_automatic__</option>
</select>
<br>__MSG_prefs_OptionText_translate_auto_Info__
</label>
</td>
</tr>
<tr class="translate_tr">
<td><span class="opt_title">__MSG_prefs_OptionText_translate_lang__</span></td>
<td>
<label>
<input type="text" id="translate_lang" name="translate_lang" class="option-input" />
<br>__MSG_prefs_OptionText_translate_lang_Info__
</label>
</td>
</tr>
</table>
<!-- PROMPTS -->
<div id="translate_prompt_container">
<!--title and description-->
<span class="section_title">
__MSG_Translate_prompt_text_title__
</span>
<br>
<span class="infoline">
__MSG_prefs_OptionText_btnManagePrompts_infoline__
<a href="https://micz.it/thunderbird-addon-thunderai/data-placeholders/">
__MSG_more_info_string__
</a>
</span>
<br>
<!--main prompt-->
<span class="infoline">
__MSG_prefs_OptionText_Translate_main_prompt__
</span>
<br>
<div class="autocomplete-container">
<textarea id="translate_prompt_text" rows="15"></textarea>
<ul class="autocomplete-list hidden"></ul>
</div>
<div id="translate_info_additional_statements"></div>
<div class="btn_div">
<button id="btn_reset_prompt" disabled>
__MSG_reset_default__
</button>
<button id="btn_save_prompt" disabled>
__MSG_save__
</button>
</div>
</div>
<script src="mzta-translate.js" type="module"></script>
<script src="../../js/mzta-i18n.js"></script>
</body>
</html>

View file

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