From 41769c0e96384f9cbf6896923e2dc249e5759ca3 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 04:33:01 -0500 Subject: [PATCH 01/80] Handle preview pane when loading details --- README.md | 1 + details.js | 6 ++++++ manifest.json | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5fbce1..b9ee4be 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ Sortana requests the following Thunderbird permissions: - `messagesTagsList` – retrieve existing message tags for rule actions. - `accountsRead` – list accounts and folders for move actions. - `menus` – add context menu commands. +- `tabs` – open new tabs and query the active tab. ## Thunderbird Add-on Store Disclosures diff --git a/details.js b/details.js index 0d190d5..4442ab1 100644 --- a/details.js +++ b/details.js @@ -8,6 +8,12 @@ document.addEventListener('DOMContentLoaded', async () => { const tabId = tabs[0]?.id; const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; id = msgs[0]?.id; + if (!id) { + const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + const mailTabId = mailTabs[0]?.id; + const selected = mailTabId !== undefined ? await browser.mailTabs.getSelectedMessages(mailTabId) : null; + id = selected?.messages?.[0]?.id; + } } catch (e) { console.error('failed to determine message id', e); } diff --git a/manifest.json b/manifest.json index 506e392..879d1f3 100644 --- a/manifest.json +++ b/manifest.json @@ -40,6 +40,7 @@ "messagesTagsList", "accountsRead", "menus", - "scripting" + "scripting", + "tabs" ] } From c0ba2d1fdd707423eb68df99e1f032c376dfb221 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 04:50:18 -0500 Subject: [PATCH 02/80] Add logging to details page --- details.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/details.js b/details.js index 4442ab1..1985aa9 100644 --- a/details.js +++ b/details.js @@ -1,6 +1,13 @@ document.addEventListener('DOMContentLoaded', async () => { + const storage = (globalThis.messenger ?? browser).storage; + const logger = await import(browser.runtime.getURL('logger.js')); + const { debugLogging } = await storage.local.get('debugLogging'); + logger.setDebug(debugLogging === true); + logger.aiLog('details page loaded', { debug: true }); + const params = new URLSearchParams(location.search); let id = parseInt(params.get('mid'), 10); + logger.aiLog('initial message id', { debug: true }, id); if (!id) { try { @@ -8,22 +15,27 @@ document.addEventListener('DOMContentLoaded', async () => { const tabId = tabs[0]?.id; const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; id = msgs[0]?.id; + logger.aiLog('message id from displayed messages', { debug: true }, id); if (!id) { const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); const mailTabId = mailTabs[0]?.id; const selected = mailTabId !== undefined ? await browser.mailTabs.getSelectedMessages(mailTabId) : null; id = selected?.messages?.[0]?.id; + logger.aiLog('message id from selected messages', { debug: true }, id); } } catch (e) { - console.error('failed to determine message id', e); + logger.aiLog('failed to determine message id', { level: 'error' }, e); } } if (!id) return; try { + logger.aiLog('requesting message details', {}, id); const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id }); + logger.aiLog('received details', { debug: true }, { subject, results }); document.getElementById('subject').textContent = subject; const container = document.getElementById('rules'); for (const r of results) { + logger.aiLog('rendering rule result', { debug: true }, r); const article = document.createElement('article'); const color = r.matched === true ? 'is-success' : 'is-danger'; article.className = `message ${color} mb-4`; @@ -43,10 +55,11 @@ document.addEventListener('DOMContentLoaded', async () => { container.appendChild(article); } document.getElementById('clear').addEventListener('click', async () => { + logger.aiLog('clearing cache for message', {}, id); await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id }); window.close(); }); } catch (e) { - console.error('failed to load details', e); + logger.aiLog('failed to load details', { level: 'error' }, e); } }); From 6b741595cced2d29f24d04418cdaf7b45b61eb1f Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 05:07:48 -0500 Subject: [PATCH 03/80] Fix message lookup in details popup --- background.js | 2 +- details.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/background.js b/background.js index 0613788..bc3c5dd 100644 --- a/background.js +++ b/background.js @@ -413,7 +413,7 @@ async function clearCacheForMessages(idsInput) { } } else if (msg?.type === "sortana:clearCacheForDisplayed") { try { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true }); const tabId = tabs[0]?.id; const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; const ids = msgs.map(m => m.id); diff --git a/details.js b/details.js index 1985aa9..c586cc2 100644 --- a/details.js +++ b/details.js @@ -11,13 +11,13 @@ document.addEventListener('DOMContentLoaded', async () => { if (!id) { try { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true }); const tabId = tabs[0]?.id; const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; id = msgs[0]?.id; logger.aiLog('message id from displayed messages', { debug: true }, id); if (!id) { - const mailTabs = await browser.mailTabs.query({ active: true, currentWindow: true }); + const mailTabs = await browser.mailTabs.query({ active: true, lastFocusedWindow: true }); const mailTabId = mailTabs[0]?.id; const selected = mailTabId !== undefined ? await browser.mailTabs.getSelectedMessages(mailTabId) : null; id = selected?.messages?.[0]?.id; From 9724c19b7d1a7518f46c8b08bcda9595c33d1118 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 19:33:05 -0500 Subject: [PATCH 04/80] Debug attempt --- ai-filter.sln | 6 ++++++ details.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ai-filter.sln b/ai-filter.sln index 86ceaed..7922392 100644 --- a/ai-filter.sln +++ b/ai-filter.sln @@ -64,6 +64,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175 resources\img\logo96.png = resources\img\logo96.png EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}" + ProjectSection(SolutionItems) = preProject + resources\js\turndown.js = resources\js\turndown.js + EndProjectSection +EndProject Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -76,5 +81,6 @@ Global {86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} {68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} {F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854} + {21D2A42C-3F85-465C-9141-C106AFD92B68} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854} EndGlobalSection EndGlobal diff --git a/details.js b/details.js index c586cc2..28c7504 100644 --- a/details.js +++ b/details.js @@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (!id) { try { - const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true }); + const tabs = await messenger.tabs.query({ active: true, currentWindow: true }); const tabId = tabs[0]?.id; const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; id = msgs[0]?.id; From 79f49fd5028314b7740d7eac6feb609b88e0fbe5 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 19:58:11 -0500 Subject: [PATCH 05/80] Simplify message lookup --- background.js | 4 +--- details.js | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/background.js b/background.js index bc3c5dd..7198e23 100644 --- a/background.js +++ b/background.js @@ -413,9 +413,7 @@ async function clearCacheForMessages(idsInput) { } } else if (msg?.type === "sortana:clearCacheForDisplayed") { try { - const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true }); - const tabId = tabs[0]?.id; - const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; + const msgs = await browser.messageDisplay.getDisplayedMessages(); const ids = msgs.map(m => m.id); await clearCacheForMessages(ids); } catch (e) { diff --git a/details.js b/details.js index c586cc2..6ea15fe 100644 --- a/details.js +++ b/details.js @@ -11,15 +11,11 @@ document.addEventListener('DOMContentLoaded', async () => { if (!id) { try { - const tabs = await browser.tabs.query({ active: true, lastFocusedWindow: true }); - const tabId = tabs[0]?.id; - const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; + const msgs = await browser.messageDisplay.getDisplayedMessages(); id = msgs[0]?.id; logger.aiLog('message id from displayed messages', { debug: true }, id); if (!id) { - const mailTabs = await browser.mailTabs.query({ active: true, lastFocusedWindow: true }); - const mailTabId = mailTabs[0]?.id; - const selected = mailTabId !== undefined ? await browser.mailTabs.getSelectedMessages(mailTabId) : null; + const selected = await browser.mailTabs.getSelectedMessages(); id = selected?.messages?.[0]?.id; logger.aiLog('message id from selected messages', { debug: true }, id); } From 846d1270c55bf511132c9504b7013531545dc181 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 20:02:51 -0500 Subject: [PATCH 06/80] Updating version number since we're making significant changes --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 879d1f3..36b94b0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.0.0", + "version": "2.1.0", "default_locale": "en-US", "applications": { "gecko": { From 97bfabfbea8aafa17db6f573954c6901df3b1469 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 5 Jul 2025 22:53:40 -0500 Subject: [PATCH 07/80] Add fallback message to fetch active message id --- background.js | 13 +++++++++++++ details.js | 13 ++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/background.js b/background.js index 7198e23..58852c2 100644 --- a/background.js +++ b/background.js @@ -411,6 +411,19 @@ async function clearCacheForMessages(idsInput) { // rethrow so the caller sees the failure throw err; } + } else if (msg?.type === "sortana:getActiveMessage") { + try { + const displayed = await browser.messageDisplay.getDisplayedMessages(); + let id = displayed[0]?.id; + if (!id) { + const selected = await browser.mailTabs.getSelectedMessages(); + id = selected?.messages?.[0]?.id; + } + return { id: id ?? null }; + } catch (e) { + logger.aiLog("failed to get active message", { level: 'error' }, e); + return { id: null }; + } } else if (msg?.type === "sortana:clearCacheForDisplayed") { try { const msgs = await browser.messageDisplay.getDisplayedMessages(); diff --git a/details.js b/details.js index 6ea15fe..3b78e8a 100644 --- a/details.js +++ b/details.js @@ -20,9 +20,20 @@ document.addEventListener('DOMContentLoaded', async () => { logger.aiLog('message id from selected messages', { debug: true }, id); } } catch (e) { - logger.aiLog('failed to determine message id', { level: 'error' }, e); + logger.aiLog('failed to determine message id locally', { level: 'error' }, e); } } + + if (!id) { + try { + const resp = await browser.runtime.sendMessage({ type: 'sortana:getActiveMessage' }); + id = resp?.id; + logger.aiLog('message id from background', { debug: true }, id); + } catch (e) { + logger.aiLog('failed to get message id from background', { level: 'error' }, e); + } + } + if (!id) return; try { logger.aiLog('requesting message details', {}, id); From eb91474f5a0e6b44f8ee803a7ade66b53a0ee8be Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 00:05:31 -0500 Subject: [PATCH 08/80] Migrate manifest to version 3 --- manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest.json b/manifest.json index 36b94b0..8d1ab76 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "Sortana", "version": "2.1.0", "default_locale": "en-US", - "applications": { + "browser_specific_settings": { "gecko": { "id": "ai-filter@jordanwages", "strict_min_version": "128.0", @@ -18,7 +18,7 @@ "96": "resources/img/logo96.png", "128": "resources/img/logo128.png" }, - "browser_action": { + "action": { "default_icon": "resources/img/logo32.png" }, "message_display_action": { From 8f5165dcec4a12b50a9f33fe61f89cd53605b620 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 00:29:46 -0500 Subject: [PATCH 09/80] Add host permissions for endpoint access --- README.md | 1 + manifest.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index b9ee4be..eff53b1 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Sortana requests the following Thunderbird permissions: - `accountsRead` – list accounts and folders for move actions. - `menus` – add context menu commands. - `tabs` – open new tabs and query the active tab. +- Host permissions (`*://*/*`) – allow network requests to your configured classification service. ## Thunderbird Add-on Store Disclosures diff --git a/manifest.json b/manifest.json index 8d1ab76..d43df2c 100644 --- a/manifest.json +++ b/manifest.json @@ -42,5 +42,8 @@ "menus", "scripting", "tabs" + ], + "host_permissions": [ + "*://*/*" ] } From 0c07479fa987cfa96ed1a1ddcbd05492841e26ec Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 01:22:44 -0500 Subject: [PATCH 10/80] Added CSP --- manifest.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index d43df2c..2ff6b74 100644 --- a/manifest.json +++ b/manifest.json @@ -45,5 +45,8 @@ ], "host_permissions": [ "*://*/*" - ] + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'self' http: https:" + } } From d60725eb4b3fb20755a555f0edad927f91eb9b67 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 02:10:46 -0500 Subject: [PATCH 11/80] Handle message display in TB 128 --- background.js | 39 ++++++++++++++-------------- details.js | 70 +++++++++++++++++++++++---------------------------- manifest.json | 3 +-- 3 files changed, 52 insertions(+), 60 deletions(-) diff --git a/background.js b/background.js index 58852c2..82c1fd0 100644 --- a/background.js +++ b/background.js @@ -333,6 +333,19 @@ async function clearCacheForMessages(idsInput) { if (browser.messageDisplayAction.setLabel) { browser.messageDisplayAction.setLabel({ label: "Details" }); } + + browser.messageDisplayAction.onClicked.addListener(async (tab) => { + const header = await browser.messageDisplay.getDisplayedMessage(tab.id); + if (!header) { + console.warn("[Sortana] no displayed message in tab", tab.id); + return; + } + + const popupUrl = `${browser.runtime.getURL("details.html")}?mid=${header.id}`; + + await browser.messageDisplayAction.setPopup({ tabId: tab.id, popup: popupUrl }); + await browser.messageDisplayAction.openPopup({ tabId: tab.id }); + }); } browser.menus.create({ @@ -370,7 +383,7 @@ async function clearCacheForMessages(idsInput) { - browser.menus.onClicked.addListener(async info => { + browser.menus.onClicked.addListener(async (info, tab) => { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { const ids = info.selectedMessages?.messages?.map(m => m.id) || (info.messageId ? [info.messageId] : []); @@ -380,11 +393,12 @@ async function clearCacheForMessages(idsInput) { (info.messageId ? [info.messageId] : []); await clearCacheForMessages(ids); } else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") { - const id = info.messageId || info.selectedMessages?.messages?.[0]?.id; - if (id) { - const url = browser.runtime.getURL(`details.html?mid=${id}`); - browser.tabs.create({ url }); - } + const header = await browser.messageDisplay.getDisplayedMessage(tab.id); + if (!header) { return; } + + const url = `${browser.runtime.getURL("details.html")}?mid=${header.id}`; + + await browser.tabs.create({ url }); } }); @@ -411,19 +425,6 @@ async function clearCacheForMessages(idsInput) { // rethrow so the caller sees the failure throw err; } - } else if (msg?.type === "sortana:getActiveMessage") { - try { - const displayed = await browser.messageDisplay.getDisplayedMessages(); - let id = displayed[0]?.id; - if (!id) { - const selected = await browser.mailTabs.getSelectedMessages(); - id = selected?.messages?.[0]?.id; - } - return { id: id ?? null }; - } catch (e) { - logger.aiLog("failed to get active message", { level: 'error' }, e); - return { id: null }; - } } else if (msg?.type === "sortana:clearCacheForDisplayed") { try { const msgs = await browser.messageDisplay.getDisplayedMessages(); diff --git a/details.js b/details.js index 3b78e8a..c5488f6 100644 --- a/details.js +++ b/details.js @@ -1,48 +1,40 @@ document.addEventListener('DOMContentLoaded', async () => { + const logger = (await import(browser.runtime.getURL('logger.js'))).aiLog; + + const midParam = new URLSearchParams(location.search).get('mid'); + const messageId = parseInt(midParam, 10); + + if (!messageId) { + logger('no ?mid → trying displayedMessage fallback'); + const openerTabId = (await browser.tabs.getCurrent()).openerTabId; + const header = await browser.messageDisplay.getDisplayedMessage(openerTabId); + if (!header) { + logger('still no message – aborting'); + return; + } + loadMessage(header.id); + return; + } + + loadMessage(messageId); +}); + +async function loadMessage(id) { const storage = (globalThis.messenger ?? browser).storage; - const logger = await import(browser.runtime.getURL('logger.js')); + const logMod = await import(browser.runtime.getURL('logger.js')); const { debugLogging } = await storage.local.get('debugLogging'); - logger.setDebug(debugLogging === true); - logger.aiLog('details page loaded', { debug: true }); + logMod.setDebug(debugLogging === true); + const log = logMod.aiLog; - const params = new URLSearchParams(location.search); - let id = parseInt(params.get('mid'), 10); - logger.aiLog('initial message id', { debug: true }, id); - - if (!id) { - try { - const msgs = await browser.messageDisplay.getDisplayedMessages(); - id = msgs[0]?.id; - logger.aiLog('message id from displayed messages', { debug: true }, id); - if (!id) { - const selected = await browser.mailTabs.getSelectedMessages(); - id = selected?.messages?.[0]?.id; - logger.aiLog('message id from selected messages', { debug: true }, id); - } - } catch (e) { - logger.aiLog('failed to determine message id locally', { level: 'error' }, e); - } - } - - if (!id) { - try { - const resp = await browser.runtime.sendMessage({ type: 'sortana:getActiveMessage' }); - id = resp?.id; - logger.aiLog('message id from background', { debug: true }, id); - } catch (e) { - logger.aiLog('failed to get message id from background', { level: 'error' }, e); - } - } - - if (!id) return; + log('details page loaded', { debug: true }); try { - logger.aiLog('requesting message details', {}, id); + log('requesting message details', {}, id); const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id }); - logger.aiLog('received details', { debug: true }, { subject, results }); + log('received details', { debug: true }, { subject, results }); document.getElementById('subject').textContent = subject; const container = document.getElementById('rules'); for (const r of results) { - logger.aiLog('rendering rule result', { debug: true }, r); + log('rendering rule result', { debug: true }, r); const article = document.createElement('article'); const color = r.matched === true ? 'is-success' : 'is-danger'; article.className = `message ${color} mb-4`; @@ -62,11 +54,11 @@ document.addEventListener('DOMContentLoaded', async () => { container.appendChild(article); } document.getElementById('clear').addEventListener('click', async () => { - logger.aiLog('clearing cache for message', {}, id); + log('clearing cache for message', {}, id); await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id }); window.close(); }); } catch (e) { - logger.aiLog('failed to load details', { level: 'error' }, e); + log('failed to load details', { level: 'error' }, e); } -}); +} diff --git a/manifest.json b/manifest.json index 2ff6b74..77506b2 100644 --- a/manifest.json +++ b/manifest.json @@ -24,8 +24,7 @@ "message_display_action": { "default_icon": "resources/img/brain.png", "default_title": "Details", - "default_label": "Details", - "default_popup": "details.html" + "default_label": "Details" }, "background": { "scripts": [ "background.js" ] }, "options_ui": { From 34cf8e234e87ce6750a774be7acc5c9f558da21a Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 02:36:42 -0500 Subject: [PATCH 12/80] Restore popup defaults and update message lookups --- background.js | 14 +------------- details.js | 29 ++++++++++++++--------------- manifest.json | 3 ++- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/background.js b/background.js index 82c1fd0..6d0b4e8 100644 --- a/background.js +++ b/background.js @@ -334,18 +334,6 @@ async function clearCacheForMessages(idsInput) { browser.messageDisplayAction.setLabel({ label: "Details" }); } - browser.messageDisplayAction.onClicked.addListener(async (tab) => { - const header = await browser.messageDisplay.getDisplayedMessage(tab.id); - if (!header) { - console.warn("[Sortana] no displayed message in tab", tab.id); - return; - } - - const popupUrl = `${browser.runtime.getURL("details.html")}?mid=${header.id}`; - - await browser.messageDisplayAction.setPopup({ tabId: tab.id, popup: popupUrl }); - await browser.messageDisplayAction.openPopup({ tabId: tab.id }); - }); } browser.menus.create({ @@ -393,7 +381,7 @@ async function clearCacheForMessages(idsInput) { (info.messageId ? [info.messageId] : []); await clearCacheForMessages(ids); } else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") { - const header = await browser.messageDisplay.getDisplayedMessage(tab.id); + const [header] = await browser.messageDisplay.getDisplayedMessages(tab.id); if (!header) { return; } const url = `${browser.runtime.getURL("details.html")}?mid=${header.id}`; diff --git a/details.js b/details.js index c5488f6..016783a 100644 --- a/details.js +++ b/details.js @@ -1,22 +1,21 @@ -document.addEventListener('DOMContentLoaded', async () => { - const logger = (await import(browser.runtime.getURL('logger.js'))).aiLog; +document.addEventListener("DOMContentLoaded", async () => { + const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog; - const midParam = new URLSearchParams(location.search).get('mid'); - const messageId = parseInt(midParam, 10); - - if (!messageId) { - logger('no ?mid → trying displayedMessage fallback'); - const openerTabId = (await browser.tabs.getCurrent()).openerTabId; - const header = await browser.messageDisplay.getDisplayedMessage(openerTabId); - if (!header) { - logger('still no message – aborting'); - return; - } - loadMessage(header.id); + const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10); + if (!isNaN(qMid)) { + loadMessage(qMid); return; } - loadMessage(messageId); + const thisTab = await browser.tabs.getCurrent(); + const baseTabId = thisTab.openerTabId ?? thisTab.id; + const [header] = await browser.messageDisplay.getDisplayedMessages(baseTabId); + + if (header) { + loadMessage(header.id); + } else { + aiLog("Details popup: no displayed message found"); + } }); async function loadMessage(id) { diff --git a/manifest.json b/manifest.json index 77506b2..2ff6b74 100644 --- a/manifest.json +++ b/manifest.json @@ -24,7 +24,8 @@ "message_display_action": { "default_icon": "resources/img/brain.png", "default_title": "Details", - "default_label": "Details" + "default_label": "Details", + "default_popup": "details.html" }, "background": { "scripts": [ "background.js" ] }, "options_ui": { From 254f0c5ffc1672e071cb38f82c9b8a235416ab70 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 04:31:51 -0500 Subject: [PATCH 13/80] just catching up --- background.js | 15 +++++++++++++++ manifest.json | 3 +-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/background.js b/background.js index 6d0b4e8..d5a5e3a 100644 --- a/background.js +++ b/background.js @@ -369,7 +369,22 @@ async function clearCacheForMessages(idsInput) { icons: { "16": "resources/img/brain.png" } }); + //for the love of god work please + browser.messageDisplayAction.onClicked.addListener(async (tab, info) => { + try { + let header = await browser.messageDisplay.getDisplayedMessages(); + if (!header) { + logger.aiLog("No header, no message loaded?", { debug: true }); + return; + } + const url = browser.runtime.getURL(`details.html?mid=${header.id}`); + await browser.messageDisplayAction.setPopup({ tabId: tab.id, popup: url }); + await browser.messageDisplayAction.openPopup({ tabId: tab.id }); + } catch (err) { + logger.aiLog("Failed to open details popup", { debug: true }); + } + }); browser.menus.onClicked.addListener(async (info, tab) => { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { diff --git a/manifest.json b/manifest.json index 2ff6b74..77506b2 100644 --- a/manifest.json +++ b/manifest.json @@ -24,8 +24,7 @@ "message_display_action": { "default_icon": "resources/img/brain.png", "default_title": "Details", - "default_label": "Details", - "default_popup": "details.html" + "default_label": "Details" }, "background": { "scripts": [ "background.js" ] }, "options_ui": { From aec56aac33a6003348f04ded1cf1b85772dc0d52 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 04:35:46 -0500 Subject: [PATCH 14/80] Revert manifest to version 2 --- manifest.json | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/manifest.json b/manifest.json index 77506b2..0d6db48 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,9 @@ { - "manifest_version": 3, + "manifest_version": 2, "name": "Sortana", "version": "2.1.0", "default_locale": "en-US", - "browser_specific_settings": { + "applications": { "gecko": { "id": "ai-filter@jordanwages", "strict_min_version": "128.0", @@ -18,7 +18,7 @@ "96": "resources/img/logo96.png", "128": "resources/img/logo128.png" }, - "action": { + "browser_action": { "default_icon": "resources/img/logo32.png" }, "message_display_action": { @@ -40,12 +40,8 @@ "accountsRead", "menus", "scripting", - "tabs" - ], - "host_permissions": [ + "tabs", "*://*/*" ], - "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'self' http: https:" - } + "content_security_policy": "script-src 'self'; object-src 'none'; connect-src 'self' http: https:" } From 13751b3ab2741b23cbbf5fc0e8e497ed7dea203c Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 6 Jul 2025 18:05:19 -0500 Subject: [PATCH 15/80] Trying new things --- background.js | 24 ++++++++++++++---------- details.js | 14 +++++++------- manifest.json | 6 ++---- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/background.js b/background.js index d5a5e3a..b600169 100644 --- a/background.js +++ b/background.js @@ -258,7 +258,7 @@ async function clearCacheForMessages(idsInput) { logger = await import(browser.runtime.getURL("logger.js")); try { AiClassifier = await import(browser.runtime.getURL("modules/AiClassifier.js")); - logger.aiLog("AiClassifier imported", {debug: true}); + logger.aiLog("AiClassifier imported", { debug: true }); const td = await import(browser.runtime.getURL("resources/js/turndown.js")); TurndownService = td.default || td.TurndownService; } catch (e) { @@ -291,7 +291,7 @@ async function clearCacheForMessages(idsInput) { if (r.stopProcessing) rule.stopProcessing = true; return rule; }) : []; - logger.aiLog("configuration loaded", {debug: true}, store); + logger.aiLog("configuration loaded", { debug: true }, store); storage.onChanged.addListener(async changes => { if (changes.aiRules) { const newRules = changes.aiRules.newValue || []; @@ -304,30 +304,30 @@ async function clearCacheForMessages(idsInput) { if (r.stopProcessing) rule.stopProcessing = true; return rule; }); - logger.aiLog("aiRules updated from storage change", {debug: true}, aiRules); + logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); } if (changes.htmlToMarkdown) { htmlToMarkdown = changes.htmlToMarkdown.newValue === true; - logger.aiLog("htmlToMarkdown updated from storage change", {debug: true}, htmlToMarkdown); + logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown); } if (changes.stripUrlParams) { stripUrlParams = changes.stripUrlParams.newValue === true; - logger.aiLog("stripUrlParams updated from storage change", {debug: true}, stripUrlParams); + logger.aiLog("stripUrlParams updated from storage change", { debug: true }, stripUrlParams); } if (changes.altTextImages) { altTextImages = changes.altTextImages.newValue === true; - logger.aiLog("altTextImages updated from storage change", {debug: true}, altTextImages); + logger.aiLog("altTextImages updated from storage change", { debug: true }, altTextImages); } if (changes.collapseWhitespace) { collapseWhitespace = changes.collapseWhitespace.newValue === true; - logger.aiLog("collapseWhitespace updated from storage change", {debug: true}, collapseWhitespace); + logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace); } }); } catch (err) { - logger.aiLog("failed to load config", {level: 'error'}, err); + logger.aiLog("failed to load config", { level: 'error' }, err); } - logger.aiLog("background.js loaded – ready to classify", {debug: true}); + logger.aiLog("background.js loaded – ready to classify", { debug: true }); if (browser.messageDisplayAction) { browser.messageDisplayAction.setTitle({ title: "Details" }); if (browser.messageDisplayAction.setLabel) { @@ -372,7 +372,7 @@ async function clearCacheForMessages(idsInput) { //for the love of god work please browser.messageDisplayAction.onClicked.addListener(async (tab, info) => { try { - let header = await browser.messageDisplay.getDisplayedMessages(); + let header = await browser.messageDisplay.getDisplayedMessages(tab.id); if (!header) { logger.aiLog("No header, no message loaded?", { debug: true }); return; @@ -386,6 +386,10 @@ async function clearCacheForMessages(idsInput) { } }); + browser.messageDisplay.onMessagesDisplayed.addListener(async (tab, displayedMessages) => { + logger.aiLog("Messages displayed!", { debug: true }, displayedMessages); + }); + browser.menus.onClicked.addListener(async (info, tab) => { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { const ids = info.selectedMessages?.messages?.map(m => m.id) || diff --git a/details.js b/details.js index 016783a..6269dae 100644 --- a/details.js +++ b/details.js @@ -8,14 +8,14 @@ document.addEventListener("DOMContentLoaded", async () => { } const thisTab = await browser.tabs.getCurrent(); - const baseTabId = thisTab.openerTabId ?? thisTab.id; - const [header] = await browser.messageDisplay.getDisplayedMessages(baseTabId); + //const baseTabId = thisTab.openerTabId ?? thisTab.id; + //const [header] = await browser.messageDisplay.getDisplayedMessages(baseTabId); - if (header) { - loadMessage(header.id); - } else { - aiLog("Details popup: no displayed message found"); - } + //if (header) { + // loadMessage(header.id); + //} else { + // aiLog("Details popup: no displayed message found"); + //} }); async function loadMessage(id) { diff --git a/manifest.json b/manifest.json index 0d6db48..fbd37ae 100644 --- a/manifest.json +++ b/manifest.json @@ -40,8 +40,6 @@ "accountsRead", "menus", "scripting", - "tabs", - "*://*/*" - ], - "content_security_policy": "script-src 'self'; object-src 'none'; connect-src 'self' http: https:" + "tabs" + ] } From caf18ed5ab1c5db206303b2a79f0578538c998ce Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 00:14:47 -0500 Subject: [PATCH 16/80] Add message for displayed messages and convert details to module --- background.js | 9 +++++++++ details.html | 2 +- details.js | 15 ++++++--------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/background.js b/background.js index b600169..d58adf7 100644 --- a/background.js +++ b/background.js @@ -501,6 +501,15 @@ async function clearCacheForMessages(idsInput) { logger.aiLog("failed to collect details", { level: 'error' }, e); return { subject: '', results: [] }; } + } else if (msg?.type === "sortana:getDisplayedMessages") { + try { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + const messages = await browser.messageDisplay.getDisplayedMessages(tab?.id); + return { messages }; + } catch (e) { + logger.aiLog("failed to get displayed messages", { level: 'error' }, e); + return { messages: [] }; + } } else if (msg?.type === "sortana:clearCacheForMessage") { try { await clearCacheForMessages([msg.id]); diff --git a/details.html b/details.html index 1502471..d15a3c9 100644 --- a/details.html +++ b/details.html @@ -15,6 +15,6 @@ - + diff --git a/details.js b/details.js index 6269dae..ca53cbe 100644 --- a/details.js +++ b/details.js @@ -1,12 +1,9 @@ -document.addEventListener("DOMContentLoaded", async () => { - const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog; - - const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10); - if (!isNaN(qMid)) { - loadMessage(qMid); - return; - } +const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog; +const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10); +if (!isNaN(qMid)) { + loadMessage(qMid); +} else { const thisTab = await browser.tabs.getCurrent(); //const baseTabId = thisTab.openerTabId ?? thisTab.id; //const [header] = await browser.messageDisplay.getDisplayedMessages(baseTabId); @@ -16,7 +13,7 @@ document.addEventListener("DOMContentLoaded", async () => { //} else { // aiLog("Details popup: no displayed message found"); //} -}); +} async function loadMessage(id) { const storage = (globalThis.messenger ?? browser).storage; From 51816d8a19d7c8976ada797b5116c711700b97b2 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 00:23:54 -0500 Subject: [PATCH 17/80] Use getDisplayedMessages to load message --- details.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/details.js b/details.js index ca53cbe..1e9023f 100644 --- a/details.js +++ b/details.js @@ -4,15 +4,14 @@ const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10); if (!isNaN(qMid)) { loadMessage(qMid); } else { - const thisTab = await browser.tabs.getCurrent(); - //const baseTabId = thisTab.openerTabId ?? thisTab.id; - //const [header] = await browser.messageDisplay.getDisplayedMessages(baseTabId); - - //if (header) { - // loadMessage(header.id); - //} else { - // aiLog("Details popup: no displayed message found"); - //} + const { messages } = await browser.runtime.sendMessage({ + type: "sortana:getDisplayedMessages", + }); + if (messages && messages[0]) { + loadMessage(messages[0].id); + } else { + aiLog("Details popup: no displayed message found"); + } } async function loadMessage(id) { From 6a85dbb2eb12ff072871c4aa429e0bdf89b1f310 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 21:46:21 -0500 Subject: [PATCH 18/80] Going back to what works. Manifest v3 and its consequences have been a disaster for the human race. --- background.js | 283 +++++++++++++++++++++++--------------------------- details.js | 11 +- manifest.json | 3 +- 3 files changed, 144 insertions(+), 153 deletions(-) diff --git a/background.js b/background.js index d58adf7..59bac43 100644 --- a/background.js +++ b/background.js @@ -126,7 +126,7 @@ function buildEmailText(full) { const attachments = []; collectText(full, bodyParts, attachments); const headers = Object.entries(full.headers || {}) - .map(([k,v]) => `${k}: ${v.join(' ')}`) + .map(([k, v]) => `${k}: ${v.join(' ')}`) .join('\n'); const attachInfo = `Attachments: ${attachments.length}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); const combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); @@ -369,35 +369,14 @@ async function clearCacheForMessages(idsInput) { icons: { "16": "resources/img/brain.png" } }); - //for the love of god work please - browser.messageDisplayAction.onClicked.addListener(async (tab, info) => { - try { - let header = await browser.messageDisplay.getDisplayedMessages(tab.id); - if (!header) { - logger.aiLog("No header, no message loaded?", { debug: true }); - return; - } - - const url = browser.runtime.getURL(`details.html?mid=${header.id}`); - await browser.messageDisplayAction.setPopup({ tabId: tab.id, popup: url }); - await browser.messageDisplayAction.openPopup({ tabId: tab.id }); - } catch (err) { - logger.aiLog("Failed to open details popup", { debug: true }); - } - }); - - browser.messageDisplay.onMessagesDisplayed.addListener(async (tab, displayedMessages) => { - logger.aiLog("Messages displayed!", { debug: true }, displayedMessages); - }); - browser.menus.onClicked.addListener(async (info, tab) => { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { const ids = info.selectedMessages?.messages?.map(m => m.id) || - (info.messageId ? [info.messageId] : []); + (info.messageId ? [info.messageId] : []); await applyAiRules(ids); } else if (info.menuItemId === "clear-ai-cache-list" || info.menuItemId === "clear-ai-cache-display") { const ids = info.selectedMessages?.messages?.map(m => m.id) || - (info.messageId ? [info.messageId] : []); + (info.messageId ? [info.messageId] : []); await clearCacheForMessages(ids); } else if (info.menuItemId === "view-ai-reason-list" || info.menuItemId === "view-ai-reason-display") { const [header] = await browser.messageDisplay.getDisplayedMessages(tab.id); @@ -417,141 +396,143 @@ async function clearCacheForMessages(idsInput) { } if (msg?.type === "sortana:test") { - const { text = "", criterion = "" } = msg; - logger.aiLog("sortana:test – text", {debug: true}, text); - logger.aiLog("sortana:test – criterion", {debug: true}, criterion); + const { text = "", criterion = "" } = msg; + logger.aiLog("sortana:test – text", { debug: true }, text); + logger.aiLog("sortana:test – criterion", { debug: true }, criterion); - try { - logger.aiLog("Calling AiClassifier.classifyText()", {debug: true}); - const result = await AiClassifier.classifyText(text, criterion); - logger.aiLog("classify() returned", {debug: true}, result); - return { match: result }; - } - catch (err) { - logger.aiLog("Error in classify()", {level: 'error'}, err); - // rethrow so the caller sees the failure - throw err; - } - } else if (msg?.type === "sortana:clearCacheForDisplayed") { - try { - const msgs = await browser.messageDisplay.getDisplayedMessages(); - const ids = msgs.map(m => m.id); - await clearCacheForMessages(ids); - } catch (e) { - logger.aiLog("failed to clear cache from message script", { level: 'error' }, e); - } - } else if (msg?.type === "sortana:getReasons") { - try { - const id = msg.id; - const hdr = await messenger.messages.get(id); - const subject = hdr?.subject || ""; - if (!aiRules.length) { - const { aiRules: stored } = await storage.local.get("aiRules"); - aiRules = Array.isArray(stored) ? stored.map(r => { - if (r.actions) return r; - const actions = []; - if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); - if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); - const rule = { criterion: r.criterion, actions }; - if (r.stopProcessing) rule.stopProcessing = true; - return rule; - }) : []; + try { + logger.aiLog("Calling AiClassifier.classifyText()", { debug: true }); + const result = await AiClassifier.classifyText(text, criterion); + logger.aiLog("classify() returned", { debug: true }, result); + return { match: result }; } - const reasons = []; - for (const rule of aiRules) { - const key = await AiClassifier.buildCacheKey(id, rule.criterion); - const reason = AiClassifier.getReason(key); - if (reason) { - reasons.push({ criterion: rule.criterion, reason }); + catch (err) { + logger.aiLog("Error in classify()", { level: 'error' }, err); + // rethrow so the caller sees the failure + throw err; + } + } else if (msg?.type === "sortana:clearCacheForDisplayed") { + try { + const msgs = await browser.messageDisplay.getDisplayedMessages(); + const ids = msgs.map(m => m.id); + await clearCacheForMessages(ids); + } catch (e) { + logger.aiLog("failed to clear cache from message script", { level: 'error' }, e); + } + } else if (msg?.type === "sortana:getReasons") { + try { + const id = msg.id; + const hdr = await messenger.messages.get(id); + const subject = hdr?.subject || ""; + if (!aiRules.length) { + const { aiRules: stored } = await storage.local.get("aiRules"); + aiRules = Array.isArray(stored) ? stored.map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; + }) : []; } - } - return { subject, reasons }; - } catch (e) { - logger.aiLog("failed to collect reasons", { level: 'error' }, e); - return { subject: '', reasons: [] }; - } - } else if (msg?.type === "sortana:getDetails") { - try { - const id = msg.id; - const hdr = await messenger.messages.get(id); - const subject = hdr?.subject || ""; - if (!aiRules.length) { - const { aiRules: stored } = await storage.local.get("aiRules"); - aiRules = Array.isArray(stored) ? stored.map(r => { - if (r.actions) return r; - const actions = []; - if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); - if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); - const rule = { criterion: r.criterion, actions }; - if (r.stopProcessing) rule.stopProcessing = true; - return rule; - }) : []; - } - const results = []; - for (const rule of aiRules) { - const key = await AiClassifier.buildCacheKey(id, rule.criterion); - const matched = AiClassifier.getCachedResult(key); - const reason = AiClassifier.getReason(key); - if (matched !== null || reason) { - results.push({ criterion: rule.criterion, matched, reason }); + const reasons = []; + for (const rule of aiRules) { + const key = await AiClassifier.buildCacheKey(id, rule.criterion); + const reason = AiClassifier.getReason(key); + if (reason) { + reasons.push({ criterion: rule.criterion, reason }); + } } + return { subject, reasons }; + } catch (e) { + logger.aiLog("failed to collect reasons", { level: 'error' }, e); + return { subject: '', reasons: [] }; } - return { subject, results }; - } catch (e) { - logger.aiLog("failed to collect details", { level: 'error' }, e); - return { subject: '', results: [] }; - } - } else if (msg?.type === "sortana:getDisplayedMessages") { - try { - const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); - const messages = await browser.messageDisplay.getDisplayedMessages(tab?.id); - return { messages }; - } catch (e) { - logger.aiLog("failed to get displayed messages", { level: 'error' }, e); - return { messages: [] }; - } - } else if (msg?.type === "sortana:clearCacheForMessage") { - try { - await clearCacheForMessages([msg.id]); - return { ok: true }; - } catch (e) { - logger.aiLog("failed to clear cache for message", { level: 'error' }, e); - return { ok: false }; - } - } else if (msg?.type === "sortana:getQueueCount") { - return { count: queuedCount + (processing ? 1 : 0) }; - } else if (msg?.type === "sortana:getTiming") { - const t = timingStats; - const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; - return { - count: queuedCount + (processing ? 1 : 0), - current: currentStart ? Date.now() - currentStart : -1, - last: t.last, - runs: t.count, - average: t.mean, - total: t.total, - stddev: std - }; - } else { - logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type); - } -}); + } else if (msg?.type === "sortana:getDetails") { + try { + const id = msg.id; + const hdr = await messenger.messages.get(id); + const subject = hdr?.subject || ""; + if (!aiRules.length) { + const { aiRules: stored } = await storage.local.get("aiRules"); + aiRules = Array.isArray(stored) ? stored.map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; + }) : []; + } + const results = []; + for (const rule of aiRules) { + const key = await AiClassifier.buildCacheKey(id, rule.criterion); + const matched = AiClassifier.getCachedResult(key); + const reason = AiClassifier.getReason(key); + if (matched !== null || reason) { + results.push({ criterion: rule.criterion, matched, reason }); + } + } + return { subject, results }; + } catch (e) { + logger.aiLog("failed to collect details", { level: 'error' }, e); + return { subject: '', results: [] }; + } + } else if (msg?.type === "sortana:getDisplayedMessages") { + try { + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + const messages = await browser.messageDisplay.getDisplayedMessages(tab?.id); + const ids = messages.map(hdr => hdr.id); -// Automatically classify new messages -if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { - messenger.messages.onNewMailReceived.addListener(async (folder, messages) => { - logger.aiLog("onNewMailReceived", {debug: true}, messages); - const ids = (messages?.messages || messages || []).map(m => m.id ?? m); - await applyAiRules(ids); + return { ids }; + } catch (e) { + logger.aiLog("failed to get displayed messages", { level: 'error' }, e); + return { messages: [] }; + } + } else if (msg?.type === "sortana:clearCacheForMessage") { + try { + await clearCacheForMessages([msg.id]); + return { ok: true }; + } catch (e) { + logger.aiLog("failed to clear cache for message", { level: 'error' }, e); + return { ok: false }; + } + } else if (msg?.type === "sortana:getQueueCount") { + return { count: queuedCount + (processing ? 1 : 0) }; + } else if (msg?.type === "sortana:getTiming") { + const t = timingStats; + const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; + return { + count: queuedCount + (processing ? 1 : 0), + current: currentStart ? Date.now() - currentStart : -1, + last: t.last, + runs: t.count, + average: t.mean, + total: t.total, + stddev: std + }; + } else { + logger.aiLog("Unknown message type, ignoring", { level: 'warn' }, msg?.type); + } }); -} else { - logger.aiLog("messenger.messages API unavailable, skipping new mail listener", { level: 'warn' }); -} -// Catch any unhandled rejections -window.addEventListener("unhandledrejection", ev => { - logger.aiLog("Unhandled promise rejection", {level: 'error'}, ev.reason); -}); + // Automatically classify new messages + if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { + messenger.messages.onNewMailReceived.addListener(async (folder, messages) => { + logger.aiLog("onNewMailReceived", { debug: true }, messages); + const ids = (messages?.messages || messages || []).map(m => m.id ?? m); + await applyAiRules(ids); + }); + } else { + logger.aiLog("messenger.messages API unavailable, skipping new mail listener", { level: 'warn' }); + } + + // Catch any unhandled rejections + window.addEventListener("unhandledrejection", ev => { + logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); + }); browser.runtime.onInstalled.addListener(async ({ reason }) => { if (reason === "install") { diff --git a/details.js b/details.js index 1e9023f..f84ebf4 100644 --- a/details.js +++ b/details.js @@ -10,7 +10,16 @@ if (!isNaN(qMid)) { if (messages && messages[0]) { loadMessage(messages[0].id); } else { - aiLog("Details popup: no displayed message found"); + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0]?.id; + const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; + let id = msgs[0]?.id; + if (id) { + loadMessage(id); + } + else { + aiLog("Details popup: no displayed message found"); + } } } diff --git a/manifest.json b/manifest.json index fbd37ae..36b94b0 100644 --- a/manifest.json +++ b/manifest.json @@ -24,7 +24,8 @@ "message_display_action": { "default_icon": "resources/img/brain.png", "default_title": "Details", - "default_label": "Details" + "default_label": "Details", + "default_popup": "details.html" }, "background": { "scripts": [ "background.js" ] }, "options_ui": { From c7333482ce0fe646e7eb601fa0f9700f648ac8ee Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 22:07:00 -0500 Subject: [PATCH 19/80] Allow selective data export and import --- options/dataTransfer.js | 45 +++++++++++++++++++++++++++++++++++++++++ options/options.html | 17 ++++++++++++++++ options/options.js | 19 +++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 options/dataTransfer.js diff --git a/options/dataTransfer.js b/options/dataTransfer.js new file mode 100644 index 0000000..b289c02 --- /dev/null +++ b/options/dataTransfer.js @@ -0,0 +1,45 @@ +"use strict"; +const storage = (globalThis.messenger ?? browser).storage; +const KEY_GROUPS = { + settings: [ + 'endpoint', + 'templateName', + 'customTemplate', + 'customSystemPrompt', + 'aiParams', + 'debugLogging', + 'htmlToMarkdown', + 'stripUrlParams', + 'altTextImages', + 'collapseWhitespace' + ], + rules: ['aiRules'], + cache: ['aiCache'] +}; + +function collectKeys(categories = Object.keys(KEY_GROUPS)) { + return categories.flatMap(cat => KEY_GROUPS[cat] || []); +} + +export async function exportData(categories) { + const data = await storage.local.get(collectKeys(categories)); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'sortana-export.json'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export async function importData(file, categories) { + const text = await file.text(); + const parsed = JSON.parse(text); + const data = {}; + for (const key of collectKeys(categories)) { + if (key in parsed) data[key] = parsed[key]; + } + await storage.local.set(data); +} diff --git a/options/options.html b/options/options.html index f40cda2..186e2b6 100644 --- a/options/options.html +++ b/options/options.html @@ -207,6 +207,23 @@ +
+ +
+ + + +
+
+
+

