/* * 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 . */ // Some original methods are derived from https://github.com/ali-raheem/Aify/blob/cfadf52f576b7be3720b5b73af7c8d3129c054da/plugin/html/actions.js import { getPrompts } from './mzta-prompts.js'; import { prefs_default, getDynamicSettingsDefaults } from '../options/mzta-options-default.js'; import { getLanguageDisplayName, getMenuContextCompose, getMenuContextDisplay, i18nConditionalGet, getMailSubject, getTagsList, extractJsonObject, convertNewlinesToBr, cleanupNewlines, checkIfTagLabelExists, getConnectionType, getContextMenuIcon, } from './mzta-utils.js' import { taPromptUtils } from './mzta-utils-prompt.js'; import { taLogger } from './mzta-logger.js'; import { placeholdersUtils } from './mzta-placeholders.js'; import { mzta_specialCommand } from './mzta-special-commands.js'; import { taWorkingStatus } from './mzta-working-status.js'; export class mzta_Menus { allPrompts = []; openChatGPT = null; menu_context_compose = null; menu_context_display = null; menu_listeners = {}; logger = null; rootMenu = [ //{ id: 'ItemC', act: (info, tab) => { console.log('ItemC', info, tab, info.menuItemId); alert('ItemC') } }, ]; shortcutMenu = [ //{ id: 'ItemD', label: 'LabelD' }, ]; constructor(openChatGPT, do_debug = false) { this.menu_context_compose = getMenuContextCompose(); this.menu_context_display = getMenuContextDisplay(); this.openChatGPT = openChatGPT; this.allPrompts = []; this.listener = this.listener.bind(this); this.logger = new taLogger('mzta_Menus', do_debug); } async initialize(also_special = []) { // also_special is an array of active special prompts ids this.allPrompts = []; this.rootMenu = []; this.shortcutMenu = []; this.menu_listeners = {}; this.allPrompts = await getPrompts(true,also_special); this.allPrompts.forEach((prompt) => { this.addAction(prompt) }); } async reload(also_special = []) { // await browser.menus.removeAll().catch(error => { // console.error("[ThunderAI] ERROR removing the menus: ", error); // }); this.removeClickListener(); this.loadMenus(also_special); } addAction = (curr_prompt) => { let curr_menu_entry = {id: curr_prompt.id, is_default: curr_prompt.is_default, name: curr_prompt.name}; let curr_message = null; const getMailBody = async (tabs, do_autoselect = false) => { //const tabs = await browser.tabs.query({ active: true, currentWindow: true }); return {tabId: tabs[0].id, selection: cleanupNewlines(await browser.tabs.sendMessage(tabs[0].id, { command: "getSelectedText" })), selection_html: convertNewlinesToBr(await browser.tabs.sendMessage(tabs[0].id, { command: "getSelectedHtml" })), text: cleanupNewlines(await browser.tabs.sendMessage(tabs[0].id, { command: "getTextOnly" })), html: convertNewlinesToBr(await browser.tabs.sendMessage(tabs[0].id, { command: "getFullHtml" })), only_typed_text: cleanupNewlines(await browser.tabs.sendMessage(tabs[0].id, { command: "getOnlyTypedText", do_autoselect: do_autoselect })), only_quoted_text: cleanupNewlines(await browser.tabs.sendMessage(tabs[0].id, { command: "getOnlyQuotedText" })) }; }; curr_menu_entry.act = async () => { taWorkingStatus.startWorking(); const tabs = await browser.tabs.query({ active: true, currentWindow: true }); const msg_text = await getMailBody(tabs, placeholdersUtils.hasPlaceholder(curr_prompt.text,'mail_typed_text')); if (curr_prompt.id === 'prompt_get_calendar_event_from_clipboard') { try { const clipboardText = await navigator.clipboard.readText(); if (!clipboardText) { browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('clipboard_empty_error') }); taWorkingStatus.stopWorking(); return {ok:'0'}; } msg_text.selection = clipboardText; msg_text.text = clipboardText; } catch (e) { console.error("Clipboard read error:", e); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage('clipboard_read_error') }); taWorkingStatus.stopWorking(); return {ok:'0'}; } } // console.log(">>>>>>>>>>>>> msg_text: " + JSON.stringify(msg_text)); //check if a selection is needed if(String(curr_prompt.need_selected) == "1" && (msg_text.selection==='')){ //A selection is needed, but nothing is selected! //alert(browser.i18n.getMessage('prompt_selection_needed')); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message : browser.i18n.getMessage('prompt_selection_needed') }); taWorkingStatus.stopWorking(); return {ok:'0'}; } let body_text = ''; let selection_text = ''; let selection_html = msg_text.selection_html; let only_typed_text = ''; let only_quoted_text = ''; only_typed_text = msg_text.only_typed_text.replace(/[ \t]+/g, ' ').trim(); selection_text = msg_text.selection.replace(/[ \t]+/g, ' ').trim(); if(selection_text === ''){ if(placeholdersUtils.hasPlaceholder(curr_prompt.text, "mail_typed_text")){ selection_text = only_typed_text; } } only_quoted_text = msg_text.only_quoted_text.replace(/[ \t]+/g, ' ').trim(); curr_prompt.selection_text = selection_text; curr_prompt.selection_html = selection_html; body_text = msg_text.text.replace(/[ \t]+/g, ' ').trim(); curr_prompt.body_text = body_text; //open chatgpt window //console.log("Click menu item..."); let chatgpt_lang = await taPromptUtils.getDefaultLang(curr_prompt); let fullPrompt = ''; let tags_full_list = await getTagsList(); let curr_messages = null; switch(tabs[0].type){ case 'mail': curr_messages = await browser.mailTabs.getSelectedMessages(); curr_message = curr_messages.messages[0]; break; case 'messageDisplay': curr_messages = await messenger.messageDisplay.getDisplayedMessage(tabs[0].id); curr_message = curr_messages; break; case 'messageCompose': curr_messages = await browser.compose.getComposeDetails(tabs[0].id); curr_message = curr_messages; break; } fullPrompt = await taPromptUtils.preparePrompt({ curr_prompt: curr_prompt, curr_message: curr_message, chatgpt_lang: chatgpt_lang, selection_text: selection_text, selection_html: selection_html, body_text: body_text, subject_text: await getMailSubject(tabs[0]), msg_text: msg_text, only_typed_text: only_typed_text, only_quoted_text: only_quoted_text, tags_full_list: tags_full_list }); curr_prompt.custom_text_array = []; if(placeholdersUtils.hasPlaceholder(curr_prompt.text, 'additional_text')){ curr_prompt.custom_text_array = placeholdersUtils.getPlaceholdersAdditionalTextArray(curr_prompt.text); } // console.log(">>>>>>>>>>>>>>>>>>> curr_prompt.custom_text_array: " + JSON.stringify(curr_prompt.custom_text_array)); switch(curr_prompt.id){ case 'prompt_translate_this': 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()); } fullPrompt = curr_prompt.text + " " + chatgpt_lang2 + ". \"" + body_text + "\" "; break; case 'prompt_reply': fullPrompt += browser.i18n.getMessage("prompt_reply_additional_text"); break; default: break; } // const tabs = await browser.tabs.query({ active: true, currentWindow: true }); // add custom text if needed //browser.runtime.sendMessage({command: "chatgpt_open", prompt: fullPrompt, action: curr_prompt.action, tabId: tabs[0].id}); if(curr_prompt.is_special == '1'){ // Special prompts switch(curr_prompt.id){ case 'prompt_add_tags': { // Add tags to the email let tags_current_email = []; let tags_current_email_final = []; let prefs_at = await browser.storage.sync.get({ add_tags_maxnum: prefs_default.add_tags_maxnum, connection_type: prefs_default.connection_type, add_tags_force_lang: prefs_default.add_tags_force_lang, add_tags_auto_force_existing: prefs_default.add_tags_auto_force_existing, default_chatgpt_lang: prefs_default.default_chatgpt_lang, do_debug: prefs_default.do_debug, }); let def_conntype = getConnectionType(prefs_at, curr_prompt, 'add_tags'); if((def_conntype === '')||(def_conntype === null)||(def_conntype === undefined)||(def_conntype === 'chatgpt_web')){ console.error("[ThunderAI | AddTags] Invalid connection type: " + def_conntype); taWorkingStatus.stopWorking(); return {ok:'0'}; } fullPrompt = taPromptUtils.finalizePrompt_add_tags(fullPrompt, prefs_at.add_tags_maxnum, prefs_at.add_tags_force_lang, prefs_at.default_chatgpt_lang); this.logger.log("fullPrompt: " + fullPrompt); let create_new_tags = !prefs_at.add_tags_auto_force_existing; let all_tags_list = tags_full_list[1]; // COMMENTED TO DO TESTS // tags_current_email = "recipients, TEST, home, work, CAR, light"; // console.log(">>>>>>>>>>>>> curr_prompt: " + JSON.stringify(curr_prompt)); // console.log(">>>>>>>>>>>>>> prefs_at.connection_type: " + JSON.stringify(prefs_at.connection_type)); // console.log(">>>>>>>>>>>>>> def_conntype: " + JSON.stringify(def_conntype)); let cmd_addTags = new mzta_specialCommand({ prompt: fullPrompt, llm: def_conntype, custom_model: curr_prompt.model ? curr_prompt.model : '', do_debug: prefs_at.do_debug, config: curr_prompt }); await cmd_addTags.initWorker(); try{ tags_current_email = taPromptUtils.getTagsFromResponse(await cmd_addTags.sendPrompt()); if(!create_new_tags){ this.logger.log("Not creating new tags, only showing existing ones..."); } for (const tag of tags_current_email) { // console.log(">>>>>>>>>>>>>>> tag: " + JSON.stringify(tag)); if (!create_new_tags && !checkIfTagLabelExists(tag, all_tags_list)) { this.logger.log("Removing tag from the list: " + tag); }else{ tags_current_email_final.push(tag); } } // console.log(">>>>>>>>>>> tags_current_email: " + tags_current_email); }catch(err){ console.error("[ThunderAI] Error getting tags: ", err); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: "Error getting tags: " + err }); taWorkingStatus.stopWorking(); return {ok:'0'}; } this.logger.log("tags_current_email_final: " + JSON.stringify(tags_current_email_final)); this.logger.log("tags_full_list: " + JSON.stringify(tags_full_list)); browser.tabs.sendMessage(tabs[0].id, {command: "getTags", tags: tags_current_email_final, messageId: curr_message.id}); taWorkingStatus.stopWorking(); return {ok:'1'}; break; // Add tags to the email - END } case 'prompt_get_calendar_event': // Get a calendar event info case 'prompt_get_calendar_event_from_clipboard': { // here from clipboard let calendar_event_data = ''; let prefs_at = await browser.storage.sync.get({ connection_type: prefs_default.connection_type, calendar_enforce_timezone: prefs_default.calendar_enforce_timezone, calendar_timezone: prefs_default.calendar_timezone, ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) }); let def_conntype = getConnectionType(prefs_at, curr_prompt, 'get_calendar_event'); if((def_conntype === '')||(def_conntype === null)||(def_conntype === undefined)||(def_conntype === 'chatgpt_web')){ console.error("[ThunderAI | GetCalendarEvent] Invalid connection type: " + def_conntype); taWorkingStatus.stopWorking(); return {ok:'0'}; } /* We expect to receive from the AI a JSON object like this: * { * "startDate": "20250104T183000Z", * "endDate": "20250104T193000Z", * "summary": "ThunderAI Sparks", * "forceAllDay": false, * "attendees": [attendee1@example.com,attendee2@example.com,attendee3@example.com]" * } */ fullPrompt = taPromptUtils.finalizePrompt_get_calendar_event(fullPrompt); this.logger.log("fullPrompt: " + fullPrompt); let cmd_GetCalendarEvent = new mzta_specialCommand({ prompt: fullPrompt, llm: def_conntype, do_debug: true, config: curr_prompt }); await cmd_GetCalendarEvent.initWorker(); try{ calendar_event_data = await cmd_GetCalendarEvent.sendPrompt(); // console.log(">>>>>>>>>>> calendar_event_data: " + calendar_event_data); }catch(err){ console.error("[ThunderAI] Error getting calendar event data: ", err.message); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("calendar_getting_data_error") + ": " + err.message }); taWorkingStatus.stopWorking(); return {ok:'0'}; } let calendar_event_data_obj = {}; try{ calendar_event_data_obj = extractJsonObject(calendar_event_data); }catch(err){ console.error("[ThunderAI] Error extracting JSON object from calendar event data: ", err.message); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("calendar_getting_data_error") + ": " + err.message }); taWorkingStatus.stopWorking(); return {ok:'0'}; } // Timezone management calendar_event_data_obj.use_timezone = false; if(prefs_at.calendar_enforce_timezone){ calendar_event_data_obj.use_timezone = true; calendar_event_data_obj.timezone = prefs_at.calendar_timezone; } let calendar_event_data_str = JSON.stringify(calendar_event_data_obj); // Timezone management - END this.logger.log("calendar_event_data: " + calendar_event_data); this.logger.log("calendar_event_data_obj: " + calendar_event_data_str); try{ let result_openCalendarEventDialog = await browser.runtime.sendMessage('thunderai-sparks@micz.it',{action: "openCalendarEventDialog", calendar_event_data: calendar_event_data_str}) if(result_openCalendarEventDialog == 'ok'){ taWorkingStatus.stopWorking(); return {ok:'1'}; } else { let err = result_openCalendarEventDialog.error; if (err && typeof err === 'string' && err.startsWith('|>>')) { result_openCalendarEventDialog.error = browser.i18n.getMessage(result_openCalendarEventDialog.error.substring(3)); } browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("calendar_opening_dialog_error") + ": " + result_openCalendarEventDialog.error }); taWorkingStatus.stopWorking(); return {ok:'0'}; } }catch(err){ console.error("[ThunderAI] Error opening calendar event dialog: ", err); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("calendar_opening_dialog_error") + ": " + browser.i18n.getMessage("no_valid_data_received") }); taWorkingStatus.stopWorking(); return {ok:'0'}; } break; // Get a calendar event info - END } case 'prompt_get_task': { // Get a task info let task_data = ''; let prefs_at = await browser.storage.sync.get({ connection_type: '', calendar_enforce_timezone: false, calendar_timezone: '', ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type'])}); let def_conntype = getConnectionType(prefs_at, curr_prompt, 'get_task'); if((def_conntype === '')||(def_conntype === null)||(def_conntype === undefined)||(def_conntype === 'chatgpt_web')){ console.error("[ThunderAI | GetTask] Invalid connection type: " + def_conntype); taWorkingStatus.stopWorking(); return {ok:'0'}; } /* We expect to receive from the AI a JSON object like this: * { * "InitialDate": "YYYYMMDDTHHMMSS", * "dueDate": "YYYYMMDDTHHMMSS", * "summary": "Task summary here" * } */ this.logger.log("fullPrompt: " + fullPrompt); let cmd_GetTask = new mzta_specialCommand({ prompt: fullPrompt, llm: def_conntype, do_debug: true, config: curr_prompt }); await cmd_GetTask.initWorker(); try{ task_data = await cmd_GetTask.sendPrompt(); // console.log(">>>>>>>>>>> task_data: " + task_data); }catch(err){ console.error("[ThunderAI] Error getting task data: ", err.message); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("task_getting_data_error") + ": " + err.message }); taWorkingStatus.stopWorking(); return {ok:'0'}; } let task_data_obj = {}; try{ task_data_obj = extractJsonObject(task_data); if (!task_data_obj.dueDate || isNaN(Date.parse(task_data_obj.dueDate)) || task_data_obj.dueDate == '' ) { delete task_data_obj.dueDate; } if (!task_data_obj.InitialDate || isNaN(Date.parse(task_data_obj.InitialDate)) || task_data_obj.InitialDate == '' ) { delete task_data_obj.InitialDate; } }catch(err){ console.error("[ThunderAI] Error extracting JSON object from task data: ", err.message); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("task_getting_data_error") + ": " + err.message }); taWorkingStatus.stopWorking(); return {ok:'0'}; } // Timezone management task_data_obj.use_timezone = false; if(prefs_at.calendar_enforce_timezone){ task_data_obj.use_timezone = true; task_data_obj.timezone = prefs_at.calendar_timezone; } let task_data_str = JSON.stringify(task_data_obj); // Timezone management - END this.logger.log("task_data: " + task_data); this.logger.log("task_data_obj: " + task_data_str); try{ let result_openTaskDialog = await browser.runtime.sendMessage('thunderai-sparks@micz.it',{action: "openTaskDialog", task_data: task_data_str}) if(result_openTaskDialog == 'ok'){ taWorkingStatus.stopWorking(); return {ok:'1'}; } else { let err = result_openTaskDialog.error; if (err && typeof err === 'string' && err.startsWith('|>>')) { result_openTaskDialog.error = browser.i18n.getMessage(result_openTaskDialog.error.substring(3)); } browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("task_opening_dialog_error") + ": " + result_openTaskDialog.error }); taWorkingStatus.stopWorking(); return {ok:'0'}; } }catch(err){ console.error("[ThunderAI] Error opening task dialog: ", err); browser.tabs.sendMessage(tabs[0].id, { command: "sendAlert", curr_tab_type: tabs[0].type, message: browser.i18n.getMessage("task_opening_dialog_error") + ": " + browser.i18n.getMessage("no_valid_data_received") }); taWorkingStatus.stopWorking(); return {ok:'0'}; } break; } // Get a task info - END default: console.error("[ThunderAI] Unknown special prompt id: " + curr_prompt.id); taWorkingStatus.stopWorking(); return {ok:'0'}; break; } }else{ // Classic prompts for the API webchat this.logger.log("fullPrompt: " + fullPrompt); this.logger.log("curr_prompt: " + JSON.stringify(curr_prompt, (key, value) => { if (typeof key === 'string' && key.endsWith('_api_key')) { return '****************'; } return value; }) ); this.openChatGPT(fullPrompt, curr_prompt.action, tabs[0].id, curr_prompt.name, curr_prompt.need_custom_text, curr_prompt); taWorkingStatus.stopWorking(); return {ok:'1'}; } }; this.rootMenu.push(curr_menu_entry); }; loadShortcutMenu() { this.shortcutMenu = []; this.allPrompts.forEach((prompt) => { this.addShortcutMenu(prompt); }); } addShortcutMenu(prompt) { let curr_menu_entry = { id: prompt.id, label: i18nConditionalGet(prompt.name), type: prompt.type, show_in: prompt.show_in || "popup", is_special: prompt.is_special, position_display: prompt.position_display, position_compose: prompt.position_compose, position_context: prompt.position_context, }; this.shortcutMenu.push(curr_menu_entry); } async loadContextMenus() { await browser.menus.removeAll(); const contextPrompts = this.allPrompts.filter(p => { const showIn = p.show_in || "popup"; // Only show prompts that should appear in context menu and are for reading context (type 0 or 1) return (showIn === "context" || showIn === "both") && (String(p.type) === "0" || String(p.type) === "1"); }); if (contextPrompts.length === 0) { this.logger.log("No prompts for context menu"); return; } // Create parent menu await new Promise(resolve => browser.menus.create({ id: 'mzta-context-parent', title: 'ThunderAI', contexts: ["message_list"], }, resolve) ); // Sort by position_context if available, otherwise alphabetically contextPrompts.sort((a, b) => { const posA = a.position_context || 9999; const posB = b.position_context || 9999; if (posA !== posB) return posA - posB; const nameA = i18nConditionalGet(a.name); const nameB = i18nConditionalGet(b.name); return nameA.localeCompare(nameB); }); // Create child menu items for (const prompt of contextPrompts) { const title = i18nConditionalGet(prompt.name); await new Promise(resolve => browser.menus.create({ id: 'mzta-ctx-' + prompt.id, title: title, contexts: ["message_list"], parentId: 'mzta-context-parent', icons: getContextMenuIcon(prompt.id), }, resolve) ); } this.logger.log("Context menus loaded: " + contextPrompts.length + " items"); } async loadMenus(also_special = []) { await this.initialize(also_special); await this.addMenu(this.rootMenu); this.addClickListener(); this.loadShortcutMenu(); await this.loadContextMenus(); this.logger.log("Menus loaded"); } listener(info, tab) { let listeners = this.menu_listeners; if (listeners[info.menuItemId]) { listeners[info.menuItemId](info, tab); } } addClickListener() { browser.menus.onClicked.addListener(this.listener); } removeClickListener() { browser.menus.onClicked.removeListener(this.listener); } addMenu = async (menu, root = null) => { for (let item of menu) { let {id, is_default, name, menu, act} = item; this.logger.log("addMenu: " + id); // await new Promise(resolve => // browser.menus.create({ // id: id, // title: this.getCustomTextAttribute(id) + is_default == 1 ? (browser.i18n.getMessage(id) || name) : name, // contexts: this.getContexts(id), // parentId: root // }, // resolve // ) // ); if (act) { this.menu_listeners[id] = act; } if (menu) { await this.addMenu(menu, id); } } }; getContexts(id){ //console.log(">>>>>>>>> id: " + id); const curr_prompt = this.allPrompts.find(p => p.id === id); //console.log(">>>>>>>>>> curr_prompt: " + JSON.stringify(curr_prompt)); if (!curr_prompt) { return []; } switch(String(curr_prompt.type)){ case "0": return [this.menu_context_compose, this.menu_context_display]; case "1": return [this.menu_context_display]; case "2": return [this.menu_context_compose]; default: return []; } } getCustomTextAttribute(id){ const curr_prompt = this.allPrompts.find(p => p.id === id); if (!curr_prompt) { return ""; } if(String(curr_prompt.need_custom_text) === "1"){ return "*"; }else{ return ""; } } async executeMenuAction(id) { // Retrieve the action callback from the menu listeners using the provided ID const action = this.menu_listeners[id]; if (action) { try { // Execute the action callback return await action(); } catch (error) { // Log any errors that occur during execution console.error(`Error executing action for menu item ${id}:`, error); } } else { // Warn if no action is found for the provided ID console.warn(`No action found for menu item ID: ${id}`); } return false; } }