diff --git a/README.md b/README.md index 60cbe13..9c75c61 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ 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 or error states. +- **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. - **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. @@ -76,11 +77,12 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e deleting or archiving a message when it matches. Drag rules to reorder them, check *Only apply to unread messages* to skip read mail, set optional minimum or maximum message age limits, select the accounts or - folders a rule should apply to, uncheck *Enabled* to temporarily disable a rule, and + folders a rule should apply to, uncheck *Enabled* to temporarily disable a rule, and check *Stop after match* to halt further processing. Forward and reply actions 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. ### Example Filters diff --git a/background.js b/background.js index b0db614..06683e2 100644 --- a/background.js +++ b/background.js @@ -30,6 +30,8 @@ let TurndownService = null; let userTheme = 'auto'; let currentTheme = 'light'; let detectSystemTheme; +let errorPending = false; +const ERROR_NOTIFICATION_ID = 'sortana-error'; function normalizeRules(rules) { return Array.isArray(rules) ? rules.map(r => { @@ -68,7 +70,8 @@ const ICONS = { logo: () => 'resources/img/logo.png', circledots: () => iconPaths('circledots'), circle: () => iconPaths('circle'), - average: () => iconPaths('average') + average: () => iconPaths('average'), + error: () => iconPaths('x') }; function setIcon(path) { @@ -82,19 +85,31 @@ function setIcon(path) { function updateActionIcon() { let path = ICONS.logo(); - if (processing || queuedCount > 0) { + if (errorPending) { + path = ICONS.error(); + } else if (processing || queuedCount > 0) { path = ICONS.circledots(); } setIcon(path); } function showTransientIcon(factory, delay = 1500) { + if (errorPending) { + return; + } clearTimeout(iconTimer); const path = typeof factory === 'function' ? factory() : factory; setIcon(path); iconTimer = setTimeout(updateActionIcon, delay); } +async function clearError() { + errorPending = false; + await storage.local.set({ errorPending: false }); + await browser.notifications.clear(ERROR_NOTIFICATION_ID); + updateActionIcon(); +} + function refreshMenuIcons() { browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); @@ -307,9 +322,17 @@ async function processMessage(id) { const elapsed = Date.now() - currentStart; currentStart = 0; updateTimingStats(elapsed); - await storage.local.set({ classifyStats: timingStats }); + await storage.local.set({ classifyStats: timingStats, errorPending: true }); + errorPending = true; logger.aiLog("failed to apply AI rules", { level: 'error' }, e); - showTransientIcon(ICONS.average); + setIcon(ICONS.error()); + 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' }] + }); } } async function applyAiRules(idsInput) { @@ -368,7 +391,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme"]); + const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme", "errorPending"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -378,6 +401,7 @@ async function clearCacheForMessages(idsInput) { stripUrlParams = store.stripUrlParams === true; altTextImages = store.altTextImages === true; collapseWhitespace = store.collapseWhitespace === true; + errorPending = store.errorPending === true; const savedStats = await storage.local.get('classifyStats'); if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { Object.assign(timingStats, savedStats.classifyStats); @@ -409,6 +433,10 @@ async function clearCacheForMessages(idsInput) { collapseWhitespace = changes.collapseWhitespace.newValue === true; logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace); } + if (changes.errorPending) { + errorPending = changes.errorPending.newValue === true; + updateActionIcon(); + } if (changes.theme) { userTheme = changes.theme.newValue || 'auto'; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; @@ -634,6 +662,18 @@ async function clearCacheForMessages(idsInput) { logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); }); + browser.notifications.onClicked.addListener(id => { + if (id === ERROR_NOTIFICATION_ID) { + clearError(); + } + }); + + browser.notifications.onButtonClicked.addListener((id) => { + if (id === ERROR_NOTIFICATION_ID) { + clearError(); + } + }); + browser.runtime.onInstalled.addListener(async ({ reason }) => { if (reason === "install") { await browser.runtime.openOptionsPage();