+ +

+

+ + +

+
diff --git a/options/options.js b/options/options.js index 2350efb..ca25eb4 100644 --- a/options/options.js +++ b/options/options.js @@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', async () => { const storage = (globalThis.messenger ?? browser).storage; const logger = await import(browser.runtime.getURL('logger.js')); const AiClassifier = await import(browser.runtime.getURL('modules/AiClassifier.js')); + const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js')); const defaults = await storage.local.get([ 'endpoint', 'templateName', @@ -395,6 +396,24 @@ document.addEventListener('DOMContentLoaded', async () => { await AiClassifier.clearCache(); cacheCountEl.textContent = '0'; }); + + function selectedCategories() { + return [...document.querySelectorAll('.transfer-category:checked')].map(el => el.value); + } + + document.getElementById('export-data').addEventListener('click', () => { + dataTransfer.exportData(selectedCategories()); + }); + + const importInput = document.getElementById('import-file'); + document.getElementById('import-data').addEventListener('click', () => importInput.click()); + importInput.addEventListener('change', async () => { + if (importInput.files.length) { + await dataTransfer.importData(importInput.files[0], selectedCategories()); + location.reload(); + } + }); + initialized = true; document.getElementById('save').addEventListener('click', async () => { From 97628c693ba80c96d855cf2d79ce222b7e8d5448 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 23:54:52 -0500 Subject: [PATCH 20/80] Changing up imagery --- ai-filter.sln | 7 +++---- resources/img/average-16.png | Bin 0 -> 416 bytes resources/img/average-32.png | Bin 0 -> 794 bytes resources/img/average-64.png | Bin 0 -> 1506 bytes resources/img/brain.png | Bin 13993 -> 0 bytes resources/img/busy.png | Bin 3603 -> 0 bytes resources/img/circle-16.png | Bin 0 -> 389 bytes resources/img/circle-32.png | Bin 0 -> 724 bytes resources/img/circle-64.png | Bin 0 -> 1566 bytes resources/img/clipboarddata-16.png | Bin 0 -> 314 bytes resources/img/clipboarddata-32.png | Bin 0 -> 543 bytes resources/img/clipboarddata-64.png | Bin 0 -> 989 bytes resources/img/done.png | Bin 3543 -> 0 bytes resources/img/download-16.png | Bin 0 -> 345 bytes resources/img/download-32.png | Bin 0 -> 571 bytes resources/img/download-64.png | Bin 0 -> 1006 bytes resources/img/error.png | Bin 2921 -> 0 bytes resources/img/eye-16.png | Bin 0 -> 371 bytes resources/img/eye-32.png | Bin 0 -> 733 bytes resources/img/eye-64.png | Bin 0 -> 1394 bytes resources/img/flag-16.png | Bin 0 -> 300 bytes resources/img/flag-32.png | Bin 0 -> 475 bytes resources/img/flag-64.png | Bin 0 -> 800 bytes resources/img/gear-16.png | Bin 0 -> 462 bytes resources/img/gear-32.png | Bin 0 -> 993 bytes resources/img/gear-64.png | Bin 0 -> 2169 bytes resources/img/reply-16.png | Bin 0 -> 289 bytes resources/img/reply-32.png | Bin 0 -> 432 bytes resources/img/reply-64.png | Bin 0 -> 750 bytes resources/img/settings-16.png | Bin 0 -> 421 bytes resources/img/settings-32.png | Bin 0 -> 787 bytes resources/img/settings-64.png | Bin 0 -> 1489 bytes resources/img/trash-16.png | Bin 0 -> 390 bytes resources/img/trash-32.png | Bin 0 -> 631 bytes resources/img/trash-64.png | Bin 0 -> 1126 bytes resources/img/upload-16.png | Bin 0 -> 352 bytes resources/img/upload-32.png | Bin 0 -> 565 bytes resources/img/upload-64.png | Bin 0 -> 1006 bytes resources/svg/average.svg | 3 +++ resources/svg/circle.svg | 3 +++ resources/svg/clipboarddata.svg | 3 +++ resources/svg/download.svg | 4 ++++ resources/svg/eye.svg | 4 ++++ resources/svg/flag.svg | 3 +++ resources/svg/gear.svg | 11 +++++++++++ resources/svg/reply.svg | 4 ++++ resources/svg/settings.svg | 3 +++ resources/svg/trash.svg | 3 +++ resources/svg/upload.svg | 4 ++++ resources/svg2img.ps1 | 28 ++++++++++++++++++++++++++++ 50 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 resources/img/average-16.png create mode 100644 resources/img/average-32.png create mode 100644 resources/img/average-64.png delete mode 100644 resources/img/brain.png delete mode 100644 resources/img/busy.png create mode 100644 resources/img/circle-16.png create mode 100644 resources/img/circle-32.png create mode 100644 resources/img/circle-64.png create mode 100644 resources/img/clipboarddata-16.png create mode 100644 resources/img/clipboarddata-32.png create mode 100644 resources/img/clipboarddata-64.png delete mode 100644 resources/img/done.png create mode 100644 resources/img/download-16.png create mode 100644 resources/img/download-32.png create mode 100644 resources/img/download-64.png delete mode 100644 resources/img/error.png create mode 100644 resources/img/eye-16.png create mode 100644 resources/img/eye-32.png create mode 100644 resources/img/eye-64.png create mode 100644 resources/img/flag-16.png create mode 100644 resources/img/flag-32.png create mode 100644 resources/img/flag-64.png create mode 100644 resources/img/gear-16.png create mode 100644 resources/img/gear-32.png create mode 100644 resources/img/gear-64.png create mode 100644 resources/img/reply-16.png create mode 100644 resources/img/reply-32.png create mode 100644 resources/img/reply-64.png create mode 100644 resources/img/settings-16.png create mode 100644 resources/img/settings-32.png create mode 100644 resources/img/settings-64.png create mode 100644 resources/img/trash-16.png create mode 100644 resources/img/trash-32.png create mode 100644 resources/img/trash-64.png create mode 100644 resources/img/upload-16.png create mode 100644 resources/img/upload-32.png create mode 100644 resources/img/upload-64.png create mode 100644 resources/svg/average.svg create mode 100644 resources/svg/circle.svg create mode 100644 resources/svg/clipboarddata.svg create mode 100644 resources/svg/download.svg create mode 100644 resources/svg/eye.svg create mode 100644 resources/svg/flag.svg create mode 100644 resources/svg/gear.svg create mode 100644 resources/svg/reply.svg create mode 100644 resources/svg/settings.svg create mode 100644 resources/svg/trash.svg create mode 100644 resources/svg/upload.svg create mode 100644 resources/svg2img.ps1 diff --git a/ai-filter.sln b/ai-filter.sln index 7922392..7818aed 100644 --- a/ai-filter.sln +++ b/ai-filter.sln @@ -47,13 +47,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_ EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}" + ProjectSection(SolutionItems) = preProject + resources\svg2img.ps1 = resources\svg2img.ps1 + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}" ProjectSection(SolutionItems) = preProject - resources\img\brain.png = resources\img\brain.png - resources\img\busy.png = resources\img\busy.png - resources\img\done.png = resources\img\done.png - resources\img\error.png = resources\img\error.png resources\img\full-logo.png = resources\img\full-logo.png resources\img\logo.png = resources\img\logo.png resources\img\logo128.png = resources\img\logo128.png diff --git a/resources/img/average-16.png b/resources/img/average-16.png new file mode 100644 index 0000000000000000000000000000000000000000..ff202ff88b987856d28c37c894f1a362af2b0b00 GIT binary patch literal 416 zcmV;R0bl-!P)H zK~y-6ozuH6MPU#H;BOjr z&X4U9JDKFqT3Pe2HM9RJBK+s8s%i>%1K*G>tY9*s8=OYOL-K7LU{*xbg+c7&1y?xA z>L#|2PM+Hlp%f3{9JiP+8z;YjdjvW?yo|7eQOrfeQ$wNjzLb+H){`qIb*E*iG6n$up7h@*nKOikV3^&9aaHX`D%VJ1<|V5^MK zWcyRBZZ`fh#DzbImvV?>pZ|kB?|c49Jd8W+)>uJyGS@v88^nt#KEvRL28HcTI(fl0 zj&YLbXY7{Zh)`iVSGd+&RX(O=?~kd<2B#76*su-#m~P7amwE%VNkkY)Rgm5Q0000< KMNUMnLSTY>O0#DG literal 0 HcmV?d00001 diff --git a/resources/img/average-32.png b/resources/img/average-32.png new file mode 100644 index 0000000000000000000000000000000000000000..e0fe4b46d0d2d0de6ca4ff004a33dd5acb80462d GIT binary patch literal 794 zcmV+#1LgdQP)7;9myn zimd}B5g`w87jOYs4v=Pz??46EqcKK-pZe?%@EBMEsyf#X z><3N(2MqQ-a2vR*+ui_~r4E!g;4^{@81g^BrM$Uf zVoZAi#)7zjW4CTyN`9;ja+`4JSOB{62)JgP+F7}u?RQMP%TWO&rS?_9FFQb9*7;XD z_XV#+{Qy%&?WPHrhg_eHn79)$0S^`QVTp_4!Opz?yy|4o8H`#TV+>hDh zvI6X?`^d01$^Dp3uBHGHsgoSD0D~FiW#E~TH-Mu#$!*M4vAu(rD6=Hnv$&mv%Sgf{ z^}QD>!%141I=t0Vi;y2SamQlBA1St55Q9IoM^623fa_9!; zjnj6>Pmmnx0=y&7ajcGGo`C5#$gdjm8qgoa1sp@ogpeGmBRLc9jq$4ihqCbNno_TC zZvf|k>p+Q?eIjX8{ibvfI8D-=rZx|p5s@lcW|Rl(oK{<23+zhP_=aiAqdTS~B{s=+ zk`kQ&W`NZI=?ZWk7{@EMJOU~)@{D)g1MJh$v(+SBmjn8vcoBFmA`6-C>mOM3&uAy? YFQTTm<6Y?dp#T5?07*qoM6N<$f-$sWFaQ7m literal 0 HcmV?d00001 diff --git a/resources/img/average-64.png b/resources/img/average-64.png new file mode 100644 index 0000000000000000000000000000000000000000..bec89df178cb32f906ae102e43d4ff8284711374 GIT binary patch literal 1506 zcmV<81s(c{P)d2v}$TK3Ik$)eORZ2I4U|Y@fQOcb7!y=VZ7c=t_^^B1y?f8h?(ELpyD2A`hdcM|Ip_O5Gqdw+ zmDZXESsrS10F9Ohh5)5hn^H=}>(@O2N~sQ|)DOS{Fr$=uvFiWzJO(JGI)HItccgkw zYu&l3QzQXOsU~1M&%o@<9zX+|wAOb4n({hI2%-fz2aLgcN)3K~O369&j9Z zG==&sa0R#u+#(qK0NQ}3fStf|1cPLfEC2&q>#q~~{AJ)Zt@WRxl^j{$7y`FtY6Un4 z9058VYUI&BqBGn|gg?TR?r#CMF7F7E^c%g4-*_8nUCrBB2SjAtH`7v zh6RH|Wy*Z)xl@Av$xH+Qo2VW6SL&U8*USs8Aij2(2U@Za@U{@Zxw3+f*q9WaJ!I0i z?F#P`>P}nqb4jixZM8@-?l)>%n7m9alU-%ae8g6_VB?Zg7a-UyjgP1^Wd|Rzc_U_L z8Q9>`1-hx-z5T`5j53N+s-0l@Y-;ugQa1azT#5bONU+ILBPIg23w75s1Y(r1&rbj^ z2!B`l{2v+3Ulr;kOhy8v{k>ZmgQ<}^X1*d4apIn(iGWt2Zc(UnmJs|H=FA_+k@<@J zEz~_CDHeJ-S`c_AF%ckTyDg8Sg27AKUXT<`XM>XRlUbwjILS#XQmj(9Ft&hMp>9`} zND3J6BRSx8;J6`D0`Ss?$Zeq6nSjvXkN{-Z zpqptM%QFF|1m7pD&mG@0g8voZrQ8IB4!#l`EMY#SEfW0m$VX$RQ$3&3$rjRIc`Fip7x0@9!#uFr z8v&uS!zBcN-4Mq+sqHvAwOxvuF1XoCnb*KECtD6qlg{UtfTIO~|H#niHSitKS;BRI6U2K-L-RoSNrc5^D(n;C!Rn7ogy^Jjxn!W9O{$*gaj=)DrF}=-9KpyZh zJ9~hW1T(U{pl^q^tgAX-1emVDW<8~C2Hpfd0WMLy(JGZ?;3Duba0tt0hL5s32r8wT z3GV*IzpeAOho_XbMPR1HyD0i+N!noavEHAS=V2Vrjd33?QoL(53T<1VIFT)r~m)}07*qo IM6N<$g6_7zO#lD@ literal 0 HcmV?d00001 diff --git a/resources/img/brain.png b/resources/img/brain.png deleted file mode 100644 index 296bb646386cd6cbcbc028e261e84bc130a313a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13993 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 Z{{!~PeOa9a)Aj%W002ovPDHLkV1jZT)9nBN diff --git a/resources/img/circle-16.png b/resources/img/circle-16.png new file mode 100644 index 0000000000000000000000000000000000000000..9984541714ea2e78e5bcd3c480ee3aa3f5be7ca1 GIT binary patch literal 389 zcmV;00eb$4P) zK~y-6ozuZCLs1Y0;P2kBs0Yy4X(jbN+v_h|m;SmbKAM*N2FBYAlS1uY(M`xI*APoxmk_kWFDK;3b^l6$7kSay=Yi zj3HW61gY~mZqTWAs7=^SHN$d}GO?FB&ji;<-q5cSTw(AVxI%J>Q7OO*Ce=63LC{N> zc8*ni;J#6&=D16kPM#$CPh^?i?Su zrjT)~ZlqI%NoJBH@GXv2jg*qDL^4+Byiv(*;wW`KQMYn!nfe$hbS{@^f(vY`EmQWx jIxqWH<}M;$n(96QSQkD23raJV00000NkvXXu0mjf2D772 literal 0 HcmV?d00001 diff --git a/resources/img/circle-32.png b/resources/img/circle-32.png new file mode 100644 index 0000000000000000000000000000000000000000..6023c0e97862d2eacd2b0fa78dad9ad5653301eb GIT binary patch literal 724 zcmV;_0xSKAP)%_D0Ty}iH~|ZU5IZgcI|x=F zfhZ3G7k~{&F?mRV0}#u_VruM4XFQn+)1swTX}Y`W|E8xOHBLnMpJgDggL7^O7zNG% z=e4;7>;fAi@+((B5jnB~RbUSI0JH(=Q`^8IFbh;O#XdrK0JsbM>?63NHL0%!OabL% z0RX3g1uy6la38n~oG@c6+V((a$@;tnYPkS_OTaf1WEHrPWlGh*4y+mfufRwq0HEfH zzW_>E#Z4RsJO(-j;?PVaee-zQJ3u3^a01AbfoS?`!XJFky!n%V6*qC8nYfKa0NrZw zYF|0dw5I@+fOWGws!0HIhBEVl%K#o+MzD;1#|K_;>3`iB20p64T zYqk%(1E%d(ORG)=G8fIBMuD>&{5jnr`Z@ibD_&lJqRU@oF-b>-c!^&C00002ElnGxQj3v*5<-m8t+phMQIZ;6xOam<)VMGb zKuz2jU!*S3s)<3xM|2}dqCzxjt8PTz1}a6M+}?gJ=3F>;?wq-&_s%(|H~3E`xidNU zoB#Kp$2s%Oj1m#9Wz5Q&K-S1q0HsuuQtF0GIV3#SHKMgyt z0h>kSTwKm2sG`+Z0)J4xwT=UC1CIgAf!Pk5vw?Qtao`>Fq*%`W@4#KD4gugPV8THv z59|RR1nQ~~xjwM5C$Je+-zDJ5#03Bp@PdQLRM6VXe2h<{?pEO4ppK~ew#}k_#BV+Nb!uyzuBrq8=3;-jsiD_iu1rtA~IGeFnhp#ma;Eu&3OP3$)Q7MVblR@!v&_Z zJr-Nvc;uBfzP6OD3TI3KS}lM0!5f|QallemUctq2CwKtEe!{AyJE%Yvl7Ha-op9B7o>mLbJ=yPmowzFt_2y!x?i395eE_Dy3$7 zr<4+uQjNe;qxfhzV+t^A(-6Y`cWGr-PY$FhW1&E90D4%cGAK5 zI^Y)z@4b=#aTx0aE?bQ4$l6f{XWJ2;9F8-Eu2p9vxa&Y!pNyNKglKx_emN(Lgj*95RJ zL+AS=T3&!nv20AEvTYF+3Hn&;^(i{ui2e9ZAw3t{)-)^L<*4*9usQ|j*F?PP3TfxG z>{UQutE2KM;60!%0ms|X7bjdtWn;H|#R3Fm1|lZLLdpXl01pB6RXE)MJPdq@`>^!I zxMSXn00Fr!^f|mJQiXIX=oOoQ4jf;S!PxEyp4}b%x19YEV$AkYsoDz@&A=Am8RB1b z$fE-lr@0!1ZbGl!7Zd;DMGn{rY!Q*lH*h?S&raYo%D0xUv=`{`!rh0FkPzMI@oGC@ zS#8Rp=arQ{IVW{(t_6J{sTX~@_Gt4QPGe3Bdr2sPl*ZlVQe^mr{58M2! Qvj6}907*qoM6N<$f_{k1&;S4c literal 0 HcmV?d00001 diff --git a/resources/img/clipboarddata-16.png b/resources/img/clipboarddata-16.png new file mode 100644 index 0000000000000000000000000000000000000000..6d615ef77b2fba225855802cb3e4f5d4e1b1ab23 GIT binary patch literal 314 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X#yh2s3WX6WN&PEET=(wkgV~9oX+sTGpOojrk_gnTh zh;3?(a}?0k&3&`Cze3(SwO_G(>{F*T`lWsWGT58Z!e{`8q-zi8DBfr)$< z%)f+%e(y~Rtz{@UAo1bA!2|7cs$UwO&3?c$r=jzp=993M9$E%WSqr#BW^!sR5YKAc zxZWf0GSi+_?;0{6uRH(!ql5LlySoqhKhADEB=nH`x#D)7FFZRKy65|U5LbA8#OUj; zn^T1zAGxD<#UxiIFuBa}QmL3AJI{{WR^sjni&tIuxWgbjr+2U2RXanVml!-<{an^L HB{Ts5PGNe5 literal 0 HcmV?d00001 diff --git a/resources/img/clipboarddata-32.png b/resources/img/clipboarddata-32.png new file mode 100644 index 0000000000000000000000000000000000000000..a4268788d8922fd9d58f18a10f42b526d3681169 GIT binary patch literal 543 zcmV+)0^t3LP)h)4k#HJ?|ZI*!-!DiJG)!Jswy9p1J4Z-bJuF==JokhU=iFtG|O@{>zfH1KFY h4Q)vLIXc~*e*t55xZec^9X$X5002ovPDHLkV1mug>2&}A literal 0 HcmV?d00001 diff --git a/resources/img/clipboarddata-64.png b/resources/img/clipboarddata-64.png new file mode 100644 index 0000000000000000000000000000000000000000..9d945d01d1ba90f1d95bdec111e91d4d91aec7e8 GIT binary patch literal 989 zcmV<310wv1P)$Z zybZ`3?O6&JkqXe2Qp~)n{`O%{EL7Ed0S1AWz#^b2%Odas=+DH~44l8^!Rbcj=DH@p zJo&7g|5pZl+U#u-6cGU~`jXcK7mO}1kTlDnh*W{|z-eFy^^@vP;B{gJ0eY#QNLPSQ zz!Tt(s(wr?Jz=OJ~X?pgsbF-LhxFluxi1HMMuItXmEhhqeMpq}-jje-SWXGq`im_AcZJ~=qw zMt%5C#`HT$5EZ_ip8-z9=$nf4s{w6UtO8xshyP5pld3)wk%Pcl;4rYwK}Q^Bfj7Wi zRs9ju{u$^A{q8Wf&e;feAs{aMip`=_^*eCeL3SF%*1J> z9QFgcX|d}|upejhZvP7C42|nFbv9#%EOw2%v)B-6N&rDLEm=CDgud_q0}kVsfOcO5 z6n+lyMu4iW0K=BuCs@6KI%eu@Fl=;rf!9e;Rfk37IgXQ{s{Rs@qvS9NdS3;MfzC!6 zLLU0Irxgl62b6h0nFo}4K(SHg0lox5nFj!69Vfzg<{`+1OHoWB>-^wdd?NhWNSR)KfaADD#T#O4)T_#aT0 z3lz2)Qz-MghJDty)@8)mp9BO{fXC~=*=Tu0f?Ffb1cXSqO6w~#Yf(~NnMp)Ih=?k1 zgWwqbT9UC0yf?b{r12rut+6o7Rkkf>f+;dNSM_0UH&Ce;xh{xftxFvSZp(00000 LNkvXXu0mjfL+`Wz literal 0 HcmV?d00001 diff --git a/resources/img/done.png b/resources/img/done.png deleted file mode 100644 index b70d72805599e17c091d6b170b3dce3c55885407..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3543 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 RKqUYG002ovPDHLkV1h&PnS1~M diff --git a/resources/img/download-16.png b/resources/img/download-16.png new file mode 100644 index 0000000000000000000000000000000000000000..620f8687f8c8303c97a8af259cc528a254da2195 GIT binary patch literal 345 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X#yh2s3WX6WN&PEET=$WUBV~9oX-OGl0M-l~CKiqdV zadDjDurxh@Ea`AHAO%wUI`zFWk8|S>2{d`uN zpB9%E6v}omV13G@w<^_pEtG4y&Ln<1FZ+k(f1tw2nn|}+xMTA-d#;Q&4MeC&2 zy@F=%Gs$e?I}Qg=+NyF{wl!^{*vB66H%Su5SE!vc=$hO1xohdFR~&C1?^(5Fv7qG< zPB;6feuV*{b6uqlCx6cgy?W@H`90f&KibP0l+XkKMly-e literal 0 HcmV?d00001 diff --git a/resources/img/download-32.png b/resources/img/download-32.png new file mode 100644 index 0000000000000000000000000000000000000000..840c82bc7d42bb4a1a21d5d9c971a6efebc4af80 GIT binary patch literal 571 zcmV-B0>u4^P)ruf3NrTnac=wmEFhr68~fmJlit{Lrra3?v;gaYh)!QXK)wq+&yri=gG5pe7zW+~ z5wK@wbCM$9J22p&i@=DBSAmZ%{z-g0{*p8X^Z@6;4`9X2E)%x^cm}L_D=GPFHy;Im zBJ^G0g~PT3*TMWI(3ZFbz`O@O2KIqvpxMJjz{cOm9WD?MV8qa&>;J<3FNHH@z14h$u z2|538J&aEHXapP*7YM+sf56r4#RsLJtP@JwChlCP#697pM&vEPC!iJBH?!LX21n%` za7J>ctJW`op|rc83z!0WiO+g1>>StzzL?ow8DA*ie_P#8!42$Q6^|nLNJIbt002ov JPDHLkV1j-8_PPK7 literal 0 HcmV?d00001 diff --git a/resources/img/download-64.png b/resources/img/download-64.png new file mode 100644 index 0000000000000000000000000000000000000000..67aa68df0ef30c4a0232edecfec0c03cb7d48807 GIT binary patch literal 1006 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 TIJ7S^00000NkvXXu0mjfV^C6t diff --git a/resources/img/eye-16.png b/resources/img/eye-16.png new file mode 100644 index 0000000000000000000000000000000000000000..392340e68ffc711ea339434e4587a3cd6441a659 GIT binary patch literal 371 zcmV-(0gV2MP)`@!!F*@$~oT;7+^aR$C!;+2OsF+GS;o29krXq@McgvEUmH_ z)R(Y;SFD!uM|eVWs*qwx(0;_`kZ-+! R5e@(V002ovPDHLkV1nL7os|Fp literal 0 HcmV?d00001 diff --git a/resources/img/eye-32.png b/resources/img/eye-32.png new file mode 100644 index 0000000000000000000000000000000000000000..5531c1a5252f0b50190e6493752d731548c55b43 GIT binary patch literal 733 zcmV<30wVp1P)tiUv|}w+qZfEDP9!ER*jKk=NFwNMI+7Dn8TB7x(ID&yF&OgGodQO% zF^9Y>B0Hp1McX3#<{aO37)~*F(G&~V5(U^@r7HR&+_cQvgVcVqivOO-e@h6VVkfND z9fzamNyUdGNs{Qj{HB*iQf7&c;Fi)6^yTbeo>CewV`@r#FxuJr9N+bk?;Y_Gq<{qq z7o>Ozx90>p8j<~uf(C^ikyP;6fuR)h3iI>Iut%X(iUBWTP6pG8V<~xDhF8#@!Oq2{ z2=gAxbNfKlL>@#=11Vu=g^JjrP-5MgwQd|&=#+7v=*aTrwc^Rxk6mbqmYx{hiU(4A zn^G%Yg(Z>CFiv1!2;plPSzgIRyF7z$nRM4Ozv48ORpY4jb37eu(%Fl}%Ks%s@J!)z zd=1w^2%pA`U5kJUf+k|7Lh6h6i$pI5YP}%cL9F@n@x_{OJ%mX P00000NkvXXu0mjfBB52F literal 0 HcmV?d00001 diff --git a/resources/img/eye-64.png b/resources/img/eye-64.png new file mode 100644 index 0000000000000000000000000000000000000000..7d7fbd2011d74575fe311d18ae099c025e684e35 GIT binary patch literal 1394 zcmV-&1&#WNP)W)xBMP=c00K@UYQl_02L(FLO- zQ_=@}iO7iPrbWGUqav_Oi=Z^kQbN--PlT7%!>og{U6(!kTvp)zV4n7T|L;HlHS^8P zH#;RkHS zw|GBI)6IRtv$q6Dl4Jm8<3?PE@x20k%(i14-p2YgO^@^nYMQ1!Y8ZhBuoY=PwcoH9 zr}dbB4+cIKFXEqm9C%~v!t$O75xoSGBpHbNvM(?)!gU9}#}@o0B)tRKZ^JUIkvI<* zVq66FUM#|jG))gj_{GftTq-1{sxNaC8}K-$V?+xxY$Rsl34GH;%oN|@l0I1in6G-e zZ^c76vkAPx!p<-iooyz3RYGG{Tw%AKm}GO?#9R2ENM(^D{+1W z#sJ)cFDf`K$DkewkPZHWk{~-UN4A|-U`@fsCZ_QrENMc_1at9w3D){55vsn{8ztb+ zVQ7=f=L#X|#bo%`t3=4Khv9`1oV8^*W$Lc(l{FR)M1MpMzPmjg5$AT{NS%lCGCE=e zSXuzI4U?NCm0_(dz}kaX@si^8d>B(AVoeoJjCoj#n-T!m;7AU@PFxT<_=yErt8r>W z-|5(u)AyCgm=g+cj$l@)0NHx_HOF}$ro|A+@E*>=dQU;p#AP{x?Z@EAxRbLP));%c zhP>72_)Lz|AzT?d_zdsO9K6dbeAeaoG`$Rt!A_xdI4TzbnUKVJm~rFh9w` zE8muQG{@(L7UM6@!RkV1cb{e|LOrP(<(kA?=SPqlh>uDJS=5eTc0`;xI9PzSM$y@$ z1aN?GAt@rb9K)L=HyWQt4E|r-8L`aDuIsOfy$l@(K1jGy7J=`?ydrgTupTSi z6^Eq^uLF0=T}S zCBO-Rk|e>|LhcwPl;4JDKkpX?y%)RFG~L!Or1YZz|5rQd|0C2Epl$+nTU&s-3Dj+E z0qQ1Dx3vYRn?T*x7NBkdbz572x(U>6C%pjw0TyUl8PSTrR{#J207*qoM6N<$f}z=i ALjV8( literal 0 HcmV?d00001 diff --git a/resources/img/flag-16.png b/resources/img/flag-16.png new file mode 100644 index 0000000000000000000000000000000000000000..3c2b58c46f67f386e6d384a0849d0e9ad531eed7 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X#yh2s3WX6WN&PEETXs4%(V~9oX-HV2POo<{a58ks& zYrWagB*G@u=kSp6&6*w4F5Rx+)!!g0D#|KyTaep#9_yR5*odDSOD0`E^ThYwY)`?3 zPF^qNl=mk~3%c&TY;+^B;e6lIXc0lfl`}h5u&X}vUANHoA@gSWnm)#5Heev3N3eZzTh`PJ^aO*atDnm{r-UW|K{#>J literal 0 HcmV?d00001 diff --git a/resources/img/flag-32.png b/resources/img/flag-32.png new file mode 100644 index 0000000000000000000000000000000000000000..531dee24dbddcc7213738008845885a005265531 GIT binary patch literal 475 zcmV<10VMv3P) z5=>$cku?}}J%%@n<<0nZclL?!4h%Ez%{|OFcbE%flK4yY`6kS499RU(U9Qmt&Lq`S zXCu}NmA1n9~HpNhJbTm0C)!$Bt3^b(E!Y>1e^e)B&lDKbn}}pW(GEaSvTI5 zRLx?F2{7mS4`4s5DJH-=NzthQRWs|)6+qGzu;D(ZfWuq?oOXcL*)N;fTCM;}s?aKM z>wfK-*>uPgD#a}go7qI0EL$X3vI3l%*`%b$)W{5_I7Yk3trUCo>6^%M1V^-SuT#_n z?z#bZl2q#gAgH{Kpa3WU3V;IOe*!dI|JF+Y^SzGU<8%f%kkm*G>Loy4`UdU)3)QWZ Rx&;6L002ovPDHLkV1k1O$kYG; literal 0 HcmV?d00001 diff --git a/resources/img/flag-64.png b/resources/img/flag-64.png new file mode 100644 index 0000000000000000000000000000000000000000..64464154e79aa1bbc80c3cfde1b41a0b032b5c7c GIT binary patch literal 800 zcmV+*1K<3KP)Smrg}2EF`RgjcqzbD-l#8f>t6}q!QGq zD5#{fvR4p6C1_zIB>0G+kv$f>h)(XB%-q>MC!6_Uik-uG+>hOV_s(8XRhF|>SPx*W zRjG+0vIZE)I7=PPsp_|+OjV6qz!2~RXaeeFHQO@{MPE+>fH4Ob+-Z%)UQdb#>`ch% zmA1zwqyX@2oDX6Xl1(tsGhi-Lt%bB3SXZ%s7Z{JOmf!)C%>V4IkFArkfMMWQMgLd8 z+LW4}-_HL?M79C9fR#aL1~{myUsCEW2>|Wi91nqwL8u9gsOsC)+U@~}hyXW%9Tnj- zs`|K2efI!>)4-96{rkYBy2V`s5RqZvTt(|6O9B9N&lGc=3Lg`Z@0n`-PgB4&up!uQh{$tQy&hdL)2XnB^#j<|x2M93 z&>%1xo77JL03YA{TQ2=3&6Ol&Sa|9Q}xqFAV~#) z^#Il?0IUbFRsmo=fVBz$>jA7)09X%TtpdP$0BaQh)&p3p0I(jwS_Odh0M;r1tOu}G z0g#Z>T;$%8Pzr#T!RrsI`Z>oi7D@qd9(YRYAKCZ7(X12oSd#kmY5R%p*={_(+Djz^ e=oc;fZ-L*$`ho>)%P__O0000 zq2ZN#by_%Y9bPYXU|`P7oSEl5&p9GO+wd53)&2pNQEt?ZIs6VI46%B8(<#07+^AE9ZaNO@H-LRug)nr%I>FlK8}|x-m9*2&@@=a{9aEUY94tC*^adib2S(IR*SS6FBQ z2MPZId$-8=BGCliqtwaiB=bzZdDjoA@uzaYw)&s6CM;(6B|LTgjDIF4Yni+Ezt8mH zuf{yX%Ov>{Z`<_^ynh;%Qc{^gMEuCuL)?jouWfz*06^nayWlO6ZvX%Q07*qoM6N<$ Eg7A#aUjP6A literal 0 HcmV?d00001 diff --git a/resources/img/gear-32.png b/resources/img/gear-32.png new file mode 100644 index 0000000000000000000000000000000000000000..67300de32f7e208241ca8f76c9d526056736ad3f GIT binary patch literal 993 zcmV<710MW|P)I8XBZA{2u6)c+(2K;^h$ul!6pSM{#^~Tm7zPwv1mj8(@y~Io zh#K7aSonS*s6=Nm>S&w{W1{hqc+179ZRz-y;7?8et85QxWE@!OfR?%W z6~IN?g-^g$pxw#mI=gLUYov;Bz8hCv-P{E_L}V(^tg54d-4AX&t8|4()T@9_U=Q%v zqIhl#C2PjvJTcoxz!t+@GGPjUpMYD`=T?DVzh!a5+aAHG0vCYR8V7(W!2O890ErwX zoam}kM0w>|0|*z170N{shW@PYzwtot1AO&sfS)BVFcFx@IWCzSSx@hs>40I<%< z)&o!7PP9bqS5OexFfb=P1r-WPy);2V<&mzA$bWY7T@iUs>7&5rx?^Zc2TkccFg1`J zGudFt7UuyRjTGWrll6I-XDomg`;Gyh1KDIA4owtS|GQe$!plzE9tF-OQm{56U+3gI zBl1fWHBt$QXfDMT4FSs|04)?-`~=tli1m%YpC%gwW=8DW$qHP7XTUV?zyTAC0c{Bm z@W*hN+WWr>^px0pdhYQK9Jk3=4G>8W@X7(@{Uy+yuobt{y{g4^e8;}6B60#aLw3(U zRMoMdW=@I78B0OM!0rQARP{ZPCL;O!U_Tj+13b(DA*<4xk*HpzqDn;ki>nRa?_k%v zS8gS{DytEL3F$eJst`wkYrw)N5t-oyYc!D08*Y&iHy$J-up<-{K2GgV%K0RI0}d<#?gr8Rg`;b-0vic* zt^@N^_+kL1O+=c>PQ_9(9zU(B9|N5^z;3d8aQk%b~MIud7=i2PPW)`-Y^BC=gX9v6`%B63ufI-{zU6krap1<;)GCNL!jqaApl z0mgP9d>wj009iVkmIy|AYg8bAZ7DiYw3qoa7n*XhDn`FsH2k zQg#~@k=4LVrUtwN905!;{@(^nR@FU)6|Ymt-anme z&kO;J;DB>GfQOBCjmmmpYJ$-M%melq?JgU!DFFP~Q0Ui2yRpF21&rSebQ(C<8*T3{ zV?)_(@K~endA~wc2Z7nRkD4M zBzEf>1kM<)-cRW@8P^f9dc6vq9mER^v)hP|?d#>5;Wj>{^?IkcxEbt!{1Rnsd zJXFS~^jt)-Ge`}%B-a7Bc>9A$z&_wUjELWj!#h0{N>v+=09iu*PdV1WYd>d?Fwv zG%5cuL;mJG2i$Kcv4JL5`efMmZ)>T0VB+P#ZFs=bcMJjM2_Ja3EU`&Hvd&uA$ z045c1Kqv+GPu~WMxgB^h0B~)j0*eDZA2i-`!W#zvjghi14(Pre_+qAg0Hh5&O}#2o ziEe}U`~p3Afx*8blDEs??Ey|R+vfnalifpWKzoD|emwC3sW~6P-wN#TFt$aGo=-f! zkAagzJi(lxx?Fp=S1rJ#5Dy#(Z;$q>*Q@GK9-k+B2)FyB>6m)xoI}Tx%^NjciD+zzvbIuM6n@A}}s@7r-qbYXbm(Dlmjkz|Pw} zl;?!R_+#k3MCy3boJgmC{Sa0C=c|R2XUZCd{xTB!#skrdn z$Eg8d&vU>F2GcSkA~-{d3AiJzWsu@ddiBGQmFusUb-)o#$nV5HhW{qd0li-19YuTI z!<*K?xJG3MIZtXLfX04~Vc=QUp0tZ;8QzTJfJ>nM>6NXdW4!`&EiKr1e6 zOgDa?YEohofL{=Ip)r6<%oZN@hTIlo1a8Tj0?!%>MV$a66`BOx2&}-JGipiMihEaZ z0|kz~5%T66_WUi~#!rji4E&sfsRrE3f6oQu60g3Zsyiq_^>>4+y00XT0H@(X_gzW# zMYxgV2SAHe^_K^Jc^(^&yF7@Qqcz;6#8<28u_k)>RA4FbdB<(Qk-;gDfL=ned%;0q z(FhoSHF0V7beW&Q34qUQ0(`+CG~T-KXf#@XW@6+zx*E6}_?q|kXk6shB_hj`*J&16>A0v77oxHb*kaUWR%j9F1fBuD z9D#W$@JJv9{%xbKHv%(EB^RYRWEZ79xE-P;X>&UHx5T0HFwl`;v;aTBt@wVsQf~-K zeJ~*+?YNI3zF_?QQBqFESzv1@e~8N}or3)`eU+-tDy(>#D%hGXqxhS` zM@VI869fNU5MwVetF-N&PEETXq~5vV~9oX(aVN@PK*L97tEWc zUW=G@;b!*3M4qY3A9w^+@GffI%Bp6j64uhj79-^|q3yrDn%}0gd1YnklRQH$S<}^X z|64jv0z&mB73V#VHeThn7GD3cQGHJM)McVE4_Uc=KGr^YCUZXY>o;#F?mLOIQc5%u z+dPFiD-H;W|30f|XPD=3;V{?tzqZEdo_Vt~R;==!B&zqDy?MP@+?tJk*L75O9;{Ap elK)dbV;k?Mj-N4#f0qMY&fw|l=d#Wzp$Pygd2JQ| literal 0 HcmV?d00001 diff --git a/resources/img/reply-32.png b/resources/img/reply-32.png new file mode 100644 index 0000000000000000000000000000000000000000..193deacfb988ca7da2d88811ee8fd6616922c4d1 GIT binary patch literal 432 zcmV;h0Z;ykP)LBxownu3nX=b;}DH5un+Vi#OxKgxBZ?QLZFqp z@~6}|R=oiiz}ny)sHzXl15Mys(q8&R(IYqjo+Fqq09(Kzun2qtJGL)@KSQ`2E$z?% z9>U&TV0zfsFeXGZwfANwKv-BxBLZgWzZC|Kazkjl4V;GQ2CzC}4w8L8fX6Uh0hlRR z+?I6PANno^K+sn>st_PRoJTay)MWn;l%%A+zd))HRV7L5z&3DZyH`2Dp#EhBYJeKx ahrR(F>X`>a$?AFl0000^6 zZ_$_&m7#35GKa-mRA+IRSyJX(aSt64?%XK}!rEIme2VU9O~_m;@X_Ca^W~EsL%~H) z(hI8<-<-`W`*yDS`=-waM64%E*S+6ty?b)(WS7}9Kk+^A6?!0hV7AHu>jrbrhH{4d zNeu59?x`@|W8CA(w1>IEcnX8@Y{Shn16w~X*v4SIo4urQ-ORhPu}chM8mVgW&a#0q>b-8vD}JGm>nZsI{x z{RH71Pxkh;XRg(&i)Uot`y+@kS7Tp*Lv+{lmQ>dFNom~&<{tQ4BwfK&v!Z^EoW>!x^4e~;s+_wtY25X^pH~OVAPS#FVdQ&MBb@07w`^00000 literal 0 HcmV?d00001 diff --git a/resources/img/settings-16.png b/resources/img/settings-16.png new file mode 100644 index 0000000000000000000000000000000000000000..bb7581d506458a18599594975402ef774d06ca03 GIT binary patch literal 421 zcmV;W0b2fvP)~3* zK|~DW5I@ku9BP?))>i;CI4WIX48L)TA^druwuRS~%={J+bxdLxYq&ra?{VHopdmB= z5sTQz0hYURukf-nmYH-1dJ$X5!cU#KN<=)zI>s^5W7H~^Fk3unM8p-E_*g-^>~m4r zjfv9vya;45ehSt9UEvEZ3qRlU03UFI?|9ooAW$!i&Rn2|uXu;2*uWb%x6lul*o*RY zJ9t)%Y~mNL2MDw=AEl^)pBOF53z_-Q1>Qkqrihrv3a+p{=>G2sJO+OOP)yR=mcdHVa5gi?GU>cEq7RS+3w{In07yO-4;KlxN zR{`Kr%;aES;~DJ1r+Mujo^1j^iU=vCL41X66)Fux#I;gBOyXhO!0)S6{x;r@h;P~_ zex(D|^LStpJ6CF?S&4s8W+34J9?1wF$B8_CEh5fj2A>T=6EWs)Od{zp2N71_5^YJAmNGUyyhid8SY)|0BJRZd{B)p4}y#4|% z=ke``m|c(nuUAmF8lP6WC(w((E54h<{)kw(j{j1$*R@(p(Y)GrH~V7i#SZdy91&wF zrN{8#DgzJUtRfNL#Ob^~f-QM`0zc&OG(MMuH<5q|-};y0pYU^Oevu;Ix>*G~@SXqb zJ%R1ZDnq=K(N5zJ#r3;$1t5Actf+)h971pHp3>$cibPy|f*HKn6jPM|@TB5~3jBzp zIEg>=+I94G13>#x`Z6}<(>@ou*Qb;&;7e@Bdnu*K<&?xSYzm)5M61ofNBN|eD>SW= zr3oJ{GjJpS4y2U&BVsDgcPmN^ZuxJMS&RLqsE&mk7*uquP?XZEIIMVhMBcEuYe|?v zoa#VT3kSPe1tk!}iY^uk?I$$!{O;tHFQWYXo{xyRhBWVJwNJA9Ve9_y_#YNnS=U7p R4GjPQ002ovPDHLkV1lC5YoGuC literal 0 HcmV?d00001 diff --git a/resources/img/settings-64.png b/resources/img/settings-64.png new file mode 100644 index 0000000000000000000000000000000000000000..347d454ca9f7c20728838a6404877bf507b3d2e1 GIT binary patch literal 1489 zcmV;?1upuDP)`A~XgeL@}<6@gqi~)QwSeff_$H zMi%PE7=xlFd;~xE(kL5r!{=1>b=LzCX~yjR^JBwrEAaGs%FG&Vz;(qRwgbNM6jm=H z7gykE@syc06c`PfT?#*sNoosm=YVFQ$r^hAc*CVB$|)UbXPN;Dz>BK7uQ=fQ8C(Wz zw1$rX3srTO#XAMqgh{Az>;gJHCS6M$RMo!tfUg540zE*h^=Jrq4^syWV9p3DfXf|X zma6J!$>*FgQdEM=NuPmtg#PZ^~!mfB-L({7lddw1kef z)0V1UhFdU6t(4)1foFjF(1w;GuOwIsB60<=0Q1eCf+;z713Oi9xJudcfqOB|o*4}v z#GEtMscLsM@`Fd9%<5_ZKBe;Z+62rBvIR0Uz8&aGV0@7d0qw!IKrJC6t(b;tqvxxC z1J`S;%1eSgw#C;_+W5l}xiAfQ*IQfY5(lKcK2ZV@9k9yhZqiMVy9!rZOw0 z85s7E_ZQX*i3>X~QW)CkIPFfbZMr>W{y}sIR2_R37{+vT^Mn%#c#e6>oPi0pf#uBy zf&T~}`63MflgdPY5^&U0=EkyhfQZZlW_xll{)z@6iBJu*}P!`92Od0ON z9^g$?J-KQ^fBLM5K8iaLt^n#{e07+vk}Cl|?4vVRm7s`B!gQQ2b3E?FoIC|{Fmmzi z2WG44U}B}AqwZJ*9r%6PiZl!?^3|8SQdm{DVU~Bn>w<|@3L_eX&D zfvKlqMN9?eL{u|D(v}WyuEXY*|bpT(G{Cd2Iy}*sCI^g+UP>lHsh)4tQ zA#US`F?aoaj%Q%1N4qM^Kdhzy67GHeD$o$)n-6^F;2k8V{r^Nc0QkjW)Kew$L_O}K z=+mK%O636HQim};z_^kxo4bHop8m3=0ZePHv6KVa9WT98)_dmv1$k?tRHC(U8{$cD z0;gigF&4*2L}wDNj{k@|;E;p)h6=*w0TZj8(P_pv@gJjgK-3XFh-n$cu|!orx1~h< zTQIHcIC?Rc6nR4d*a56k)sw9@Yfrnn!bdUp`V!;22w3ajEdXtyGrJGjf0S$ie#Z3L zZ0y4vN+x3(5|bS~>s57m+7JbSBeQpsF1z+q#Vu{{I>2(mYER>j05^p;re+R^obVW? zc&KE27jRW*L()>&rl^QC0#9P@-{!_1odCYZ{IPn2mJ^b5B<>1F9otJV2cG^j+2}ZO r)fNwd+;xRdH_kaA^aMiVtarfwH_=Tv5PQ|!00000NkvXXu0mjfGS|6a literal 0 HcmV?d00001 diff --git a/resources/img/trash-16.png b/resources/img/trash-16.png new file mode 100644 index 0000000000000000000000000000000000000000..43e1f7fee82f83c47d98c64decbdd3c8063a9868 GIT binary patch literal 390 zcmV;10eSw3P)@wb`yx|FqUDvm1Ao99aG$MpCu86R7}a_J-1se)OVt^3<~sRKLgZfu z+WrcKnO(RR&a|Cb9J~V10|cD9T>^KmrTfmD={98m&^c0n+Dq&|2+#_fOR5eh=o6TQ zHB?R9P)%k%fFzmO(jg?>hTv)40Tv1$D3$>54O|^5q3_I2_6Fq{SW_R_sB7RUp0WM! kY-T;+vLS;4ManZK@rxo4-h1hn{Qx1a!yX-CF+9+K7e>he1lvf=3p*K z4qjCPLM}q02BJpX@-Q{hHr+eZGb0j6K~rqkzi+C(-fmGbPjnSKMG zRCPNkp{kZzKnvIav|z0RtCdWq85reXV8`<<6ve$XEoRBO(J;D_d;44gi2*FX^C&Oa_WgipZe1?Z%!r0swdi?1{*{Q+yuS zGg}NmQU_eLJ`bim9owq~pkD`4Vh&U=8>RY&v_JwzuZ_=CK!+GgvHkjUJO-qsuj};uXvBtIC@&A43xy z2+kkk8~`{mJz4|6i0RWR0KS=?6Opmh$|5ojytJQRqJH_SIFMkP>>bx4WAKke3{S~U z!4>daRd<431tfZ7#jC!4&wvTNyz+R7IS9qdHm}t}Oi0lI-=QdT<Dxr8kKnJ=7d*gG;ChYvHc@*9h{!bX#JIEs zJSIx(OclT^u+NbzFs-V8R(3Lh5PyMFhU^dEgsT2kM1hDL0PX;DNpY;ZRsur20!Iwl z#iGFjsOo#*qH*bHX|GZcf`HEr+3ON|UKz44O6bW%R|%$F5Rn<+3h>7_$(oM1B@ol7l31_3Yx)h^w$R;fXT%9s;YiW zYn)jL#tk`;z|&yY7bxgNQ)oN`J^&j9oJhib zA{y(lbh{Ikc~8@=12#xZd;7kHk^@fNDa1wY5{E~6|k3{2w-vGB%^+t{_m1c#Y#6n3CSp*IyL~m7f+}Cg& z5Rtd^uVGqL)k8iy%1mpyEN`X0fOiJeLBLk}3$#MOurN&PEET=&h%VV~9oX-OKB}S^`B_KHM+d zzgCR({{qM64uz(>54eA@ud&ii>+UflPDYerC>??JJC&ZvbPjQnAql| zrP(iN@kx~aeDqI~^_>ShTh54{{c<2Yejm%)M}ns<4r@;1+J5cI^=JtNWP@y|RWA%5)A_)!U!=6OWa3ba%Tg}*|9=twC22}CI&L4ryr#PcK~ zB7W4Okq|`Wy`$K3-=4j@>+Q;A6+6i!v**m6`R?2~b9N)8WRfy7(d<}VUxmaYNZDwq7@Ohv$jXb zRmSY*D!OzHe&}@R6*@BPc@`II zucI|m>=6M@m&uEZYZ)M~l@Mo|1endGJzu*ydjRtK+3Y*l7{CyyfdJ#`e>DlG08@Y| zz!YFySsY@IC4GO*#*i1dgP(Z)r$PFD5BzJHcn|yn?X6@UmO5cF00000NkvXXu0mjf Dma6|+ literal 0 HcmV?d00001 diff --git a/resources/img/upload-64.png b/resources/img/upload-64.png new file mode 100644 index 0000000000000000000000000000000000000000..65629dfb4ae1193db0e6150f47a5887cc9c27d32 GIT binary patch literal 1006 zcmVXdZ>uB0FMYdPJn|V(xIw-89ic&!{^E=v;w|3 ze7jTA$%7+#fMr0hh_q+%44}o~`|P|jq@kz*#wczm&H`0sG%CA*QAgYvn*jEg*$f>6 z{uIQW2I^hcJKJFZ5xE0&I);XTcg(iK!@xCFeeMi28?P03$p16%!UPbx97JRra1L0P z=iW@v5*MOhRok3_B96m;67SUI7qF#_nNL&+KrQe+W~?coIW$&8IUT0BoWksQNQ_q8 z%PGpnfjuEH4>na^+b# zQu1k}aKE#*$TnqVE)0!tIl{7sKOtN3QTTe5 z)^Wj~2zdd#0M-Po;RUcJU=1&TH34gQ0jvpF!wX + + diff --git a/resources/svg/circle.svg b/resources/svg/circle.svg new file mode 100644 index 0000000..84f078d --- /dev/null +++ b/resources/svg/circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/clipboarddata.svg b/resources/svg/clipboarddata.svg new file mode 100644 index 0000000..aebd8f5 --- /dev/null +++ b/resources/svg/clipboarddata.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/download.svg b/resources/svg/download.svg new file mode 100644 index 0000000..595f3a5 --- /dev/null +++ b/resources/svg/download.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/svg/eye.svg b/resources/svg/eye.svg new file mode 100644 index 0000000..2f1f62f --- /dev/null +++ b/resources/svg/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/svg/flag.svg b/resources/svg/flag.svg new file mode 100644 index 0000000..ef18dc3 --- /dev/null +++ b/resources/svg/flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/gear.svg b/resources/svg/gear.svg new file mode 100644 index 0000000..3827d6a --- /dev/null +++ b/resources/svg/gear.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/resources/svg/reply.svg b/resources/svg/reply.svg new file mode 100644 index 0000000..fdf9414 --- /dev/null +++ b/resources/svg/reply.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/svg/settings.svg b/resources/svg/settings.svg new file mode 100644 index 0000000..ce5be79 --- /dev/null +++ b/resources/svg/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/trash.svg b/resources/svg/trash.svg new file mode 100644 index 0000000..c236be7 --- /dev/null +++ b/resources/svg/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/svg/upload.svg b/resources/svg/upload.svg new file mode 100644 index 0000000..09e7ce4 --- /dev/null +++ b/resources/svg/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/svg2img.ps1 b/resources/svg2img.ps1 new file mode 100644 index 0000000..c71f0ff --- /dev/null +++ b/resources/svg2img.ps1 @@ -0,0 +1,28 @@ +$svgDir = "./svg" +$outDir = "./img" +$sizes = @(16, 32, 64) + +# Ensure output directory exists +if (!(Test-Path -Path $outDir)) { + New-Item -ItemType Directory -Path $outDir | Out-Null +} + +# Check for Inkscape +if (-not (Get-Command "inkscape" -ErrorAction SilentlyContinue)) { + Write-Error "Inkscape CLI is not installed or not in PATH. Please install it from https://inkscape.org/" + exit 1 +} + +# Process SVGs +Get-ChildItem -Path $svgDir -Filter *.svg | ForEach-Object { + $svgPath = $_.FullName + $baseName = $_.BaseName + + foreach ($size in $sizes) { + $outFile = Join-Path $outDir "$baseName-$size.png" + Write-Host "Converting $($_.Name) to $outFile ($size x $size)..." + & inkscape "$svgPath" --export-type=png --export-filename="$outFile" --export-width=$size --export-height=$size + } +} + +Write-Host "Conversion complete." From 58a2891847eaaf7991ba72ef8cdd3aa3e2895a30 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 7 Jul 2025 23:55:38 -0500 Subject: [PATCH 21/80] Update ai-filter.sln --- ai-filter.sln | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/ai-filter.sln b/ai-filter.sln index 7818aed..f41f23f 100644 --- a/ai-filter.sln +++ b/ai-filter.sln @@ -53,7 +53,29 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}" ProjectSection(SolutionItems) = preProject + resources\img\average-16.png = resources\img\average-16.png + resources\img\average-32.png = resources\img\average-32.png + resources\img\average-64.png = resources\img\average-64.png + resources\img\circle-16.png = resources\img\circle-16.png + resources\img\circle-32.png = resources\img\circle-32.png + resources\img\circle-64.png = resources\img\circle-64.png + resources\img\clipboarddata-16.png = resources\img\clipboarddata-16.png + resources\img\clipboarddata-32.png = resources\img\clipboarddata-32.png + resources\img\clipboarddata-64.png = resources\img\clipboarddata-64.png + resources\img\download-16.png = resources\img\download-16.png + resources\img\download-32.png = resources\img\download-32.png + resources\img\download-64.png = resources\img\download-64.png + resources\img\eye-16.png = resources\img\eye-16.png + resources\img\eye-32.png = resources\img\eye-32.png + resources\img\eye-64.png = resources\img\eye-64.png + resources\img\flag-16.png = resources\img\flag-16.png + resources\img\flag-32.png = resources\img\flag-32.png + resources\img\flag-64.png = resources\img\flag-64.png + resources\img\full-logo-white.png = resources\img\full-logo-white.png resources\img\full-logo.png = resources\img\full-logo.png + resources\img\gear-16.png = resources\img\gear-16.png + resources\img\gear-32.png = resources\img\gear-32.png + resources\img\gear-64.png = resources\img\gear-64.png resources\img\logo.png = resources\img\logo.png resources\img\logo128.png = resources\img\logo128.png resources\img\logo16.png = resources\img\logo16.png @@ -61,6 +83,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175 resources\img\logo48.png = resources\img\logo48.png resources\img\logo64.png = resources\img\logo64.png resources\img\logo96.png = resources\img\logo96.png + resources\img\reply-16.png = resources\img\reply-16.png + resources\img\reply-32.png = resources\img\reply-32.png + resources\img\reply-64.png = resources\img\reply-64.png + resources\img\settings-16.png = resources\img\settings-16.png + resources\img\settings-32.png = resources\img\settings-32.png + resources\img\settings-64.png = resources\img\settings-64.png + resources\img\trash-16.png = resources\img\trash-16.png + resources\img\trash-32.png = resources\img\trash-32.png + resources\img\trash-64.png = resources\img\trash-64.png + resources\img\upload-16.png = resources\img\upload-16.png + resources\img\upload-32.png = resources\img\upload-32.png + resources\img\upload-64.png = resources\img\upload-64.png EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}" From 6c352e904e146fcf3f619d093f6905ac041aaf0e Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Tue, 8 Jul 2025 00:24:43 -0500 Subject: [PATCH 22/80] more images --- resources/img/check-16.png | Bin 0 -> 300 bytes resources/img/check-32.png | Bin 0 -> 450 bytes resources/img/check-64.png | Bin 0 -> 927 bytes resources/img/circledots-16.png | Bin 0 -> 394 bytes resources/img/circledots-32.png | Bin 0 -> 773 bytes resources/img/circledots-64.png | Bin 0 -> 1656 bytes resources/img/x-16.png | Bin 0 -> 227 bytes resources/img/x-32.png | Bin 0 -> 331 bytes resources/img/x-64.png | Bin 0 -> 655 bytes resources/svg/check.svg | 3 +++ resources/svg/circledots.svg | 4 ++++ resources/svg/x.svg | 3 +++ 12 files changed, 10 insertions(+) create mode 100644 resources/img/check-16.png create mode 100644 resources/img/check-32.png create mode 100644 resources/img/check-64.png create mode 100644 resources/img/circledots-16.png create mode 100644 resources/img/circledots-32.png create mode 100644 resources/img/circledots-64.png create mode 100644 resources/img/x-16.png create mode 100644 resources/img/x-32.png create mode 100644 resources/img/x-64.png create mode 100644 resources/svg/check.svg create mode 100644 resources/svg/circledots.svg create mode 100644 resources/svg/x.svg diff --git a/resources/img/check-16.png b/resources/img/check-16.png new file mode 100644 index 0000000000000000000000000000000000000000..2a829805b3a1cd134853c1ad26ec809c1048a6c7 GIT binary patch literal 300 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#X#yh2s3WX6WN&PEETXs4%(V~9oX)yuY7PJsff5At0u zbxbhPpS!G3_}zh9u1mz;Y-+f2v#B*Zfa6BDZxB~xW2!=e;`L{m&t86J=l*y|(nd_4)D+W|Fx{` zSH?7sJ;{yN;_}loIxIIa{S>XwkQdRfka^7AZ+Y#Y(A}Ww*4)xF29DDEU&x+)-f&-M s`=&$3Pd0cvRUeerTF&z5UwaF1JXNji0(O88K!ICe<=0Gw@DA{7@F!q1b*>`#DsX4;cVIhnwgUJPaBc7} zV1Md-$?z6%VQ>WwbN8f#w1K3cz*zu4joOnEa&7?wC;T{okAPNDf$$afBjG(eZw0P^ z#c1)Auw`Hzd_)I1@Z7GuKSE$FsL=%2&6tbox?8})H-JYAfBo5IPuB|x7-S2037)WH z_g!5tEMTy1aK;b7y0_~y37Etl@M*v|Z`WrQFo}Kuc6GfJ0h1UQ{L<6)X_r`V^b(N- sxL4JQYhzZPSD@_mfBuRafj=wo4O$HW=K^IzoB#j-07*qoM6N<$g5e&#HUIzs literal 0 HcmV?d00001 diff --git a/resources/img/check-64.png b/resources/img/check-64.png new file mode 100644 index 0000000000000000000000000000000000000000..175c4a276691fe7854413dbf6b0ec13e843c6a3d GIT binary patch literal 927 zcmV;Q17Q4#P)7@5gh`@7;4nRoTpH zvNm9?9Dua}Yvll}4OlA&U~Rx!IRI+|*2)1`8?aUmz}i645|M(442Vd3RUK5dVJ_f0 zpzitsbXMxqFe^YrhJdSU{01%Nr;8v`@Sw9dFkpl1%*jCc?3plK*)8+bS zdIg9`2Qbe1@Csb06dp1RKt$SrXTa`=<`GpLt7;(Yojc}kzXh0Nb;na~?3W&%cFdES`3v{dMmzu}S3IGx50UiM@B~1$y%iT^n9G2c9}F9`4|OmzZF5vKkCCld@`02U+WSptp*TX9vI>ve%k%`F4R z6A0e{eDr6MngBSn!ycnw<2<9JRO=H1 z;7t2U)JI@X4dW#GUY{la0q%N?zXNvG2{P7ueQE%l`DWmuC%{yt0KlN9Z;)D_H~<%7 zEAYw_;;2<0XzIpz`A3WjjScX?k_*7U#x`7w0|5O#Fr@$#y zT}jM93?+Ibrm9oGS&DCT<@5$PV;vp>oC&JxW8fmiB_5?&pjTBFlb$<{f*gvf>OJ5} zMAHIrLSuZu2&SMrEL>yt4RQfEV!i1F-2|f~B8Pwjz;jid3p!a0W#-dZC~fEuBWWlH zU~Rx!IRI+|*2)1`8?aUmz}kSdasbu_thMO@`~w5OS?n{{`V0U7002ovPDHLkV1mW! Bk2wGU literal 0 HcmV?d00001 diff --git a/resources/img/circledots-16.png b/resources/img/circledots-16.png new file mode 100644 index 0000000000000000000000000000000000000000..1443d226c15d61a0f9fa7c9a5e055a1d468258b0 GIT binary patch literal 394 zcmV;50d@X~P);L)DsdN}nXE&|t}_FxN( zl3MDS>i?^{|F04e#ziS*0iFDOj)<4B2P5KVpnU=$~Snxa$TX-VJFXYn@*a9Bhonm!KEi-^~TanH~s zohhoX7|OWaHqw=$NhXpc_z@RsBV}?nNj?m9UaxW+IL*qJ+E#82(=P7vk(yx|;1)Yt o!_;X1D@rNz*)lvu#M`)Y-~5wsLN!KCO#lD@07*qoM6N<$f}a1N?*IS* literal 0 HcmV?d00001 diff --git a/resources/img/circledots-32.png b/resources/img/circledots-32.png new file mode 100644 index 0000000000000000000000000000000000000000..e8dfa44211444bbbf9660f7e39b74fc6ac5ce183 GIT binary patch literal 773 zcmV+g1N!`lP)@(^qejM*MXzJF15ELx*HNDZSw-Cq#^(u0lp|F?|@TDLMrw(;Jvc{3=AhC z08~8iM?fJdxbkCwyFgo!*b<4*nMYx71NF4P0V0!%M8nSsfAcl<<@f#$9oTw%NwjKxS-05Hbv15V;( zj={+jnQL;TI|9H7;&YPf@`1hYRPn8*uN!H#={7!;*4BXu|IAYnP(;eWH1L}Kzh-}d zSHP4mwSL10Kw^>ZX$&|>_s{7j?&tJRDt~DS@?3cCbFv#5B5g)C845R3$b5Hv=w%93n+kgU;zcP|J;jRzwE z)WnMsh2Q~MB^m@oq8Cw&2$2XV@!*4(;r*VO{XA3;+f!X#(=$`uy%WBqveUIyU;V#- zRekkVBEroqEqMdTTXH2pDb=Br8k;MIw7Y;(>ULlrFb9|dbORHC@kY|Kz;WOZa2VJF zd?zBOQ^l7C0ZOU6fu+C_Uk+eYz7_& zS{op_Ezq$!&>2_WRp5ox2>=xEx&z2iP}`Gz^iQMgDZs};8FA&WN{xWP`1c%z{{lSZ z+i(kxhk^YL9P82|;0;Hi@4NbT3M7mI-gLBvm0k%zd-=G&_f%GfTX6L`e9?dx0)SaD zg`5W#=4E^c-y-yIYO9>yh6n)0VIS(Qjs)Wk5^R<9JJ8t(0c&DBeQwR(fweEDE4*D7 z0l;*iU{Sa_Z(|*p-?88=0Qc8Jz}FUK`?B=*br8@F9I)WtUK0VpY>Sc-8I1EDNSGTl zJX*2GDf!Hz;KRI)tAT`#7W{*@w2{@IQmPC1o61#L3794#M`!|3N_7JVsoaPu0{4o@ z@o-w(PE9}2CFx+Z7!M#KhtL}{VQE8;6cu74V2LH}0}r^e78~ZL&4`LF5Nu$cqLP^7 z=%!QjjWA77N{s^k0qzV%mw-D&Zwy*qM9Z^}61yCfo)Z+v-jf?JJzf?iR^IknW% z*Xjh#sqk;;^ZT+Cfw3ZT-9>RQO!Wf`gQ#7=hAP8aYUxAy+JQ?{4uc0pr02$$!PX#A zrl(e8d87uQ$70WxxKRM~%?wj))8l3l8}>ut50e1f)Nz3&u32QEC2mjn!}I~S0sm1+ z%n4wUG}(&Ku^gTtBIn8x6)iwS&Ym4@AnFqPctagx-HD(RED$m}sVrv;Yw~0eophw*aryRS)^da-yw!s~9`N zKNI z+fWB)+ZCPyjyr|b6t1=?6re9}V=Y+wW3ayE%vVKeCwfoNRC*0ql(*3#oQttvkT?u< z)J;I3ycc~V#8iG^B#;mof6;>P3XXdLi3tb_8Hj0K*MOxt8s8sN^8zeOq@#wyR>c$~ z=xeRFW@vaj_Uk+4@M>aRH5RHx6PQTPzB0q9P_@M-9W z6RxYWiEF-Y0s=4tF^;huO2DVU6F^%7MvnrX1h(M5EPW+upZ7vQ0InB(4=)Z>Ih+hy z#WM63RqRYM=-WL(+KoZpYWfe8z}v@#MjuRc0xN)*N&e8Ggx;t)#qhyP2YT&(56K^1 z6o9qB3K2BTEVA*IWp#92BpP18nHrIu| zkhBE-v?gh@Hl+mYMepQn5|NXB5DrTg4=2Z?ucgdEpUrmzccC9nsvw!5A5Q*@c4|j} z?}2Z{*u(RVFKq&xg$K`OMdVU$81f`wB*;zwdi;NiiK$R6J&G>?0000N&PEETsKC?3F~p+x>SS-e1_d6M`xTzT z268>KUoFeKz`$shs_bdLey*pzV_MtuT)nSF%$}Y>GNRsGlY&^!zTsA2GB227^So~k zx9EG3cL!MK7@L`7uo{27upo1%^(Es S(sBuCH-o3EpUXO@geCxx1x&*L literal 0 HcmV?d00001 diff --git a/resources/img/x-32.png b/resources/img/x-32.png new file mode 100644 index 0000000000000000000000000000000000000000..785d7befb5343bc41d835d7516845ad133dbdd3b GIT binary patch literal 331 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?4`^&rfc{H*UVP*AeO zHKHUqKdq!Zu_%?Hyu4g5GcUV1Ik6yBFTW^#_B$IXprRX|E{-7)t#79}ay1(WxZd}8 zKRI{7ju)Y}NlS10nFnheJ(8kyNKyg zV3`*}dV7}cJ}6hQ@qpX`?)5?*jZqJ-GIh$ns|vouBrTM;W7RJEqyEhMvzhePEty}% z^5ss8Wk9!!@t)8}0u|jy5)0n@=&gAFeul7&N0X<@TW*y_sVyP>5y{)F-mk3S_|toU Zn}NyWzp!@RC!il0JYD@<);T3K0RZc!e%}B9 literal 0 HcmV?d00001 diff --git a/resources/img/x-64.png b/resources/img/x-64.png new file mode 100644 index 0000000000000000000000000000000000000000..37dce1ab667d77e328cbca34ef17ff6b429a6c16 GIT binary patch literal 655 zcmV;A0&x9_P)W z^#LFA(79r+0f&~&6j(av#u4)c$=6u=n!gpkq$B7LfhYdbPkttF{K$* + + diff --git a/resources/svg/circledots.svg b/resources/svg/circledots.svg new file mode 100644 index 0000000..6275a98 --- /dev/null +++ b/resources/svg/circledots.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/svg/x.svg b/resources/svg/x.svg new file mode 100644 index 0000000..b209221 --- /dev/null +++ b/resources/svg/x.svg @@ -0,0 +1,3 @@ + + + From 9a4b1ce2390974bcebd891131b048a795ed5efb8 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Tue, 8 Jul 2025 00:40:40 -0500 Subject: [PATCH 23/80] Update icons and options UI --- AGENTS.md | 7 +++++ README.md | 2 ++ background.js | 61 +++++++++++++++++++++++++++++++++++++++----- manifest.json | 2 +- options/options.html | 50 ++++++++++++++++++++++++++++-------- 5 files changed, 103 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab79c66..c51f40c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,3 +66,10 @@ text extracted from all text parts. properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first time the add-on loads after an update. +### Icon Set Usage + +Toolbar and menu icons reside under `resources/img` and are provided in 16, 32 +and 64 pixel variants. When changing these icons, pass a dictionary mapping the +sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`. +Use `resources/svg/svg2img.ps1` to regenerate PNGs from the SVG sources. + diff --git a/README.md b/README.md index eff53b1..bdff14a 100644 --- a/README.md +++ b/README.md @@ -141,3 +141,5 @@ Sortana builds upon knowledge gained from open-source projects. In particular, how Thunderbird's WebExtension and experiment APIs can be extended. Their code provided invaluable guidance during development. +- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license. + diff --git a/background.js b/background.js index 59bac43..2916c06 100644 --- a/background.js +++ b/background.js @@ -28,6 +28,25 @@ let altTextImages = false; let collapseWhitespace = false; let TurndownService = null; +const ICONS = { + logo: "resources/img/logo.png", + circledots: { + 16: "resources/img/circledots-16.png", + 32: "resources/img/circledots-32.png", + 64: "resources/img/circledots-64.png" + }, + circle: { + 16: "resources/img/circle-16.png", + 32: "resources/img/circle-32.png", + 64: "resources/img/circle-64.png" + }, + average: { + 16: "resources/img/average-16.png", + 32: "resources/img/average-32.png", + 64: "resources/img/average-64.png" + } +}; + function setIcon(path) { if (browser.browserAction) { browser.browserAction.setIcon({ path }); @@ -38,9 +57,9 @@ function setIcon(path) { } function updateActionIcon() { - let path = "resources/img/brain.png"; + let path = ICONS.logo; if (processing || queuedCount > 0) { - path = "resources/img/busy.png"; + path = ICONS.circledots; } setIcon(path); } @@ -201,7 +220,7 @@ async function applyAiRules(idsInput) { t.mean += delta / t.count; t.m2 += delta * (elapsed - t.mean); await storage.local.set({ classifyStats: t }); - showTransientIcon("resources/img/done.png"); + showTransientIcon(ICONS.circle); } catch (e) { processing = false; const elapsed = Date.now() - currentStart; @@ -215,7 +234,7 @@ async function applyAiRules(idsInput) { t.m2 += delta * (elapsed - t.mean); await storage.local.set({ classifyStats: t }); logger.aiLog("failed to apply AI rules", { level: 'error' }, e); - showTransientIcon("resources/img/error.png"); + showTransientIcon(ICONS.average); } }); } @@ -250,7 +269,7 @@ async function clearCacheForMessages(idsInput) { } if (keys.length) { await AiClassifier.removeCacheEntries(keys); - showTransientIcon("resources/img/done.png"); + showTransientIcon(ICONS.circle); } } @@ -340,33 +359,61 @@ async function clearCacheForMessages(idsInput) { id: "apply-ai-rules-list", title: "Apply AI Rules", contexts: ["message_list"], + icons: { + 16: "resources/img/eye-16.png", + 32: "resources/img/eye-32.png", + 64: "resources/img/eye-64.png" + } }); browser.menus.create({ id: "apply-ai-rules-display", title: "Apply AI Rules", contexts: ["message_display_action"], + icons: { + 16: "resources/img/eye-16.png", + 32: "resources/img/eye-32.png", + 64: "resources/img/eye-64.png" + } }); browser.menus.create({ id: "clear-ai-cache-list", title: "Clear AI Cache", contexts: ["message_list"], + icons: { + 16: "resources/img/trash-16.png", + 32: "resources/img/trash-32.png", + 64: "resources/img/trash-64.png" + } }); browser.menus.create({ id: "clear-ai-cache-display", title: "Clear AI Cache", contexts: ["message_display_action"], + icons: { + 16: "resources/img/trash-16.png", + 32: "resources/img/trash-32.png", + 64: "resources/img/trash-64.png" + } }); browser.menus.create({ id: "view-ai-reason-list", title: "View Reasoning", contexts: ["message_list"], - icons: { "16": "resources/img/brain.png" } + icons: { + 16: "resources/img/clipboarddata-16.png", + 32: "resources/img/clipboarddata-32.png", + 64: "resources/img/clipboarddata-64.png" + } }); browser.menus.create({ id: "view-ai-reason-display", title: "View Reasoning", contexts: ["message_display_action"], - icons: { "16": "resources/img/brain.png" } + icons: { + 16: "resources/img/clipboarddata-16.png", + 32: "resources/img/clipboarddata-32.png", + 64: "resources/img/clipboarddata-64.png" + } }); browser.menus.onClicked.addListener(async (info, tab) => { diff --git a/manifest.json b/manifest.json index 36b94b0..d44fd1d 100644 --- a/manifest.json +++ b/manifest.json @@ -22,7 +22,7 @@ "default_icon": "resources/img/logo32.png" }, "message_display_action": { - "default_icon": "resources/img/brain.png", + "default_icon": "resources/img/logo.png", "default_title": "Details", "default_label": "Details", "default_popup": "details.html" diff --git a/options/options.html b/options/options.html index 186e2b6..0fc5cae 100644 --- a/options/options.html +++ b/options/options.html @@ -44,18 +44,25 @@
- +
+

+ + Settings +

@@ -88,8 +95,14 @@
- - + +
+ diff --git a/options/options.js b/options/options.js index ebd7497..3a09d37 100644 --- a/options/options.js +++ b/options/options.js @@ -21,7 +21,9 @@ document.addEventListener('DOMContentLoaded', async () => { 'aiCache', 'theme', 'showDebugTab', - 'lastPayload' + 'lastPayload', + 'lastFullText', + 'lastPromptText' ]); const tabButtons = document.querySelectorAll('#main-tabs li'); const tabs = document.querySelectorAll('.tab-content'); @@ -67,9 +69,17 @@ document.addEventListener('DOMContentLoaded', async () => { await applyTheme(themeSelect.value); const payloadDisplay = document.getElementById('payload-display'); + const diffDisplay = document.getElementById('diff-display'); if (defaults.lastPayload) { payloadDisplay.textContent = JSON.stringify(defaults.lastPayload, null, 2); } + if (defaults.lastFullText && defaults.lastPromptText && diff_match_patch) { + const dmp = new diff_match_patch(); + dmp.Diff_EditCost = 4; + const diffs = dmp.diff_main(defaults.lastFullText, defaults.lastPromptText); + dmp.diff_cleanupEfficiency(diffs); + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + } themeSelect.addEventListener('change', async () => { markDirty(); await applyTheme(themeSelect.value); From 841a697c69a9204bcaf7c22c30be8fd5259837f5 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 19 Jul 2025 19:46:44 -0500 Subject: [PATCH 68/80] Update debug tab with live refresh --- README.md | 2 +- options/options.js | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 64fbf26..82649d0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ message meets a specified criterion. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. -- **Debug tab** – view the last request payload sent to the AI service. +- **Debug tab** – view the last request payload and message diff with live updates. - **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override. - **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range. - **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match. diff --git a/options/options.js b/options/options.js index 3a09d37..3402dcf 100644 --- a/options/options.js +++ b/options/options.js @@ -70,13 +70,18 @@ document.addEventListener('DOMContentLoaded', async () => { await applyTheme(themeSelect.value); const payloadDisplay = document.getElementById('payload-display'); const diffDisplay = document.getElementById('diff-display'); - if (defaults.lastPayload) { - payloadDisplay.textContent = JSON.stringify(defaults.lastPayload, null, 2); + + let lastFullText = defaults.lastFullText || ''; + let lastPromptText = defaults.lastPromptText || ''; + let lastPayload = defaults.lastPayload ? JSON.stringify(defaults.lastPayload, null, 2) : ''; + + if (lastPayload) { + payloadDisplay.textContent = lastPayload; } - if (defaults.lastFullText && defaults.lastPromptText && diff_match_patch) { + if (lastFullText && lastPromptText && diff_match_patch) { const dmp = new diff_match_patch(); dmp.Diff_EditCost = 4; - const diffs = dmp.diff_main(defaults.lastFullText, defaults.lastPromptText); + const diffs = dmp.diff_main(lastFullText, lastPromptText); dmp.diff_cleanupEfficiency(diffs); diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); } @@ -729,6 +734,30 @@ document.addEventListener('DOMContentLoaded', async () => { } catch { cacheCountEl.textContent = '?'; } + + try { + if (debugTabToggle.checked) { + const latest = await storage.local.get(['lastPayload', 'lastFullText', 'lastPromptText']); + const payloadStr = latest.lastPayload ? JSON.stringify(latest.lastPayload, null, 2) : ''; + if (payloadStr !== lastPayload) { + lastPayload = payloadStr; + payloadDisplay.textContent = payloadStr; + } + if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) { + lastFullText = latest.lastFullText || ''; + lastPromptText = latest.lastPromptText || ''; + if (lastFullText && lastPromptText && diff_match_patch) { + const dmp = new diff_match_patch(); + dmp.Diff_EditCost = 4; + const diffs = dmp.diff_main(lastFullText, lastPromptText); + dmp.diff_cleanupEfficiency(diffs); + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + } else { + diffDisplay.innerHTML = ''; + } + } + } + } catch {} } refreshMaintenance(); From bcac4ad01709c85002e2f8123406bacffcf1d4cd Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 19 Jul 2025 21:58:37 -0500 Subject: [PATCH 69/80] Improve debug tab layout --- options/options.html | 15 +++++++++------ options/options.js | 22 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/options/options.html b/options/options.html index 3618a20..ddc5ee0 100644 --- a/options/options.html +++ b/options/options.html @@ -154,6 +154,11 @@ Aggressive token reduction
+
+ +
@@ -220,11 +225,6 @@
-
- -
@@ -290,7 +290,10 @@ Debug

-                
+ diff --git a/options/options.js b/options/options.js index 3402dcf..d881d6a 100644 --- a/options/options.js +++ b/options/options.js @@ -70,6 +70,7 @@ document.addEventListener('DOMContentLoaded', async () => { await applyTheme(themeSelect.value); const payloadDisplay = document.getElementById('payload-display'); const diffDisplay = document.getElementById('diff-display'); + const diffContainer = document.getElementById('diff-container'); let lastFullText = defaults.lastFullText || ''; let lastPromptText = defaults.lastPromptText || ''; @@ -83,7 +84,16 @@ document.addEventListener('DOMContentLoaded', async () => { dmp.Diff_EditCost = 4; const diffs = dmp.diff_main(lastFullText, lastPromptText); dmp.diff_cleanupEfficiency(diffs); - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } + } else { + diffContainer.classList.add('is-hidden'); } themeSelect.addEventListener('change', async () => { markDirty(); @@ -751,9 +761,17 @@ document.addEventListener('DOMContentLoaded', async () => { dmp.Diff_EditCost = 4; const diffs = dmp.diff_main(lastFullText, lastPromptText); dmp.diff_cleanupEfficiency(diffs); - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } } else { diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); } } } From 9cad2674e3841fbc40d237f7233fbd6784f61760 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 19 Jul 2025 22:44:04 -0500 Subject: [PATCH 70/80] Capture raw message text for debug --- README.md | 2 +- background.js | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82649d0..57a0285 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ message meets a specified criterion. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. -- **Debug tab** – view the last request payload and message diff with live updates. +- **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt. - **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override. - **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range. - **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match. diff --git a/background.js b/background.js index 58a5de5..fc585ff 100644 --- a/background.js +++ b/background.js @@ -210,17 +210,38 @@ function collectText(part, bodyParts, attachments) { } } -function buildEmailText(full) { +function collectRawText(part, bodyParts, attachments) { + if (part.parts && part.parts.length) { + for (const p of part.parts) collectRawText(p, bodyParts, attachments); + return; + } + const ct = (part.contentType || "text/plain").toLowerCase(); + const cd = (part.headers?.["content-disposition"]?.[0] || "").toLowerCase(); + const body = String(part.body || ""); + if (cd.includes("attachment") || !ct.startsWith("text/")) { + const nameMatch = /filename\s*=\s*"?([^";]+)/i.exec(cd) || /name\s*=\s*"?([^";]+)/i.exec(part.headers?.["content-type"]?.[0] || ""); + const name = nameMatch ? nameMatch[1] : ""; + attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`); + } else if (ct.startsWith("text/html")) { + const doc = new DOMParser().parseFromString(body, 'text/html'); + bodyParts.push(doc.body.textContent || ""); + } else { + bodyParts.push(body); + } +} + +function buildEmailText(full, applyTransforms = true) { const bodyParts = []; const attachments = []; - collectText(full, bodyParts, attachments); + const collect = applyTransforms ? collectText : collectRawText; + collect(full, bodyParts, attachments); const headers = Object.entries(full.headers || {}) .map(([k, v]) => `${k}: ${v.join(' ')}`) .join('\n'); const attachInfo = `Attachments: ${attachments.length}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); - if (tokenReduction) { + if (applyTransforms && tokenReduction) { const seen = new Set(); combined = combined.split('\n').filter(l => { if (seen.has(l)) return false; @@ -228,7 +249,7 @@ function buildEmailText(full) { return true; }).join('\n'); } - return sanitizeString(combined); + return applyTransforms ? sanitizeString(combined) : combined; } function updateTimingStats(elapsed) { @@ -262,8 +283,8 @@ async function processMessage(id) { updateActionIcon(); try { const full = await messenger.messages.getFull(id); + const originalText = buildEmailText(full, false); let text = buildEmailText(full); - const originalText = text; if (tokenReduction && maxTokens > 0) { const limit = Math.floor(maxTokens * 0.9); if (text.length > limit) { From c622c07c66a6936c4c8d3bb039b1f0bba4692384 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 18 Aug 2025 19:39:35 -0500 Subject: [PATCH 71/80] Supporting v140+ After testing in Betterbird v140, updating manifest. --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index a3c9f7c..e7cb9d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,13 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.1.2", + "version": "2.2.0", "default_locale": "en-US", "applications": { "gecko": { "id": "ai-filter@jordanwages", "strict_min_version": "128.0", - "strict_max_version": "139.*" + "strict_max_version": "140.*" } }, "icons": { From 0f2f148b71913b89489cf72ab6bc0efc263c1064 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 20:45:31 -0600 Subject: [PATCH 72/80] Normalize completions endpoint base --- AGENTS.md | 5 ++++- README.md | 11 ++++++----- modules/AiClassifier.js | 41 +++++++++++++++++++++++++++++++++++++---- options/options.html | 1 + options/options.js | 18 ++++++++++++++++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 401f962..e2a1696 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ This file provides guidelines for codex agents contributing to the Sortana proje There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script. +## Endpoint Notes + +Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. + ## Documentation Additional documentation exists outside this repository. @@ -73,4 +77,3 @@ Toolbar and menu icons reside under `resources/img` and are provided in 16, 32 and 64 pixel variants. When changing these icons, pass a dictionary mapping the sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`. Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources. - diff --git a/README.md b/README.md index 57a0285..2f8f204 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. It allows you to classify email messages by sending their contents to a configurable -HTTP endpoint. The endpoint should respond with JSON indicating whether the -message meets a specified criterion. +HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base +URL and appends `/v1/completions` when sending requests. The endpoint should respond +with JSON indicating whether the message meets a specified criterion. ## Features -- **Configurable endpoint** – set the classification service URL on the options page. +- **Configurable endpoint** – set the classification service base URL on the options page. - **Prompt templates** – choose between several model formats or provide your own custom template. - **Custom system prompts** – tailor the instructions sent to the model for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. @@ -72,7 +73,8 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e ## Usage -1. Open the add-on's options and set the URL of your classification service. +1. Open the add-on's options and set the base URL of your classification service + (Sortana will append `/v1/completions`). 2. Use the **Classification Rules** section to add a criterion and optional actions such as tagging, moving, copying, forwarding, replying, deleting or archiving a message when it matches. Drag rules to @@ -158,4 +160,3 @@ how Thunderbird's WebExtension and experiment APIs can be extended. Their code provided invaluable guidance during development. - Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license. - diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index 8313654..f5a3bff 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -15,6 +15,8 @@ try { Services = undefined; } +const COMPLETIONS_PATH = "/v1/completions"; + const SYSTEM_PREFIX = `You are an email-classification assistant. Read the email below and the classification criterion provided by the user. `; @@ -28,7 +30,8 @@ Return ONLY a JSON object on a single line of the form: Do not add any other keys, text, or formatting.`; -let gEndpoint = "http://127.0.0.1:5000/v1/classify"; +let gEndpointBase = "http://127.0.0.1:5000"; +let gEndpoint = buildEndpointUrl(gEndpointBase); let gTemplateName = "openai"; let gCustomTemplate = ""; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; @@ -39,6 +42,28 @@ let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gCache = new Map(); let gCacheLoaded = false; +function normalizeEndpointBase(endpoint) { + if (typeof endpoint !== "string") { + return ""; + } + let base = endpoint.trim(); + if (!base) { + return ""; + } + base = base.replace(/\/v1\/completions\/?$/i, ""); + return base; +} + +function buildEndpointUrl(endpointBase) { + const base = normalizeEndpointBase(endpointBase); + if (!base) { + return ""; + } + const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; + const needsSlash = withScheme.endsWith("/"); + return `${withScheme}${needsSlash ? "" : "/"}v1/completions`; +} + function sha256HexSync(str) { try { const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); @@ -158,8 +183,12 @@ function loadTemplateSync(name) { } async function setConfig(config = {}) { - if (config.endpoint) { - gEndpoint = config.endpoint; + if (typeof config.endpoint === "string") { + const base = normalizeEndpointBase(config.endpoint); + if (base) { + gEndpointBase = base; + } + gEndpoint = buildEndpointUrl(gEndpointBase); } if (config.templateName) { gTemplateName = config.templateName; @@ -187,6 +216,10 @@ async function setConfig(config = {}) { } else { gTemplateText = await loadTemplate(gTemplateName); } + if (!gEndpoint) { + gEndpoint = buildEndpointUrl(gEndpointBase); + } + aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); } @@ -344,4 +377,4 @@ async function init() { await loadCache(); } -export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; +export { buildEndpointUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; diff --git a/options/options.html b/options/options.html index ddc5ee0..59ebc83 100644 --- a/options/options.html +++ b/options/options.html @@ -73,6 +73,7 @@
+

diff --git a/options/options.js b/options/options.js index d881d6a..30fa5f3 100644 --- a/options/options.js +++ b/options/options.js @@ -99,7 +99,21 @@ document.addEventListener('DOMContentLoaded', async () => { markDirty(); await applyTheme(themeSelect.value); }); - document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions'; + const endpointInput = document.getElementById('endpoint'); + const endpointPreview = document.getElementById('endpoint-preview'); + const fallbackEndpoint = 'http://127.0.0.1:5000'; + const storedEndpoint = defaults.endpoint || fallbackEndpoint; + const endpointBase = AiClassifier.normalizeEndpointBase(storedEndpoint) || storedEndpoint; + endpointInput.value = endpointBase; + + function updateEndpointPreview() { + const resolved = AiClassifier.buildEndpointUrl(endpointInput.value); + endpointPreview.textContent = resolved + ? `Resolved endpoint: ${resolved}` + : 'Resolved endpoint: (invalid)'; + } + endpointInput.addEventListener('input', updateEndpointPreview); + updateEndpointPreview(); const templates = { openai: browser.i18n.getMessage('template.openai'), @@ -806,7 +820,7 @@ document.addEventListener('DOMContentLoaded', async () => { initialized = true; document.getElementById('save').addEventListener('click', async () => { - const endpoint = document.getElementById('endpoint').value; + const endpoint = endpointInput.value.trim(); const templateName = templateSelect.value; const customTemplateText = customTemplate.value; const customSystemPrompt = systemBox.value; From 2178de9a90ba4ea8c846d839f138de183c0186a2 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 21:07:55 -0600 Subject: [PATCH 73/80] Add bash build script --- AGENTS.md | 1 + README.md | 7 +++-- build-xpi.sh | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100755 build-xpi.sh diff --git a/AGENTS.md b/AGENTS.md index e2a1696..23080ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `resources/`: Images and other static files. - `prompt_templates/`: Prompt template files for the AI service. - `build-xpi.ps1`: PowerShell script to package the extension. +- `build-xpi.sh`: Bash script to package the extension. ## Coding Style diff --git a/README.md b/README.md index 2f8f204..a58a799 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ with JSON indicating whether the message meets a specified criterion. - **View reasoning** – inspect why rules matched via the Details popup. - **Cache management** – clear cached results from the context menu or options page. - **Queue & timing stats** – monitor processing time on the Maintenance tab. -- **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation. +- **Packaging scripts** – `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation. - **Maintenance tab** – view rule counts, cache entries and clear cached results from the options page. ### Cache Storage @@ -65,8 +65,9 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e 1. Ensure PowerShell is available (for Windows) or adapt the script for other environments. 2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`. -3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads - the version from `manifest.json` and creates an XPI in the `release` folder. +3. Run `powershell ./build-xpi.ps1` or `./build-xpi.sh` from the repository root. + The script reads the version from `manifest.json` and creates an XPI in the + `release` folder. 4. Install the generated XPI in Thunderbird via the Add-ons Manager. During development you can also load the directory as a temporary add-on. 5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`. diff --git a/build-xpi.sh b/build-xpi.sh new file mode 100755 index 0000000..20c6e15 --- /dev/null +++ b/build-xpi.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +release_dir="$script_dir/release" +manifest="$script_dir/manifest.json" + +if [[ ! -f "$manifest" ]]; then + echo "manifest.json not found at $manifest" >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "zip is required to build the XPI." >&2 + exit 1 +fi + +if command -v jq >/dev/null 2>&1; then + version="$(jq -r '.version // empty' "$manifest")" +else + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to read manifest.json without jq." >&2 + exit 1 + fi + version="$(python3 - <<'PY' +import json +import sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get('version', '') or '') +PY +"$manifest")" +fi + +if [[ -z "$version" ]]; then + echo "No version found in manifest.json" >&2 + exit 1 +fi + +mkdir -p "$release_dir" + +xpi_name="sortana-$version.xpi" +zip_path="$release_dir/ai-filter-$version.zip" +xpi_path="$release_dir/$xpi_name" + +rm -f "$zip_path" "$xpi_path" + +mapfile -d '' files < <( + find "$script_dir" -type f \ + ! -name '*.sln' \ + ! -name '*.ps1' \ + ! -name '*.sh' \ + ! -path "$release_dir/*" \ + ! -path "$script_dir/.vs/*" \ + ! -path "$script_dir/.git/*" \ + -printf '%P\0' +) + +if [[ ${#files[@]} -eq 0 ]]; then + echo "No files found to package." >&2 + exit 0 +fi + +for rel in "${files[@]}"; do + full="$script_dir/$rel" + size=$(stat -c '%s' "$full") + echo "Zipping: $rel <- $full ($size bytes)" +done + +( + cd "$script_dir" + printf '%s\n' "${files[@]}" | zip -q -9 -@ "$zip_path" +) + +mv -f "$zip_path" "$xpi_path" + +echo "Built XPI at: $xpi_path" From af6702bceb57fa65fda3d47c218d42b40cc83bfe Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 21:42:57 -0600 Subject: [PATCH 74/80] Add prompt reduction badge to debug diff --- options/options.html | 5 ++- options/options.js | 88 +++++++++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/options/options.html b/options/options.html index 59ebc83..b118ee0 100644 --- a/options/options.html +++ b/options/options.html @@ -292,7 +292,10 @@

                 
             
diff --git a/options/options.js b/options/options.js index 30fa5f3..046c674 100644 --- a/options/options.js +++ b/options/options.js @@ -71,6 +71,7 @@ document.addEventListener('DOMContentLoaded', async () => { const payloadDisplay = document.getElementById('payload-display'); const diffDisplay = document.getElementById('diff-display'); const diffContainer = document.getElementById('diff-container'); + const promptReductionLabel = document.getElementById('prompt-reduction'); let lastFullText = defaults.lastFullText || ''; let lastPromptText = defaults.lastPromptText || ''; @@ -79,22 +80,6 @@ document.addEventListener('DOMContentLoaded', async () => { if (lastPayload) { payloadDisplay.textContent = lastPayload; } - if (lastFullText && lastPromptText && diff_match_patch) { - const dmp = new diff_match_patch(); - dmp.Diff_EditCost = 4; - const diffs = dmp.diff_main(lastFullText, lastPromptText); - dmp.diff_cleanupEfficiency(diffs); - const hasDiff = diffs.some(d => d[0] !== 0); - if (hasDiff) { - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); - diffContainer.classList.remove('is-hidden'); - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } - } else { - diffContainer.classList.add('is-hidden'); - } themeSelect.addEventListener('change', async () => { markDirty(); await applyTheme(themeSelect.value); @@ -164,6 +149,51 @@ document.addEventListener('DOMContentLoaded', async () => { const tokenReductionToggle = document.getElementById('token-reduction'); tokenReductionToggle.checked = defaults.tokenReduction === true; + function tokenSavingEnabled() { + return htmlToggle.checked + || stripUrlToggle.checked + || altTextToggle.checked + || collapseWhitespaceToggle.checked + || tokenReductionToggle.checked; + } + + function updatePromptReductionLabel(hasDiff) { + if (!promptReductionLabel) return; + if (!hasDiff || !tokenSavingEnabled() || !lastFullText || !lastPromptText) { + promptReductionLabel.classList.add('is-hidden'); + return; + } + const baseLength = lastFullText.length; + const promptLength = lastPromptText.length; + const percentSaved = baseLength > 0 + ? Math.max(0, Math.round((1 - (promptLength / baseLength)) * 100)) + : 0; + promptReductionLabel.textContent = `Prompt Token Reduction: ${percentSaved}%`; + promptReductionLabel.classList.remove('is-hidden'); + } + + function updateDiffDisplay() { + if (lastFullText && lastPromptText && diff_match_patch) { + const dmp = new diff_match_patch(); + dmp.Diff_EditCost = 4; + const diffs = dmp.diff_main(lastFullText, lastPromptText); + dmp.diff_cleanupEfficiency(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } + updatePromptReductionLabel(hasDiff); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + updatePromptReductionLabel(false); + } + } + const debugTabToggle = document.getElementById('show-debug-tab'); const debugTabBtn = document.getElementById('debug-tab-button'); function updateDebugTab() { @@ -174,6 +204,14 @@ document.addEventListener('DOMContentLoaded', async () => { debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); }); updateDebugTab(); + updateDiffDisplay(); + + [htmlToggle, stripUrlToggle, altTextToggle, collapseWhitespaceToggle, tokenReductionToggle].forEach(toggle => { + toggle.addEventListener('change', () => { + updatePromptReductionLabel(!diffContainer.classList.contains('is-hidden')); + }); + }); + const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {}); for (const [key, val] of Object.entries(aiParams)) { @@ -770,23 +808,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) { lastFullText = latest.lastFullText || ''; lastPromptText = latest.lastPromptText || ''; - if (lastFullText && lastPromptText && diff_match_patch) { - const dmp = new diff_match_patch(); - dmp.Diff_EditCost = 4; - const diffs = dmp.diff_main(lastFullText, lastPromptText); - dmp.diff_cleanupEfficiency(diffs); - const hasDiff = diffs.some(d => d[0] !== 0); - if (hasDiff) { - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); - diffContainer.classList.remove('is-hidden'); - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } + updateDiffDisplay(); } } } catch {} From 9269225a0c5da9e13a024677be8375694ac7d93a Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 22:01:20 -0600 Subject: [PATCH 75/80] Add session error log and transient error icon --- README.md | 7 +++-- background.js | 46 +++++++++++++++++++++-------- options/options.html | 27 +++++++++++++++++ options/options.js | 70 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a58a799..87a3565 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ with JSON indicating whether the message meets a specified criterion. - **Rule enable/disable** – temporarily turn a rule off without removing it. - **Account & folder filters** – limit rules to specific accounts or folders. - **Context menu** – apply AI rules from the message list or the message display action button. -- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red until you dismiss the notification. -- **Error notification** – failed classification displays a notification with a button to clear the error and reset the icon. +- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal. +- **Error notification** – failed classification displays a notification in Thunderbird. +- **Session error log** – the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start. - **View reasoning** – inspect why rules matched via the Details popup. - **Cache management** – clear cached results from the context menu or options page. - **Queue & timing stats** – monitor processing time on the Maintenance tab. @@ -88,7 +89,7 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e open a compose window using the account that received the message. 3. Save your settings. New mail will be evaluated automatically using the configured rules. -4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error. +4. If the toolbar icon shows a red X, it will clear after a few seconds. Open the Errors tab in Options to review the latest failures. ### Example Filters diff --git a/background.js b/background.js index fc585ff..e16d5a3 100644 --- a/background.js +++ b/background.js @@ -19,6 +19,7 @@ let queue = Promise.resolve(); let queuedCount = 0; let processing = false; let iconTimer = null; +let errorTimer = null; let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 }; let currentStart = 0; let logGetTiming = true; @@ -33,8 +34,11 @@ let userTheme = 'auto'; let currentTheme = 'light'; let detectSystemTheme; let errorPending = false; +let errorLog = []; let showDebugTab = false; const ERROR_NOTIFICATION_ID = 'sortana-error'; +const ERROR_ICON_TIMEOUT = 4500; +const MAX_ERROR_LOG = 50; function normalizeRules(rules) { return Array.isArray(rules) ? rules.map(r => { @@ -108,11 +112,33 @@ function showTransientIcon(factory, delay = 1500) { async function clearError() { errorPending = false; - await storage.local.set({ errorPending: false }); + clearTimeout(errorTimer); await browser.notifications.clear(ERROR_NOTIFICATION_ID); updateActionIcon(); } +function recordError(context, err) { + const message = err instanceof Error ? err.message : String(err || 'Unknown error'); + const detail = err instanceof Error ? err.stack : ''; + errorLog.unshift({ + time: Date.now(), + context, + message, + detail + }); + if (errorLog.length > MAX_ERROR_LOG) { + errorLog.length = MAX_ERROR_LOG; + } + errorPending = true; + updateActionIcon(); + clearTimeout(errorTimer); + errorTimer = setTimeout(() => { + errorPending = false; + updateActionIcon(); + }, ERROR_ICON_TIMEOUT); + browser.runtime.sendMessage({ type: 'sortana:errorLogUpdated', count: errorLog.length }).catch(() => {}); +} + function refreshMenuIcons() { browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); @@ -382,16 +408,14 @@ async function processMessage(id) { const elapsed = Date.now() - currentStart; currentStart = 0; updateTimingStats(elapsed); - await storage.local.set({ classifyStats: timingStats, errorPending: true }); - errorPending = true; + await storage.local.set({ classifyStats: timingStats }); logger.aiLog("failed to apply AI rules", { level: 'error' }, e); - setIcon(ICONS.error()); + recordError("Failed to apply AI rules", e); browser.notifications.create(ERROR_NOTIFICATION_ID, { type: 'basic', iconUrl: browser.runtime.getURL('resources/img/logo.png'), title: 'Sortana Error', - message: 'Failed to apply AI rules', - buttons: [{ title: 'Dismiss' }] + message: 'Failed to apply AI rules' }); } } @@ -451,7 +475,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "errorPending", "showDebugTab"]); + const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -465,7 +489,6 @@ async function clearCacheForMessages(idsInput) { if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') { maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens; } - errorPending = store.errorPending === true; showDebugTab = store.showDebugTab === true; const savedStats = await storage.local.get('classifyStats'); if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { @@ -524,10 +547,6 @@ async function clearCacheForMessages(idsInput) { if (changes.showDebugTab) { showDebugTab = changes.showDebugTab.newValue === true; } - if (changes.errorPending) { - errorPending = changes.errorPending.newValue === true; - updateActionIcon(); - } if (changes.theme) { userTheme = changes.theme.newValue || 'auto'; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; @@ -720,6 +739,8 @@ async function clearCacheForMessages(idsInput) { } } else if (msg?.type === "sortana:getQueueCount") { return { count: queuedCount + (processing ? 1 : 0) }; + } else if (msg?.type === "sortana:getErrorLog") { + return { errors: errorLog.slice() }; } else if (msg?.type === "sortana:getTiming") { const t = timingStats; const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; @@ -751,6 +772,7 @@ async function clearCacheForMessages(idsInput) { // Catch any unhandled rejections window.addEventListener("unhandledrejection", ev => { logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); + recordError("Unhandled promise rejection", ev.reason); }); browser.notifications.onClicked.addListener(id => { diff --git a/options/options.html b/options/options.html index b118ee0..fd8700c 100644 --- a/options/options.html +++ b/options/options.html @@ -51,6 +51,7 @@
  • Settings
  • Rules
  • Maintenance
  • + @@ -285,6 +286,32 @@ + + +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +

    +
    +
    diff --git a/options/options.js b/options/options.js index 860c944..5e46857 100644 --- a/options/options.js +++ b/options/options.js @@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', async () => { 'templateName', 'customTemplate', 'customSystemPrompt', + 'model', 'aiParams', 'debugLogging', 'htmlToMarkdown', @@ -100,6 +101,88 @@ document.addEventListener('DOMContentLoaded', async () => { endpointInput.addEventListener('input', updateEndpointPreview); updateEndpointPreview(); + const modelSelect = document.getElementById('model-select'); + const refreshModelsBtn = document.getElementById('refresh-models'); + const modelHelp = document.getElementById('model-help'); + const storedModel = typeof defaults.model === 'string' ? defaults.model : ''; + + function setModelHelp(message = '', isError = false) { + if (!modelHelp) return; + modelHelp.textContent = message; + modelHelp.classList.toggle('is-danger', isError); + } + + function populateModelOptions(models = [], selectedModel = '') { + if (!modelSelect) return; + const modelIds = Array.isArray(models) ? models.filter(Boolean) : []; + modelSelect.innerHTML = ''; + + const noneOpt = document.createElement('option'); + noneOpt.value = ''; + noneOpt.textContent = 'None (omit model)'; + modelSelect.appendChild(noneOpt); + + if (selectedModel && !modelIds.includes(selectedModel)) { + const storedOpt = document.createElement('option'); + storedOpt.value = selectedModel; + storedOpt.textContent = `Stored: ${selectedModel}`; + modelSelect.appendChild(storedOpt); + } + + for (const id of modelIds) { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = id; + modelSelect.appendChild(opt); + } + + const hasSelected = [...modelSelect.options].some(opt => opt.value === selectedModel); + modelSelect.value = hasSelected ? selectedModel : ''; + } + + async function fetchModels(preferredModel = '') { + if (!modelSelect || !refreshModelsBtn) return; + const modelsUrl = AiClassifier.buildModelsUrl(endpointInput.value); + if (!modelsUrl) { + setModelHelp('Set a valid endpoint to load models.', true); + populateModelOptions([], preferredModel || modelSelect.value); + return; + } + + refreshModelsBtn.disabled = true; + setModelHelp('Loading models...'); + + try { + const response = await fetch(modelsUrl, { method: 'GET' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + let models = []; + if (Array.isArray(data?.data)) { + models = data.data.map(model => model?.id ?? model?.name ?? model?.model ?? '').filter(Boolean); + } else if (Array.isArray(data?.models)) { + models = data.models.map(model => model?.id ?? model?.name ?? model?.model ?? '').filter(Boolean); + } else if (Array.isArray(data)) { + models = data.map(model => model?.id ?? model?.name ?? model?.model ?? model).filter(Boolean); + } + models = [...new Set(models)]; + populateModelOptions(models, preferredModel || modelSelect.value); + setModelHelp(models.length ? `Loaded ${models.length} model${models.length === 1 ? '' : 's'}.` : 'No models returned.'); + } catch (e) { + logger.aiLog('[options] failed to load models', { level: 'warn' }, e); + setModelHelp('Failed to load models. Check the endpoint and network.', true); + populateModelOptions([], preferredModel || modelSelect.value); + } finally { + refreshModelsBtn.disabled = false; + } + } + + populateModelOptions([], storedModel); + refreshModelsBtn?.addEventListener('click', () => { + fetchModels(modelSelect.value); + }); + const templates = { openai: browser.i18n.getMessage('template.openai'), qwen: browser.i18n.getMessage('template.qwen'), @@ -276,6 +359,7 @@ document.addEventListener('DOMContentLoaded', async () => { await loadErrors(); updateDiffDisplay(); + await fetchModels(storedModel); [htmlToggle, stripUrlToggle, altTextToggle, collapseWhitespaceToggle, tokenReductionToggle].forEach(toggle => { toggle.addEventListener('change', () => { @@ -914,6 +998,7 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('save').addEventListener('click', async () => { const endpoint = endpointInput.value.trim(); + const model = modelSelect?.value || ''; const templateName = templateSelect.value; const customTemplateText = customTemplate.value; const customSystemPrompt = systemBox.value; @@ -979,10 +1064,10 @@ document.addEventListener('DOMContentLoaded', async () => { const tokenReduction = tokenReductionToggle.checked; const showDebugTab = debugTabToggle.checked; const theme = themeSelect.value; - await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); + await storage.local.set({ endpoint, model, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); await applyTheme(theme); try { - await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); + await AiClassifier.setConfig({ endpoint, model, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); logger.setDebug(debugLogging); } catch (e) { logger.aiLog('[options] failed to apply config', {level: 'error'}, e); From 1680ad6c3085a11a36dcb138103f1787367d64d1 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Fri, 30 Jan 2026 02:54:19 -0600 Subject: [PATCH 79/80] Add optional OpenAI auth headers --- AGENTS.md | 1 + README.md | 3 +++ background.js | 7 ++++-- manifest.json | 2 +- modules/AiClassifier.js | 28 ++++++++++++++++++++++- options/dataTransfer.js | 3 +++ options/options.html | 26 ++++++++++++++++++++++ options/options.js | 49 ++++++++++++++++++++++++++++++++++++++--- 8 files changed, 112 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2ea2d9..aece578 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ There are currently no automated tests for this project. If you add tests in the Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. The options page can query `/v1/models` from the same base URL to populate the Model dropdown; selecting **None** omits the `model` field from the request payload. +Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided. Responses are expected to include a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in the response text and ignores any surrounding commentary. ## Documentation diff --git a/README.md b/README.md index 957e5e0..5b908ea 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. - **Configurable endpoint** – set the classification service base URL on the options page. - **Model selection** – load available models from the endpoint and choose one (or omit the model field). +- **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed. - **Prompt templates** – choose between OpenAI/ChatML, Qwen, Mistral, Harmony (gpt-oss), or provide your own custom template. - **Custom system prompts** – tailor the instructions sent to the model for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. @@ -82,6 +83,8 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e 1. Open the add-on's options and set the base URL of your classification service (Sortana will append `/v1/completions`). Use the Model dropdown to load `/v1/models` and select a model or choose **None** to omit the `model` field. + Advanced settings include optional API key, organization, and project headers + for OpenAI-hosted endpoints. 2. Use the **Classification Rules** section to add a criterion and optional actions such as tagging, moving, copying, forwarding, replying, deleting or archiving a message when it matches. Drag rules to diff --git a/background.js b/background.js index aef8cbb..827dec8 100644 --- a/background.js +++ b/background.js @@ -484,7 +484,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "model", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); + const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -514,10 +514,13 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(newRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); } - if (changes.endpoint || changes.model || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { + if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { const config = {}; if (changes.endpoint) config.endpoint = changes.endpoint.newValue; if (changes.model) config.model = changes.model.newValue; + if (changes.apiKey) config.apiKey = changes.apiKey.newValue; + if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue; + if (changes.openaiProject) config.openaiProject = changes.openaiProject.newValue; if (changes.templateName) config.templateName = changes.templateName.newValue; if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue; if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue; diff --git a/manifest.json b/manifest.json index a18e7cd..81baae5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.4.0", + "version": "2.4.1", "default_locale": "en-US", "applications": { "gecko": { diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index cb68382..b4c0907 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -40,6 +40,9 @@ let gTemplateText = ""; let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gModel = ""; +let gApiKey = ""; +let gOpenaiOrganization = ""; +let gOpenaiProject = ""; let gCache = new Map(); let gCacheLoaded = false; @@ -223,6 +226,15 @@ async function setConfig(config = {}) { if (typeof config.model === "string") { gModel = config.model.trim(); } + if (typeof config.apiKey === "string") { + gApiKey = config.apiKey.trim(); + } + if (typeof config.openaiOrganization === "string") { + gOpenaiOrganization = config.openaiOrganization.trim(); + } + if (typeof config.openaiProject === "string") { + gOpenaiProject = config.openaiProject.trim(); + } if (typeof config.debugLogging === "boolean") { setDebug(config.debugLogging); } @@ -241,6 +253,20 @@ async function setConfig(config = {}) { aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); } +function buildAuthHeaders() { + const headers = {}; + if (gApiKey) { + headers.Authorization = `Bearer ${gApiKey}`; + } + if (gOpenaiOrganization) { + headers["OpenAI-Organization"] = gOpenaiOrganization; + } + if (gOpenaiProject) { + headers["OpenAI-Project"] = gOpenaiProject; + } + return headers; +} + function buildSystemPrompt() { return SYSTEM_PREFIX + (gCustomSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX; } @@ -453,7 +479,7 @@ async function classifyText(text, criterion, cacheKey = null) { try { const response = await fetch(gEndpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...buildAuthHeaders() }, body: payload, }); diff --git a/options/dataTransfer.js b/options/dataTransfer.js index fdf096f..393b533 100644 --- a/options/dataTransfer.js +++ b/options/dataTransfer.js @@ -4,6 +4,9 @@ const KEY_GROUPS = { settings: [ 'endpoint', 'model', + 'apiKey', + 'openaiOrganization', + 'openaiProject', 'templateName', 'customTemplate', 'customSystemPrompt', diff --git a/options/options.html b/options/options.html index 4c1fd79..2a1431e 100644 --- a/options/options.html +++ b/options/options.html @@ -141,6 +141,32 @@