From c364238f54d823a8a23f827e53e8a14c083ce781 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Wed, 25 Jun 2025 00:44:15 -0500 Subject: [PATCH] Add rule management UI and automatic classification --- README.md | 3 ++- background.js | 28 ++++++++++++++++---- options/options.html | 22 +++++++++++++++- options/options.js | 63 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e6b8e41..bda2023 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ message meets a specified criterion. - **Persistent result caching** – classification results are saved to disk so messages aren't re-evaluated across restarts. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. +- **Automatic rules** – create rules that tag or move new messages based on AI classification. - **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation. ## Architecture Overview @@ -45,7 +46,7 @@ APIs: | `modules/ExpressionSearchFilter.jsm` | Custom filter term and AI request logic. | | `experiment/api.js` | Bridges WebExtension code with privileged APIs.| | `content/filterEditor.js` | Patches the filter editor interface. | -| `options/options.html` and `options.js` | Endpoint configuration UI. | +| `options/options.html` and `options.js` | Endpoint and rule configuration UI. | | `logger.js` and `modules/logger.jsm` | Colorized logging with optional debug mode. | ## Building diff --git a/background.js b/background.js index a3577b7..a51028a 100644 --- a/background.js +++ b/background.js @@ -12,6 +12,12 @@ let logger; let AiClassifier; +let aiRules = []; + +async function sha256Hex(str) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join(''); +} // Startup (async () => { logger = await import(browser.runtime.getURL("logger.js")); @@ -23,9 +29,10 @@ let AiClassifier; logger.aiLog("failed to import AiClassifier", {level: 'error'}, e); } try { - const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging"]); + const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]); logger.setDebug(store.debugLogging); AiClassifier.setConfig(store); + aiRules = Array.isArray(store.aiRules) ? store.aiRules : []; logger.aiLog("configuration loaded", {debug: true}, store); } catch (err) { logger.aiLog("failed to load config", {level: 'error'}, err); @@ -62,15 +69,26 @@ browser.runtime.onMessage.addListener(async (msg) => { if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) { messenger.messages.onNewMailReceived.addListener(async (folder, messages) => { logger.aiLog("onNewMailReceived", {debug: true}, messages); + if (!aiRules.length) { + const { aiRules: stored } = await browser.storage.local.get("aiRules"); + aiRules = Array.isArray(stored) ? stored : []; + } for (const msg of (messages?.messages || messages || [])) { const id = msg.id ?? msg; try { const full = await messenger.messages.getFull(id); const text = full?.parts?.[0]?.body || ""; - const criterion = (await browser.storage.local.get("autoCriterion")).autoCriterion || ""; - const matched = await AiClassifier.classifyText(text, criterion); - if (matched) { - await messenger.messages.update(id, {tags: ["$label1"]}); + for (const rule of aiRules) { + const cacheKey = await sha256Hex(`${id}|${rule.criterion}`); + const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); + if (matched) { + if (rule.tag) { + await messenger.messages.update(id, {tags: [rule.tag]}); + } + if (rule.moveTo) { + await messenger.messages.move([id], rule.moveTo); + } + } } } catch (e) { logger.aiLog("failed to classify new mail", {level: 'error'}, e); diff --git a/options/options.html b/options/options.html index b758309..ed239cd 100644 --- a/options/options.html +++ b/options/options.html @@ -78,6 +78,21 @@ flex-wrap: wrap; } + #rules-container { + margin-top: 10px; + } + + .rule { + border: 1px solid #ccc; + padding: 10px; + margin-bottom: 10px; + border-radius: 4px; + } + + .rule-actions { + margin-top: 10px; + } + button { padding: 10px 20px; border: none; @@ -197,8 +212,13 @@ + +
+

Classification Rules

+
+ - \ No newline at end of file + diff --git a/options/options.js b/options/options.js index dfc3bd9..aaa86a8 100644 --- a/options/options.js +++ b/options/options.js @@ -7,7 +7,8 @@ document.addEventListener('DOMContentLoaded', async () => { 'customTemplate', 'customSystemPrompt', 'aiParams', - 'debugLogging' + 'debugLogging', + 'aiRules' ]); logger.setDebug(defaults.debugLogging === true); const DEFAULT_AI_PARAMS = { @@ -72,6 +73,59 @@ document.addEventListener('DOMContentLoaded', async () => { systemBox.value = DEFAULT_SYSTEM; }); + const rulesContainer = document.getElementById('rules-container'); + const addRuleBtn = document.getElementById('add-rule'); + + function renderRules(rules = []) { + rulesContainer.innerHTML = ''; + for (const rule of rules) { + const div = document.createElement('div'); + div.className = 'rule'; + + const critInput = document.createElement('input'); + critInput.type = 'text'; + critInput.placeholder = 'Criterion'; + critInput.value = rule.criterion || ''; + + const tagInput = document.createElement('input'); + tagInput.type = 'text'; + tagInput.placeholder = 'Tag (e.g. $label1)'; + tagInput.value = rule.tag || ''; + + const moveInput = document.createElement('input'); + moveInput.type = 'text'; + moveInput.placeholder = 'Folder URL'; + moveInput.value = rule.moveTo || ''; + + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'rule-actions'; + + const delBtn = document.createElement('button'); + delBtn.textContent = 'Delete'; + delBtn.type = 'button'; + delBtn.addEventListener('click', () => div.remove()); + + actionsDiv.appendChild(delBtn); + + div.appendChild(critInput); + div.appendChild(tagInput); + div.appendChild(moveInput); + div.appendChild(actionsDiv); + + rulesContainer.appendChild(div); + } + } + + addRuleBtn.addEventListener('click', () => { + renderRules([...rulesContainer.querySelectorAll('.rule')].map(el => ({ + criterion: el.children[0].value, + tag: el.children[1].value, + moveTo: el.children[2].value + })).concat([{ criterion: '', tag: '', moveTo: '' }])); + }); + + renderRules(defaults.aiRules || []); + document.getElementById('save').addEventListener('click', async () => { const endpoint = document.getElementById('endpoint').value; const templateName = templateSelect.value; @@ -86,7 +140,12 @@ document.addEventListener('DOMContentLoaded', async () => { } } const debugLogging = debugToggle.checked; - await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); + const rules = [...rulesContainer.querySelectorAll('.rule')].map(el => ({ + criterion: el.children[0].value, + tag: el.children[1].value, + moveTo: el.children[2].value + })).filter(r => r.criterion); + await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules }); try { AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); logger.setDebug(debugLogging);