diff --git a/README.md b/README.md index b46b5d3..97fe5e8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ message meets a specified criterion. - **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. +- **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match. - **Context menu** – apply AI rules from the message list or the message display action button. - **Status icons** – toolbar icons indicate when messages are queued or being classified. - **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation. @@ -60,7 +61,8 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e 1. Open the add-on's options and set the URL of your classification service. 2. Use the **Classification Rules** section to add a criterion and optional - actions such as tagging or moving a message when it matches. + actions such as tagging or moving a message when it matches. Drag rules to + reorder them and check *Stop after match* to halt further processing. 3. Save your settings. New mail will be evaluated automatically using the configured rules. diff --git a/background.js b/background.js index 5ca824b..0df9f72 100644 --- a/background.js +++ b/background.js @@ -50,7 +50,9 @@ async function applyAiRules(idsInput) { const actions = []; if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); - return { criterion: r.criterion, actions }; + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; }) : []; } @@ -78,6 +80,9 @@ async function applyAiRules(idsInput) { await messenger.messages.update(id, { junk: !!act.junk }); } } + if (rule.stopProcessing) { + break; + } } } } catch (e) { @@ -111,9 +116,26 @@ async function applyAiRules(idsInput) { const actions = []; if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); - return { criterion: r.criterion, actions }; + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; }) : []; logger.aiLog("configuration loaded", {debug: true}, store); + storage.onChanged.addListener(async changes => { + if (changes.aiRules) { + const newRules = changes.aiRules.newValue || []; + aiRules = newRules.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; + }); + logger.aiLog("aiRules updated from storage change", {debug: true}, aiRules); + } + }); } catch (err) { logger.aiLog("failed to load config", {level: 'error'}, err); } @@ -145,7 +167,8 @@ async function applyAiRules(idsInput) { browser.menus.onClicked.addListener(async info => { if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") { - const ids = info.selectedMessages?.ids || (info.messageId ? [info.messageId] : []); + const ids = info.selectedMessages?.messages?.map(m => m.id) || + (info.messageId ? [info.messageId] : []); await applyAiRules(ids); } }); diff --git a/options/options.js b/options/options.js index 93440c7..d21cfbe 100644 --- a/options/options.js +++ b/options/options.js @@ -24,6 +24,7 @@ document.addEventListener('DOMContentLoaded', async () => { const saveBtn = document.getElementById('save'); let initialized = false; + let dragRule = null; function markDirty() { if (initialized) saveBtn.disabled = false; } @@ -185,6 +186,23 @@ document.addEventListener('DOMContentLoaded', async () => { for (const rule of rules) { const div = document.createElement('div'); div.className = 'rule box'; + div.draggable = true; + div.addEventListener('dragstart', ev => { dragRule = div; ev.dataTransfer.setData('text/plain', ''); }); + div.addEventListener('dragover', ev => ev.preventDefault()); + div.addEventListener('drop', ev => { + ev.preventDefault(); + if (dragRule && dragRule !== div) { + const children = Array.from(rulesContainer.children); + const dragIndex = children.indexOf(dragRule); + const dropIndex = children.indexOf(div); + if (dragIndex < dropIndex) { + rulesContainer.insertBefore(dragRule, div.nextSibling); + } else { + rulesContainer.insertBefore(dragRule, div); + } + markDirty(); + } + }); const critInput = document.createElement('input'); critInput.type = 'text'; @@ -205,6 +223,15 @@ document.addEventListener('DOMContentLoaded', async () => { addAction.className = 'button is-small'; addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow())); + const stopLabel = document.createElement('label'); + stopLabel.className = 'checkbox ml-2'; + const stopCheck = document.createElement('input'); + stopCheck.type = 'checkbox'; + stopCheck.className = 'stop-processing'; + stopCheck.checked = rule.stopProcessing === true; + stopLabel.appendChild(stopCheck); + stopLabel.append(' Stop after match'); + const delBtn = document.createElement('button'); delBtn.textContent = 'Delete Rule'; delBtn.type = 'button'; @@ -214,6 +241,7 @@ document.addEventListener('DOMContentLoaded', async () => { div.appendChild(critInput); div.appendChild(actionsContainer); div.appendChild(addAction); + div.appendChild(stopLabel); div.appendChild(delBtn); rulesContainer.appendChild(div); @@ -236,9 +264,10 @@ document.addEventListener('DOMContentLoaded', async () => { } return { type }; }); - return { criterion, actions }; + const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; + return { criterion, actions, stopProcessing }; }); - data.push({ criterion: '', actions: [] }); + data.push({ criterion: '', actions: [], stopProcessing: false }); renderRules(data); }); @@ -247,7 +276,9 @@ document.addEventListener('DOMContentLoaded', async () => { const actions = []; if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); - return { criterion: r.criterion, actions }; + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; })); initialized = true; @@ -280,7 +311,8 @@ document.addEventListener('DOMContentLoaded', async () => { } return { type }; }); - return { criterion, actions }; + const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; + return { criterion, actions, stopProcessing }; }).filter(r => r.criterion); await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules }); try {