From f52546320e13272714cf4097119d06ab87692c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 21 Aug 2026 23:23:28 +0200 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=93=A6=20Update=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++-- pnpm-lock.yaml | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 86a4f06..26f85bb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage" }, - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@11.22.0", "dependencies": { "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", @@ -26,7 +26,7 @@ "@vitest/coverage-v8": "^4.1.11", "oxlint": "^1.79.0", "prettier": "^3.9.6", - "vite": "^8.2.1", + "vite": "^8.2.2", "vitest": "^4.1.11" }, "browserslist": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5918e82..e1d80b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ importers: devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.8 - version: 6.0.8(vite@8.2.1)(vue@3.5.41) + version: 6.0.8(vite@8.2.2)(vue@3.5.41) '@vitest/coverage-v8': specifier: ^4.1.11 version: 4.1.11(vitest@4.1.11) @@ -40,11 +40,11 @@ importers: specifier: ^3.9.6 version: 3.9.6 vite: - specifier: ^8.2.1 - version: 8.2.1 + specifier: ^8.2.2 + version: 8.2.2 vitest: specifier: ^4.1.11 - version: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.1) + version: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) packages: @@ -668,13 +668,13 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - vite@8.2.1: - resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -934,10 +934,10 @@ snapshots: '@types/estree@1.0.9': {} - '@vitejs/plugin-vue@6.0.8(vite@8.2.1)(vue@3.5.41)': + '@vitejs/plugin-vue@6.0.8(vite@8.2.2)(vue@3.5.41)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.1 + vite: 8.2.2 vue: 3.5.41 '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': @@ -952,7 +952,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.1) + vitest: 4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2) '@vitest/expect@4.1.11': dependencies: @@ -963,13 +963,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.1)': + '@vitest/mocker@4.1.11(vite@8.2.2)': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1 + vite: 8.2.2 '@vitest/pretty-format@4.1.11': dependencies: @@ -1269,7 +1269,7 @@ snapshots: tinyrainbow@3.1.1: {} - vite@8.2.1: + vite@8.2.2: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -1279,10 +1279,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vitest@4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.1): + vitest@4.1.11(@vitest/coverage-v8@4.1.11)(vite@8.2.2): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.1) + '@vitest/mocker': 4.1.11(vite@8.2.2) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -1299,7 +1299,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1 + vite: 8.2.2 why-is-node-running: 2.3.0 optionalDependencies: '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) From b61b1f972413f664fc1b6e11576664e71b0d045b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 21 Aug 2026 23:23:52 +0200 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9E=95=20Add=20background=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/background.js | 31 --- public/manifest.json | 6 +- src/background.js | 3 + src/backgroundEngine.js | 98 +++++++++ src/composables/useStatsData.js | 220 ++++++-------------- src/statsEngine.js | 132 ++++++++++++ test/backgroundEngine.spec.js | 200 +++++++++++++++++++ test/composables/useStatsData.spec.js | 51 +++++ test/helpers/messenger.js | 17 ++ test/statsEngine.spec.js | 276 ++++++++++++++++++++++++++ vite.config.js | 14 +- vitest.config.js | 2 +- 12 files changed, 850 insertions(+), 200 deletions(-) delete mode 100644 public/js/background.js create mode 100644 src/background.js create mode 100644 src/backgroundEngine.js create mode 100644 src/statsEngine.js create mode 100644 test/backgroundEngine.spec.js create mode 100644 test/statsEngine.spec.js diff --git a/public/js/background.js b/public/js/background.js deleted file mode 100644 index 7138e36..0000000 --- a/public/js/background.js +++ /dev/null @@ -1,31 +0,0 @@ -// this script holds all background activity not related to stats building - -const main = async () => { - // add icon to spaces toolbar - if (messenger.spaces) { - await messenger.spaces.create( - 'third_stats', - '../index.stats.html', - { - badgeBackgroundColor: '#e64db9', - badgeText: '', - defaultIcons: '../icon.svg', - themeIcons: [ - { - dark: '../icon-dark.svg', - light: '../icon-light.svg', - size: 16 - }, - { - dark: '../icon-dark.svg', - light: '../icon-light.svg', - size: 32 - } - ], - title: 'ThirdStats', - } - ); - } -}; - -main(); diff --git a/public/manifest.json b/public/manifest.json index c280660..74f5180 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -20,7 +20,8 @@ "background": { "scripts": [ "js/background.js" - ] + ], + "type": "module" }, "options_ui": { "page": "index.options.html", @@ -31,7 +32,8 @@ "messagesRead", "storage", "downloads", - "messagesTagsList" + "messagesTagsList", + "alarms" ], "icons": { "64": "icon.svg", diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..10cdf9b --- /dev/null +++ b/src/background.js @@ -0,0 +1,3 @@ +import { initBackground } from '@/backgroundEngine.js'; + +initBackground(); diff --git a/src/backgroundEngine.js b/src/backgroundEngine.js new file mode 100644 index 0000000..6f159e6 --- /dev/null +++ b/src/backgroundEngine.js @@ -0,0 +1,98 @@ +// background-script orchestration: schedules periodic stats reprocessing via +// messenger.alarms so it runs independent of the Stats page being open (#381). +import { defaultOptions } from '@/definitions.js'; +import { filterActiveAccounts } from '@/utils.js'; +import { buildAllIdentities, reprocessAccount } from '@/statsEngine.js'; + +export const ALARM_NAME = 'third-stats-refresh'; + +// in-memory guard against overlapping refresh runs (mirrors isLoading.value on the Stats page) +let refreshing = false; + +// get all add-on settings needed for background processing, applying the same +// normalization useStatsData.js's getOptions() applies (addresses is stored as a +// comma-separated string, not an array) +export const getOptions = async () => { + const result = await messenger.storage.local.get('options'); + const o = result?.options ?? {}; + return { + autoRefresh: o.autoRefresh ?? defaultOptions.autoRefresh, + autoRefreshInterval: o.autoRefreshInterval ?? defaultOptions.autoRefreshInterval, + accounts: o.accounts ?? defaultOptions.accounts, + addresses: (o.addresses ?? defaultOptions.addresses) + .toLowerCase() + .split(',') + .map((x) => x.trim()) + .filter(Boolean), + includeSubfolders: o.includeSubfolders ?? defaultOptions.includeSubfolders, + selfMessages: o.selfMessages ?? defaultOptions.selfMessages, + maxListCount: o.maxListCount ?? defaultOptions.maxListCount, + cache: o.cache ?? defaultOptions.cache, + debug: o.debug ?? defaultOptions.debug, + }; +}; + +// (re)create the refresh alarm to match the currently configured options +export const syncAlarm = async () => { + const options = await getOptions(); + await messenger.alarms.clear(ALARM_NAME); + if (options.autoRefresh) { + messenger.alarms.create(ALARM_NAME, { periodInMinutes: Number(options.autoRefreshInterval) }); + } +}; + +// reprocess every active account and persist it to the stats- cache +export const runScheduledRefresh = async () => { + if (refreshing) return; + refreshing = true; + try { + const options = await getOptions(); + const allAccounts = await messenger.accounts.list(); + const activeAccounts = filterActiveAccounts(allAccounts, options.accounts); + const allIdentities = buildAllIdentities(activeAccounts, options.addresses); + for (const account of activeAccounts) { + await reprocessAccount(account.id, options, { allIdentities, filterIsActive: false }); + } + } finally { + refreshing = false; + } +}; + +// registers the Stats page in Thunderbird's spaces toolbar +export const registerSpacesIcon = async () => { + if (messenger.spaces) { + await messenger.spaces.create('third_stats', '../index.stats.html', { + badgeBackgroundColor: '#e64db9', + badgeText: '', + defaultIcons: '../icon.svg', + themeIcons: [ + { + dark: '../icon-dark.svg', + light: '../icon-light.svg', + size: 16, + }, + { + dark: '../icon-dark.svg', + light: '../icon-light.svg', + size: 32, + }, + ], + title: 'ThirdStats', + }); + } +}; + +export const initBackground = () => { + messenger.runtime.onInstalled.addListener(syncAlarm); + messenger.runtime.onStartup.addListener(syncAlarm); + messenger.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === ALARM_NAME) runScheduledRefresh(); + }); + messenger.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.options) syncAlarm(); + }); + registerSpacesIcon(); + // also sync once at load, not just on onInstalled/onStartup, so a reload during + // development or a version bump doesn't leave no alarm registered + syncAlarm(); +}; diff --git a/src/composables/useStatsData.js b/src/composables/useStatsData.js index 54944ed..b7f5939 100644 --- a/src/composables/useStatsData.js +++ b/src/composables/useStatsData.js @@ -5,23 +5,14 @@ import { ref, reactive, computed, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { accentColors, defaultColors, defaultOptions } from '@/definitions.js'; +import { filterActiveAccounts, setTheme, statsCacheKey, traverseAccount, yyyymmdd } from '@/utils.js'; import { - filterActiveAccounts, - flattenSubfolders, - queryMessages, - setTheme, - sortAndLimitObject, - statsCacheKey, - traverseAccount, - yyyymmdd, -} from '@/utils.js'; -import { - analyzeMessage, buildComparisonData, createComparisonData, createStatsData, sumAccountsData, } from '@/composables/statsAggregation.js'; +import { buildAllIdentities, reprocessAccount as engineReprocessAccount } from '@/statsEngine.js'; export function useStatsData() { const { t } = useI18n(); @@ -147,6 +138,16 @@ export function useStatsData() { options.debug = n.debug; } } + // react to the background script writing a fresh stats- cache entry while this page is open - re-run the + // cheap cache-read path (refresh=false) instead of leaving display/comparison stale until a manual reload or + // filter change + if (area == 'local' && !isLoading.value && !filterIsActive.value) { + const changedStatsKeys = Object.keys(result).filter((k) => k.startsWith('stats-')); + if (changedStatsKeys.length) { + const relevant = active.account === 'sum' || changedStatsKeys.includes(statsCacheKey(active.account)); + if (relevant) loadAccount(active.account, false); + } + } }); }; @@ -205,12 +206,7 @@ export function useStatsData() { // store accounts accounts.value = list; // store identities of all activated accounts - let activeIdentities = list.reduce((p, c) => p.concat(c.identities.map((i) => i.email.toLowerCase())), []); - // add local identities if any local account is active - if (options.addresses.length && list.some((a) => ['none', 'local'].includes(a.type))) { - options.addresses.forEach((l) => activeIdentities.push(l.toLowerCase())); - } - identities.value = activeIdentities; + identities.value = buildAllIdentities(list, options.addresses); // extract account id from url GET parameter const uri = window.location.search.substring(1); let id = new URLSearchParams(uri).get('s'); @@ -218,103 +214,6 @@ export function useStatsData() { active.account = id; }; - // retrieve all messages of a given with accounts - // store results in object - const processMessages = async (data, folder, identityList) => { - // Only analyze existing, non-virtual folders - if (folder && !folder.isUnified && !folder.isVirtual) { - const context = { - activeContact: active.contact, - selfMessagesMode: options.selfMessages, - allIdentities: identities.value, - }; - let n = 0, - s = 0, - r = 0; - for await (let m of queryMessages( - folder.id, - active.period.start, - active.period.end, - options.debug, - folder.path - )) { - const type = analyzeMessage(data, m, identityList, context); - // live update numbers section if corresponding option is enabled - if (options.liveCountUp) display.value.numbers = data.numbers; - if (options.debug) { - n++; - s += type === 'sent' ? 1 : 0; - r += type === 'received' ? 1 : 0; - } - } - - // Handle debug output - if (options.debug) { - const totalOutput = String(n).padStart(6); - const receivedOutput = String(r).padStart(6); - const sentOutput = String(s).padStart(6); - console.debug( - `${totalOutput} %c${receivedOutput} %c${sentOutput} %c📁 ${folder.path}`, - `color:${accentColors[1]}`, - `color:${accentColors[0]}`, - 'color:inherit' - ); - } - } - }; - - // analyze folders of a given account - // return processed data object structured like createStatsData - const processAccount = async (a) => { - // get identities from account, or from preferences if it's a local account - const identities = !['none', 'local'].includes(a.type) - ? a.identities.map((i) => i.email.toLowerCase()) - : options.addresses; - // get all folders and subfolders from given account or selected folder of active account (filter field) - const foldersList = active.folder - ? [ - JSON.parse(JSON.stringify(active.folder)), - ...(options.includeSubfolders ? flattenSubfolders(active.folder) : []), - ] - : await traverseAccount(a); - // build folder list for filter selection, if not already present - if (!folders.value.length) { - folders.value = foldersList; - } - const accountData = createStatsData(active.period.start, active.period.end); - await Promise.all( - foldersList.map(async (f) => { - // analyze all messages in all folders - await processMessages(accountData, f, identities); - // increment current progress by 1 for each folder - progress.current++; - }) - ); - // post processing: sort and reduce size of lists to configured limit - accountData.contacts.received = sortAndLimitObject(accountData.contacts.received, options.maxListCount); - accountData.contacts.sent = sortAndLimitObject(accountData.contacts.sent, options.maxListCount); - accountData.contacts.junk = sortAndLimitObject(accountData.contacts.junk, options.maxListCount); - accountData.tags = sortAndLimitObject(accountData.tags, options.maxListCount); - // post processing: sort folders - accountData.folders.received = sortAndLimitObject(accountData.folders.received); - accountData.folders.sent = sortAndLimitObject(accountData.folders.sent); - // post processing: add timestamp of finished processing - accountData.meta.timestamp = Date.now(); - - // Handle debug output - if (options.debug) { - const debugIdentities = identities.length ? identities.join(', ') : 'None'; - console.debug(`Detected identities: ${debugIdentities}`); - } - - // check if error occured during processing - // any error is saved to local storage during processing - const { err } = await messenger.storage.local.get('error'); - error.account = err; - - return accountData; - }; - // true, if at least one filter is set const filterIsActive = computed(() => active.folder || active.period.start || active.period.end || active.contact); @@ -323,20 +222,46 @@ export function useStatsData() { // retrieve and process data of account with // gets called multiple times if processing was invoked for all accounts - const reprocessData = async (id, auto = false) => { - // get currently selected account - const account = await messenger.accounts.get(id); - // process data of this account again - const accountData = await processAccount(account); - // directly display data if only one single account was manually processed - if (singleAccount.value && !auto) { - display.value = JSON.parse(JSON.stringify(accountData)); + const reprocessData = async (id) => { + const { + accountData, + foldersList, + error: hadError, + } = await engineReprocessAccount( + id, + { + addresses: options.addresses, + includeSubfolders: options.includeSubfolders, + selfMessages: options.selfMessages, + maxListCount: options.maxListCount, + cache: options.cache, + debug: options.debug, + }, + { + activeFolder: active.folder, + activeContact: active.contact, + allIdentities: identities.value, + periodStart: active.period.start, + periodEnd: active.period.end, + filterIsActive: filterIsActive.value, + }, + { + onMessage: options.liveCountUp + ? (numbers) => { + display.value.numbers = numbers; + } + : undefined, + onFolderDone: () => progress.current++, + } + ); + // build folder list for filter selection, if not already present + if (!folders.value.length) { + folders.value = foldersList; } - // only store reprocessed data if cache is enabled and no filter is set - if (options.cache && !filterIsActive.value) { - const stats = {}; - stats[statsCacheKey(id)] = JSON.parse(JSON.stringify(accountData)); - await messenger.storage.local.set(stats); + error.account = hadError; + // directly display data if only one single account was processed + if (singleAccount.value) { + display.value = JSON.parse(JSON.stringify(accountData)); } // return processed account data return accountData; @@ -354,8 +279,7 @@ export function useStatsData() { // load data of given account or all accounts // from cache or reprocess from scratch - // while reprocessing was invoked manually or automaticalle - const loadAccount = async (id, refresh, auto = false) => { + const loadAccount = async (id, refresh) => { // start loading indication isLoading.value = true; // check id type @@ -370,13 +294,6 @@ export function useStatsData() { // init progress indicator progress.current = 1; progress.max = activeAccounts.reduce(async (p, c) => p + (await traverseAccount(c).length), 0); - // when auto processing remember displayed account key and disable live counts - let displayedAccountKey = null; - let liveCountUpDisabled = false; - if (auto && options.liveCountUp) { - liveCountUpDisabled = true; - options.liveCountUp = false; - } await Promise.all( activeAccounts.map(async (a) => { // get data from storage @@ -387,33 +304,21 @@ export function useStatsData() { progress.current += a.folderCount; } else { // otherwise (re)process account - await messenger.storage.local.set({ error: false }); // Handle debug output if (options.debug) { console.debug(`Processing account ${a.name}`, a); } - const data = await reprocessData(a.id, auto); + const data = await reprocessData(a.id); accountsData.push(JSON.parse(JSON.stringify(data))); - // remember key of currently displayed account if auto processed - if (auto && active.account == a.id) { - displayedAccountKey = accountsData.length - 1; - } } }) ); - // enable live counts again if set - if (auto && liveCountUpDisabled) { - options.liveCountUp = true; - } // finish progress indicator progress.current = 0; progress.max = 0; // sum all values of all account objects - const sum = sumAccountsData(accountsData, options.maxListCount); - - // show summed stats or keep current view if processing was invoked automatically - display.value = auto && displayedAccountKey ? accountsData[displayedAccountKey] : sum; + display.value = sumAccountsData(accountsData, options.maxListCount); // retrieve all values of account objects for comparison views comparison.value = buildComparisonData(accountsData, activeAccounts); @@ -443,7 +348,6 @@ export function useStatsData() { 'color:inherit' ); } - await messenger.storage.local.set({ error: false }); await reprocessData(id); progress.current = 0; progress.max = 0; @@ -727,17 +631,9 @@ export function useStatsData() { // check if error occured during previous processing const { err } = await messenger.storage.local.get('error'); error.account = err; - // start auto-processing in intervals if activated - if (options.autoRefresh) { - setInterval( - () => { - if (!isLoading.value) { - loadAccount('sum', true, true); - } - }, - Number(options.autoRefreshInterval) * 60 * 1000 - ); // convert minutes to seconds - } + // periodic auto-refresh is scheduled by the background script (src/backgroundEngine.js) + // via messenger.alarms, independent of this page being open - see addStorageListener() + // above for how this page picks up the background-written cache updates live }; return { diff --git a/src/statsEngine.js b/src/statsEngine.js new file mode 100644 index 0000000..e80b19d --- /dev/null +++ b/src/statsEngine.js @@ -0,0 +1,132 @@ +// portable stats fetch/aggregate/cache orchestration - no Vue/DOM dependencies. +// Callable identically from the Stats page (via useStatsData.js) and from the +// background script (via backgroundEngine.js), so the "reprocess an account" logic +// exists in exactly one place regardless of what triggered it. +import { accentColors } from '@/definitions.js'; +import { flattenSubfolders, queryMessages, sortAndLimitObject, statsCacheKey, traverseAccount } from '@/utils.js'; +import { analyzeMessage, createStatsData } from '@/composables/statsAggregation.js'; + +// combines identities of every account in with configured local +// (already-normalized lowercase array) - used for self-message context +// and for the folder/contact filter dropdowns +export function buildAllIdentities(accountList, addresses) { + let activeIdentities = accountList.reduce((p, c) => p.concat(c.identities.map((i) => i.email.toLowerCase())), []); + if (addresses.length && accountList.some((a) => ['none', 'local'].includes(a.type))) { + addresses.forEach((l) => activeIdentities.push(l.toLowerCase())); + } + return activeIdentities; +} + +// retrieve all messages of a given with accounts , updating +// = { activeContact, selfMessagesMode, allIdentities, periodStart, periodEnd, debug } +// = { onMessage?: (numbers) => void } +export async function processMessages(data, folder, identityList, filters = {}, hooks = {}) { + // Only analyze existing, non-virtual folders + if (folder && !folder.isUnified && !folder.isVirtual) { + const context = { + activeContact: filters.activeContact, + selfMessagesMode: filters.selfMessagesMode, + allIdentities: filters.allIdentities ?? [], + }; + let n = 0, + s = 0, + r = 0; + for await (let m of queryMessages(folder.id, filters.periodStart, filters.periodEnd, filters.debug, folder.path)) { + const type = analyzeMessage(data, m, identityList, context); + if (hooks.onMessage) hooks.onMessage(data.numbers); + if (filters.debug) { + n++; + s += type === 'sent' ? 1 : 0; + r += type === 'received' ? 1 : 0; + } + } + + // Handle debug output + if (filters.debug) { + const totalOutput = String(n).padStart(6); + const receivedOutput = String(r).padStart(6); + const sentOutput = String(s).padStart(6); + console.debug( + `${totalOutput} %c${receivedOutput} %c${sentOutput} %c📁 ${folder.path}`, + `color:${accentColors[1]}`, + `color:${accentColors[0]}`, + 'color:inherit' + ); + } + } +} + +// analyze folders of a given account , return { accountData, foldersList, error } +// = { addresses, includeSubfolders, selfMessages, maxListCount, debug } +// = { activeFolder?, activeContact?, allIdentities?, periodStart?, periodEnd? } +// = { onMessage?, onFolderDone? } +export async function processAccount(account, addonOptions, filters = {}, hooks = {}) { + // get identities from account, or from configured addresses if it's a local account + const identityList = !['none', 'local'].includes(account.type) + ? account.identities.map((i) => i.email.toLowerCase()) + : addonOptions.addresses; + // get all folders and subfolders from given account or selected folder (filter field) + const foldersList = filters.activeFolder + ? [ + JSON.parse(JSON.stringify(filters.activeFolder)), + ...(addonOptions.includeSubfolders ? flattenSubfolders(filters.activeFolder) : []), + ] + : await traverseAccount(account); + const accountData = createStatsData(filters.periodStart, filters.periodEnd); + const messageFilters = { + activeContact: filters.activeContact, + selfMessagesMode: addonOptions.selfMessages, + allIdentities: filters.allIdentities, + periodStart: filters.periodStart, + periodEnd: filters.periodEnd, + debug: addonOptions.debug, + }; + await Promise.all( + foldersList.map(async (f) => { + // analyze all messages in all folders + await processMessages(accountData, f, identityList, messageFilters, hooks); + if (hooks.onFolderDone) hooks.onFolderDone(); + }) + ); + // post processing: sort and reduce size of lists to configured limit + accountData.contacts.received = sortAndLimitObject(accountData.contacts.received, addonOptions.maxListCount); + accountData.contacts.sent = sortAndLimitObject(accountData.contacts.sent, addonOptions.maxListCount); + accountData.contacts.junk = sortAndLimitObject(accountData.contacts.junk, addonOptions.maxListCount); + accountData.tags = sortAndLimitObject(accountData.tags, addonOptions.maxListCount); + // post processing: sort folders + accountData.folders.received = sortAndLimitObject(accountData.folders.received); + accountData.folders.sent = sortAndLimitObject(accountData.folders.sent); + // post processing: add timestamp of finished processing + accountData.meta.timestamp = Date.now(); + + // Handle debug output + if (addonOptions.debug) { + const debugIdentities = identityList.length ? identityList.join(', ') : 'None'; + console.debug(`Detected identities: ${debugIdentities}`); + } + + // check if error occured during processing + // any error is saved to local storage during processing + const { err } = await messenger.storage.local.get('error'); + + return { accountData, foldersList, error: err }; +} + +// fetch account , reprocess its data, and persist to the stats- cache +// when addonOptions.cache is enabled and no filter is active. Returns +// { accountData, foldersList, error } - this is the single entry point callable +// identically from the Stats page and from the background script. +// additionally accepts filterIsActive (boolean) +export async function reprocessAccount(accountId, addonOptions, filters = {}, hooks = {}) { + const account = await messenger.accounts.get(accountId); + // reset error flag before (re)processing + await messenger.storage.local.set({ error: false }); + const { accountData, foldersList, error } = await processAccount(account, addonOptions, filters, hooks); + // only store reprocessed data if cache is enabled and no filter is set + if (addonOptions.cache && !filters.filterIsActive) { + const stats = {}; + stats[statsCacheKey(accountId)] = JSON.parse(JSON.stringify(accountData)); + await messenger.storage.local.set(stats); + } + return { accountData, foldersList, error }; +} diff --git a/test/backgroundEngine.spec.js b/test/backgroundEngine.spec.js new file mode 100644 index 0000000..3beb2bf --- /dev/null +++ b/test/backgroundEngine.spec.js @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ALARM_NAME, initBackground, runScheduledRefresh, syncAlarm } from '@/backgroundEngine.js'; +import { defaultOptions } from '@/definitions.js'; +import { statsCacheKey } from '@/utils.js'; +import { createMockMessenger } from './helpers/messenger.js'; + +const fakeAccount = { + id: 'acc-1', + name: 'Account 1', + type: 'imap', + identities: [{ email: 'me@example.com' }], + rootFolder: { id: 'root-1' }, +}; + +const inboxFolder = { + id: 'folder-1', + name: 'Inbox', + path: '/Inbox', + isRoot: false, + isUnified: false, + isVirtual: false, + subFolders: [], +}; + +const makeMessage = (overrides) => ({ + author: 'other@example.com', + recipients: ['me@example.com'], + ccList: [], + bccList: [], + date: new Date(2023, 5, 15), + read: true, + junk: false, + junkScore: 0, + flagged: false, + tags: [], + folder: { name: 'Inbox' }, + ...overrides, +}); + +const setupMessenger = (overrides = {}) => + createMockMessenger({ + accounts: { + list: vi.fn(async () => [fakeAccount]), + get: vi.fn(async () => fakeAccount), + }, + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { list: vi.fn(async () => ({ id: null, messages: [makeMessage()] })) }, + ...overrides, + }); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('syncAlarm', () => { + it('clears then creates the alarm with the configured interval when autoRefresh is enabled', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, autoRefresh: true, autoRefreshInterval: 45 } }); + + await syncAlarm(); + + expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME); + expect(messenger.alarms.create).toHaveBeenCalledWith(ALARM_NAME, { periodInMinutes: 45 }); + }); + + it('only clears, never creates, when autoRefresh is disabled', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, autoRefresh: false } }); + + await syncAlarm(); + + expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME); + expect(messenger.alarms.create).not.toHaveBeenCalled(); + }); + + it('falls back to defaultOptions when nothing has been stored yet', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + + await syncAlarm(); + + if (defaultOptions.autoRefresh) { + expect(messenger.alarms.create).toHaveBeenCalledWith(ALARM_NAME, { + periodInMinutes: Number(defaultOptions.autoRefreshInterval), + }); + } else { + expect(messenger.alarms.create).not.toHaveBeenCalled(); + } + }); +}); + +describe('initBackground', () => { + it('registers onInstalled, onStartup, onAlarm and storage.onChanged listeners, and syncs once at load', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + + initBackground(); + + expect(messenger.runtime.onInstalled.addListener).toHaveBeenCalledWith(syncAlarm); + expect(messenger.runtime.onStartup.addListener).toHaveBeenCalledWith(syncAlarm); + expect(messenger.alarms.onAlarm.addListener).toHaveBeenCalled(); + expect(messenger.storage.onChanged.addListener).toHaveBeenCalled(); + expect(messenger.spaces.create).toHaveBeenCalledWith('third_stats', '../index.stats.html', expect.any(Object)); + // the load-time syncAlarm() call is fire-and-forget, so its effect lands a few + // microtask turns later - wait for it instead of asserting synchronously + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + }); + + it('a live options change re-syncs the alarm', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, autoRefresh: false } }); + + initBackground(); + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + messenger.alarms.clear.mockClear(); + + await messenger.storage.local.set({ options: { ...defaultOptions, autoRefresh: true, autoRefreshInterval: 10 } }); + + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + expect(messenger.alarms.create).toHaveBeenCalledWith(ALARM_NAME, { periodInMinutes: 10 }); + }); + + it('ignores storage changes to keys other than "options"', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + + initBackground(); + // wait for the load-time sync to fully settle before observing "nothing happens" below + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + messenger.alarms.clear.mockClear(); + + // the options.onChanged listener checks `changes.options` synchronously before ever + // calling syncAlarm(), so an irrelevant key never schedules any async work at all + await messenger.storage.local.set({ error: true }); + + expect(messenger.alarms.clear).not.toHaveBeenCalled(); + }); + + it('firing the registered onAlarm callback with the wrong name is a no-op', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + + initBackground(); + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + const onAlarmCallback = messenger.alarms.onAlarm.addListener.mock.calls[0][0]; + + onAlarmCallback({ name: 'some-other-alarm' }); + + expect(messenger.accounts.list).not.toHaveBeenCalled(); + }); + + it('firing the registered onAlarm callback with the right name drives a full refresh', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + + initBackground(); + await vi.waitFor(() => expect(messenger.alarms.clear).toHaveBeenCalledWith(ALARM_NAME)); + const onAlarmCallback = messenger.alarms.onAlarm.addListener.mock.calls[0][0]; + + // the listener fires runScheduledRefresh() without awaiting it (a real + // alarms.onAlarm listener can't be awaited by anything), so poll storage instead + onAlarmCallback({ name: ALARM_NAME }); + + await vi.waitFor(async () => { + const cached = await messenger.storage.local.get(statsCacheKey(fakeAccount.id)); + expect(cached[statsCacheKey(fakeAccount.id)]?.numbers.total).toBe(1); + }); + }); +}); + +describe('runScheduledRefresh - concurrency guard', () => { + it('a second overlapping call is a no-op while the first is still in flight', async () => { + let resolveList; + const list = vi.fn( + async () => + new Promise((resolve) => { + resolveList = () => resolve({ id: null, messages: [makeMessage()] }); + }) + ); + const messenger = setupMessenger({ messages: { list } }); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + + const firstRun = runScheduledRefresh(); + const secondRun = runScheduledRefresh(); // guard check runs synchronously, before the first await + + // give the first run's chain of awaits (getOptions -> accounts.list) a few + // microtask turns to actually reach messages.list, where it's now stuck + await vi.waitFor(() => expect(list).toHaveBeenCalled()); + expect(messenger.accounts.list).toHaveBeenCalledTimes(1); // second call returned early, guard held + + resolveList(); + await firstRun; + await secondRun; + }); +}); diff --git a/test/composables/useStatsData.spec.js b/test/composables/useStatsData.spec.js index 91ca4ee..1e45c35 100644 --- a/test/composables/useStatsData.spec.js +++ b/test/composables/useStatsData.spec.js @@ -437,6 +437,57 @@ describe('useStatsData - caching', () => { }); }); +describe('useStatsData - live stats cache sync', () => { + const setupSingleAccount = (messages) => { + const list = vi.fn(async () => ({ id: null, messages })); + const messenger = setupMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { list }, + }); + return { messenger, list }; + }; + + it('reacts to a background-written stats- cache update by reloading from cache, not refetching', async () => { + const { messenger, list } = setupSingleAccount([makeMessage()]); + await messenger.storage.local.set({ options: { ...baseOptions, cache: true } }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + const callsBefore = list.mock.calls.length; + + await messenger.storage.local.set({ + [statsCacheKey(fakeAccount.id)]: { + numbers: { total: 42 }, + meta: { start: new Date(2021, 0, 1), end: new Date(2021, 0, 2) }, + }, + }); + await flushPending(); + + expect(engine.display.value.numbers.total).toBe(42); + expect(list.mock.calls.length).toBe(callsBefore); // cache re-read, not a refetch + }); + + it('ignores a background-written cache update while a filter is active', async () => { + const { messenger } = setupSingleAccount([makeMessage()]); + await messenger.storage.local.set({ options: { ...baseOptions, cache: true } }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + engine.active.folder = inboxFolder; + await flushPending(); + const totalBefore = engine.display.value.numbers.total; + + await messenger.storage.local.set({ [statsCacheKey(fakeAccount.id)]: { numbers: { total: 999 }, meta: {} } }); + await flushPending(); + + expect(engine.display.value.numbers.total).toBe(totalBefore); + }); +}); + describe('useStatsData - summed view across accounts', () => { it('combines numbers and builds per-account comparison data for the "sum" view', async () => { const accountA = { diff --git a/test/helpers/messenger.js b/test/helpers/messenger.js index 14eda5c..a6d9085 100644 --- a/test/helpers/messenger.js +++ b/test/helpers/messenger.js @@ -70,6 +70,23 @@ export function createMockMessenger(overrides = {}) { }, runtime: { getBackgroundPage: vi.fn(async () => ({ messenger: { accounts: { list: vi.fn(async () => []) } } })), + onInstalled: { + addListener: vi.fn(), + }, + onStartup: { + addListener: vi.fn(), + }, + }, + alarms: { + create: vi.fn(), + clear: vi.fn(async () => true), + get: vi.fn(async () => null), + onAlarm: { + addListener: vi.fn(), + }, + }, + spaces: { + create: vi.fn(async () => {}), }, i18n: { getUILanguage: vi.fn(() => 'en'), diff --git a/test/statsEngine.spec.js b/test/statsEngine.spec.js new file mode 100644 index 0000000..33de50c --- /dev/null +++ b/test/statsEngine.spec.js @@ -0,0 +1,276 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildAllIdentities, processAccount, processMessages, reprocessAccount } from '@/statsEngine.js'; +import { createStatsData } from '@/composables/statsAggregation.js'; +import { statsCacheKey } from '@/utils.js'; +import { createMockMessenger } from './helpers/messenger.js'; + +const fakeAccount = { + id: 'acc-1', + name: 'Account 1', + type: 'imap', + identities: [{ email: 'me@example.com' }], + rootFolder: { id: 'root-1' }, +}; + +const localAccount = { + id: 'acc-local', + name: 'Local Folders', + type: 'none', + identities: [], + rootFolder: { id: 'root-local' }, +}; + +const inboxFolder = { + id: 'folder-1', + name: 'Inbox', + path: '/Inbox', + isRoot: false, + isUnified: false, + isVirtual: false, + subFolders: [], +}; + +const virtualFolder = { ...inboxFolder, id: 'folder-virtual', isVirtual: true }; + +const makeMessage = (overrides) => ({ + author: 'other@example.com', + recipients: ['me@example.com'], + ccList: [], + bccList: [], + date: new Date(2023, 5, 15), + read: true, + junk: false, + junkScore: 0, + flagged: false, + tags: [], + folder: { name: 'Inbox' }, + ...overrides, +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('buildAllIdentities', () => { + it('collects lowercased identities from every account', () => { + const accountA = { type: 'imap', identities: [{ email: 'A@Example.com' }] }; + const accountB = { type: 'imap', identities: [{ email: 'b@example.com' }] }; + expect(buildAllIdentities([accountA, accountB], [])).toEqual(['a@example.com', 'b@example.com']); + }); + + it('adds configured local addresses only when a none/local account is present', () => { + const imapOnly = [{ type: 'imap', identities: [{ email: 'a@example.com' }] }]; + expect(buildAllIdentities(imapOnly, ['Local@Example.com'])).toEqual(['a@example.com']); + + const withLocal = [ + { type: 'imap', identities: [{ email: 'a@example.com' }] }, + { type: 'local', identities: [] }, + ]; + expect(buildAllIdentities(withLocal, ['Local@Example.com'])).toEqual(['a@example.com', 'local@example.com']); + }); +}); + +describe('processMessages', () => { + it('skips unified and virtual folders entirely', async () => { + vi.stubGlobal( + 'messenger', + createMockMessenger({ messages: { list: vi.fn(async () => ({ id: null, messages: [makeMessage()] })) } }) + ); + const data = { numbers: { total: 0 } }; + await processMessages(data, { ...virtualFolder }, ['me@example.com'], {}); + expect(messenger.messages.list).not.toHaveBeenCalled(); + }); + + it('invokes hooks.onMessage once per analyzed message, and works with no hooks at all', async () => { + vi.stubGlobal( + 'messenger', + createMockMessenger({ + messages: { list: vi.fn(async () => ({ id: null, messages: [makeMessage(), makeMessage()] })) }, + }) + ); + const data = createStatsData(); + const numbersSeen = []; + + await processMessages(data, inboxFolder, ['me@example.com'], {}, { onMessage: (n) => numbersSeen.push(n.total) }); + expect(numbersSeen).toEqual([1, 2]); + + // no hooks object at all - the background-processing call shape - must not throw + await expect(processMessages(data, inboxFolder, ['me@example.com'], {})).resolves.toBeUndefined(); + }); +}); + +describe('processAccount', () => { + it('uses account identities for a normal account, and configured addresses for a local one', async () => { + vi.stubGlobal( + 'messenger', + createMockMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { + list: vi.fn(async () => ({ + id: null, + messages: [makeMessage({ author: 'me@example.com', recipients: ['x@example.com'] })], + })), + }, + }) + ); + + const { accountData: normalResult } = await processAccount(fakeAccount, { addresses: [], maxListCount: 20 }); + expect(normalResult.numbers.sent).toBe(1); // author matches account identity -> sent + + const { accountData: localResult } = await processAccount(localAccount, { + addresses: ['me@example.com'], + maxListCount: 20, + }); + expect(localResult.numbers.sent).toBe(1); // author matches configured local address -> sent + }); + + it('restricts to filters.activeFolder (plus subfolders when includeSubfolders) instead of the full account tree', async () => { + const subFolder = { ...inboxFolder, id: 'folder-sub', subFolders: [] }; + const parentFolder = { ...inboxFolder, id: 'folder-parent', subFolders: [subFolder] }; + const traverseAccountSpy = vi.fn(async () => ({ isRoot: true, subFolders: [parentFolder] })); + vi.stubGlobal( + 'messenger', + createMockMessenger({ + folders: { get: traverseAccountSpy }, + messages: { list: vi.fn(async () => ({ id: null, messages: [] })) }, + }) + ); + + const { foldersList: withoutFilter } = await processAccount(fakeAccount, { addresses: [], maxListCount: 20 }); + // full account traversal always flattens subfolders, regardless of includeSubfolders + // (that option only governs the filtered single-folder path below) + expect(withoutFilter.map((f) => f.id)).toEqual(['folder-parent', 'folder-sub']); + expect(traverseAccountSpy).toHaveBeenCalled(); + + traverseAccountSpy.mockClear(); + const { foldersList: withFilterNoSub } = await processAccount( + fakeAccount, + { addresses: [], includeSubfolders: false, maxListCount: 20 }, + { activeFolder: parentFolder } + ); + expect(withFilterNoSub.map((f) => f.id)).toEqual(['folder-parent']); + expect(traverseAccountSpy).not.toHaveBeenCalled(); // filtered path never re-traverses the account + + const { foldersList: withFilterAndSub } = await processAccount( + fakeAccount, + { addresses: [], includeSubfolders: true, maxListCount: 20 }, + { activeFolder: parentFolder } + ); + expect(withFilterAndSub.map((f) => f.id)).toEqual(['folder-parent', 'folder-sub']); + }); + + it('calls hooks.onFolderDone once per processed folder', async () => { + const folderA = { ...inboxFolder, id: 'folder-a' }; + const folderB = { ...inboxFolder, id: 'folder-b' }; + vi.stubGlobal( + 'messenger', + createMockMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [folderA, folderB] })) }, + messages: { list: vi.fn(async () => ({ id: null, messages: [] })) }, + }) + ); + + let done = 0; + await processAccount(fakeAccount, { addresses: [], maxListCount: 20 }, {}, { onFolderDone: () => done++ }); + expect(done).toBe(2); + }); + + it('post-processes contacts/tags/folders via sortAndLimitObject and sets meta.timestamp', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 0, 1)); + vi.stubGlobal( + 'messenger', + createMockMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { + list: vi.fn(async () => ({ + id: null, + messages: [ + makeMessage({ author: 'a@example.com' }), + makeMessage({ author: 'a@example.com' }), + makeMessage({ author: 'b@example.com' }), + ], + })), + }, + }) + ); + + const { accountData } = await processAccount(fakeAccount, { addresses: [], maxListCount: 1 }); + expect(Object.keys(accountData.contacts.received)).toEqual(['a@example.com']); // limited to maxListCount, sorted by count + expect(accountData.meta.timestamp).toBe(new Date(2024, 0, 1).getTime()); + vi.useRealTimers(); + }); + + // NOTE: processAccount's return value comes from `const { err } = await + // messenger.storage.local.get('error')` - destructuring key "err", but the stored key + // is "error" (see queryMessages/statsCacheKey usage in utils.js), so this always reads + // undefined regardless of the actual flag. This is a pre-existing bug carried over + // verbatim from useStatsData.js's original processAccount() during extraction - flagged, + // not fixed, per project convention. + it('always reads back undefined for the error flag due to the pre-existing {err} vs {error} key mismatch', async () => { + vi.stubGlobal( + 'messenger', + createMockMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { list: vi.fn(async () => ({ id: null, messages: [] })) }, + }) + ); + await messenger.storage.local.set({ error: true }); + + const { error } = await processAccount(fakeAccount, { addresses: [], maxListCount: 20 }); + expect(error).toBeUndefined(); + }); +}); + +describe('reprocessAccount', () => { + const setup = (overrides = {}) => + createMockMessenger({ + accounts: { get: vi.fn(async () => fakeAccount) }, + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { list: vi.fn(async () => ({ id: null, messages: [makeMessage()] })) }, + ...overrides, + }); + + it('resets the error flag to false before processing', async () => { + const messenger = setup(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ error: true }); + + await reprocessAccount(fakeAccount.id, { addresses: [], maxListCount: 20, cache: false }, {}); + + // the reset happens before processing, and no fetch failed here, so it stays false + const { error } = await messenger.storage.local.get('error'); + expect(error).toBe(false); + }); + + it('persists to the stats- cache only when cache is enabled and no filter is active', async () => { + const messenger = setup(); + vi.stubGlobal('messenger', messenger); + + await reprocessAccount(fakeAccount.id, { addresses: [], maxListCount: 20, cache: true }, { filterIsActive: false }); + const cached = await messenger.storage.local.get(statsCacheKey(fakeAccount.id)); + expect(cached[statsCacheKey(fakeAccount.id)].numbers.total).toBe(1); + }); + + it('does not persist when a filter is active, even with cache enabled', async () => { + const messenger = setup(); + vi.stubGlobal('messenger', messenger); + + await reprocessAccount(fakeAccount.id, { addresses: [], maxListCount: 20, cache: true }, { filterIsActive: true }); + const cached = await messenger.storage.local.get(statsCacheKey(fakeAccount.id)); + expect(cached).toEqual({}); + }); + + it('does not persist when cache is disabled, even without a filter', async () => { + const messenger = setup(); + vi.stubGlobal('messenger', messenger); + + await reprocessAccount( + fakeAccount.id, + { addresses: [], maxListCount: 20, cache: false }, + { filterIsActive: false } + ); + const cached = await messenger.storage.local.get(statsCacheKey(fakeAccount.id)); + expect(cached).toEqual({}); + }); +}); diff --git a/vite.config.js b/vite.config.js index c18ca95..6ef3ffe 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,16 +9,22 @@ export default defineConfig({ }, resolve: { alias: { - '@': resolve(__dirname, './src'), + '@': resolve(import.meta.dirname, './src'), }, }, build: { minify: true, // set to false to make source code readable rollupOptions: { input: { - popup: resolve(__dirname, 'index.popup.html'), - stats: resolve(__dirname, 'index.stats.html'), - options: resolve(__dirname, 'index.options.html'), + popup: resolve(import.meta.dirname, 'index.popup.html'), + stats: resolve(import.meta.dirname, 'index.stats.html'), + options: resolve(import.meta.dirname, 'index.options.html'), + background: resolve(import.meta.dirname, 'src/background.js'), + }, + output: { + // the background script's manifest.json reference needs a fixed, non-hashed path + entryFileNames: (chunkInfo) => + chunkInfo.name === 'background' ? 'js/background.js' : 'assets/[name]-[hash].js', }, }, }, diff --git a/vitest.config.js b/vitest.config.js index 5f3e140..14d9cc9 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,7 +4,7 @@ import { resolve } from 'path'; export default defineConfig({ resolve: { alias: { - '@': resolve(__dirname, './src'), + '@': resolve(import.meta.dirname, './src'), }, }, test: { From 830753ef6dc1d3a73a116525b45b74ed723ef72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 21 Aug 2026 23:51:20 +0200 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9E=95=20Reflect=20background=20busy=20s?= =?UTF-8?q?tate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backgroundEngine.js | 25 ++++++++++++- src/composables/useStatsData.js | 19 +++++++++- src/filters/AccountFilter.vue | 10 ++--- src/statsEngine.js | 5 +++ test/backgroundEngine.spec.js | 53 +++++++++++++++++++++++++++ test/composables/useStatsData.spec.js | 44 ++++++++++++++++++++++ test/helpers/messenger.js | 3 +- 7 files changed, 150 insertions(+), 9 deletions(-) diff --git a/src/backgroundEngine.js b/src/backgroundEngine.js index 6f159e6..5bc23af 100644 --- a/src/backgroundEngine.js +++ b/src/backgroundEngine.js @@ -2,13 +2,23 @@ // messenger.alarms so it runs independent of the Stats page being open (#381). import { defaultOptions } from '@/definitions.js'; import { filterActiveAccounts } from '@/utils.js'; -import { buildAllIdentities, reprocessAccount } from '@/statsEngine.js'; +import { buildAllIdentities, PROCESSING_STORAGE_KEY, reprocessAccount } from '@/statsEngine.js'; export const ALARM_NAME = 'third-stats-refresh'; // in-memory guard against overlapping refresh runs (mirrors isLoading.value on the Stats page) let refreshing = false; +// id of the spaces-toolbar entry, captured once registerSpacesIcon() resolves - used to +// badge the icon while a scheduled refresh is running, the only visual indication of +// background activity when no ThirdStats page happens to be open +let spaceId = null; + +const setBadgeProcessing = async (isProcessing) => { + if (!messenger.spaces || spaceId === null) return; + await messenger.spaces.update(spaceId, {}, { badgeText: isProcessing ? '•' : '' }); +}; + // get all add-on settings needed for background processing, applying the same // normalization useStatsData.js's getOptions() applies (addresses is stored as a // comma-separated string, not an array) @@ -45,6 +55,8 @@ export const syncAlarm = async () => { export const runScheduledRefresh = async () => { if (refreshing) return; refreshing = true; + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: true }); + await setBadgeProcessing(true); try { const options = await getOptions(); const allAccounts = await messenger.accounts.list(); @@ -55,13 +67,15 @@ export const runScheduledRefresh = async () => { } } finally { refreshing = false; + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: false }); + await setBadgeProcessing(false); } }; // registers the Stats page in Thunderbird's spaces toolbar export const registerSpacesIcon = async () => { if (messenger.spaces) { - await messenger.spaces.create('third_stats', '../index.stats.html', { + const space = await messenger.spaces.create('third_stats', '../index.stats.html', { badgeBackgroundColor: '#e64db9', badgeText: '', defaultIcons: '../icon.svg', @@ -79,6 +93,7 @@ export const registerSpacesIcon = async () => { ], title: 'ThirdStats', }); + spaceId = space.id; } }; @@ -95,4 +110,10 @@ export const initBackground = () => { // also sync once at load, not just on onInstalled/onStartup, so a reload during // development or a version bump doesn't leave no alarm registered syncAlarm(); + // PROCESSING_STORAGE_KEY is only ever set true by this module itself (see + // runScheduledRefresh), so at this point in a fresh background-script lifetime nothing + // can legitimately be mid-run yet - reset it in case a previous lifetime was killed + // before reaching its finally block and left the flag (and the Stats page's disabled + // refresh button) stuck true forever + messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: false }); }; diff --git a/src/composables/useStatsData.js b/src/composables/useStatsData.js index b7f5939..09891c7 100644 --- a/src/composables/useStatsData.js +++ b/src/composables/useStatsData.js @@ -12,7 +12,11 @@ import { createStatsData, sumAccountsData, } from '@/composables/statsAggregation.js'; -import { buildAllIdentities, reprocessAccount as engineReprocessAccount } from '@/statsEngine.js'; +import { + buildAllIdentities, + PROCESSING_STORAGE_KEY, + reprocessAccount as engineReprocessAccount, +} from '@/statsEngine.js'; export function useStatsData() { const { t } = useI18n(); @@ -56,6 +60,11 @@ export function useStatsData() { max: 0, // upper limit for progress indicator }); + // true while the background script (src/backgroundEngine.js) is running a scheduled + // refresh - read-only here, synced from messenger.storage.local; used to disable the + // manual refresh action so it can't start a second concurrent pass over the same accounts + const backgroundBusy = ref(false); + // preferences for stats page configuration const preferences = reactive({ sections: { @@ -148,6 +157,10 @@ export function useStatsData() { if (relevant) loadAccount(active.account, false); } } + // mirror whether the background script is currently mid-refresh + if (area == 'local' && result?.[PROCESSING_STORAGE_KEY]) { + backgroundBusy.value = !!result[PROCESSING_STORAGE_KEY].newValue; + } }); }; @@ -631,6 +644,9 @@ export function useStatsData() { // check if error occured during previous processing const { err } = await messenger.storage.local.get('error'); error.account = err; + // pick up whether a background refresh is already in flight when this page opens + const { [PROCESSING_STORAGE_KEY]: initialProcessing } = await messenger.storage.local.get(PROCESSING_STORAGE_KEY); + backgroundBusy.value = !!initialProcessing; // periodic auto-refresh is scheduled by the background script (src/backgroundEngine.js) // via messenger.alarms, independent of this page being open - see addStorageListener() // above for how this page picks up the background-written cache updates live @@ -644,6 +660,7 @@ export function useStatsData() { error, isLoading, progress, + backgroundBusy, preferences, options, display, diff --git a/src/filters/AccountFilter.vue b/src/filters/AccountFilter.vue index 0e1bdac..b4e1328 100644 --- a/src/filters/AccountFilter.vue +++ b/src/filters/AccountFilter.vue @@ -3,18 +3,18 @@ - +
diff --git a/src/statsEngine.js b/src/statsEngine.js index e80b19d..9d7456b 100644 --- a/src/statsEngine.js +++ b/src/statsEngine.js @@ -6,6 +6,11 @@ import { accentColors } from '@/definitions.js'; import { flattenSubfolders, queryMessages, sortAndLimitObject, statsCacheKey, traverseAccount } from '@/utils.js'; import { analyzeMessage, createStatsData } from '@/composables/statsAggregation.js'; +// messenger.storage.local key set by backgroundEngine.js while a scheduled refresh is in +// flight, and read by useStatsData.js to disable the manual refresh action meanwhile - +// prevents a user-triggered reprocess from running concurrently against the same account +export const PROCESSING_STORAGE_KEY = 'statsProcessing'; + // combines identities of every account in with configured local // (already-normalized lowercase array) - used for self-message context // and for the folder/contact filter dropdowns diff --git a/test/backgroundEngine.spec.js b/test/backgroundEngine.spec.js index 3beb2bf..80b0548 100644 --- a/test/backgroundEngine.spec.js +++ b/test/backgroundEngine.spec.js @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ALARM_NAME, initBackground, runScheduledRefresh, syncAlarm } from '@/backgroundEngine.js'; import { defaultOptions } from '@/definitions.js'; +import { PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; import { createMockMessenger } from './helpers/messenger.js'; @@ -172,6 +173,58 @@ describe('initBackground', () => { }); }); +describe('runScheduledRefresh - processing indicator', () => { + it('sets the storage flag true while running and false once finished', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + const seenDuringRun = []; + messenger.accounts.list.mockImplementation(async () => { + const { [PROCESSING_STORAGE_KEY]: flag } = await messenger.storage.local.get(PROCESSING_STORAGE_KEY); + seenDuringRun.push(flag); + return [fakeAccount]; + }); + + await runScheduledRefresh(); + + expect(seenDuringRun).toEqual([true]); // true while the refresh was actually running + const { [PROCESSING_STORAGE_KEY]: finalFlag } = await messenger.storage.local.get(PROCESSING_STORAGE_KEY); + expect(finalFlag).toBe(false); + }); + + it('badges the spaces-toolbar icon while running and clears it once finished', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + + initBackground(); + await vi.waitFor(() => expect(messenger.spaces.create).toHaveBeenCalled()); + const spaceId = (await messenger.spaces.create.mock.results[0].value).id; + + await runScheduledRefresh(); + + expect(messenger.spaces.update).toHaveBeenNthCalledWith(1, spaceId, {}, { badgeText: '•' }); + expect(messenger.spaces.update).toHaveBeenNthCalledWith(2, spaceId, {}, { badgeText: '' }); + }); +}); + +describe('initBackground - stuck-flag recovery', () => { + it('resets a leftover "true" processing flag at startup', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + // simulate a previous background-script lifetime that got killed mid-refresh, + // never reaching runScheduledRefresh's finally block + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: true }); + + initBackground(); + + await vi.waitFor(async () => { + const { [PROCESSING_STORAGE_KEY]: flag } = await messenger.storage.local.get(PROCESSING_STORAGE_KEY); + expect(flag).toBe(false); + }); + }); +}); + describe('runScheduledRefresh - concurrency guard', () => { it('a second overlapping call is a no-op while the first is still in flight', async () => { let resolveList; diff --git a/test/composables/useStatsData.spec.js b/test/composables/useStatsData.spec.js index 1e45c35..c9585e9 100644 --- a/test/composables/useStatsData.spec.js +++ b/test/composables/useStatsData.spec.js @@ -9,6 +9,7 @@ vi.mock('vue-i18n', () => ({ import { useStatsData } from '@/composables/useStatsData.js'; import { defaultOptions } from '@/definitions.js'; +import { PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; import { createMockMessenger } from '../helpers/messenger.js'; @@ -488,6 +489,49 @@ describe('useStatsData - live stats cache sync', () => { }); }); +describe('useStatsData - backgroundBusy', () => { + it('picks up a background refresh already in flight when the page opens', async () => { + const messenger = setupMessenger(); + await messenger.storage.local.set({ options: baseOptions }); + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: true }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + + expect(engine.backgroundBusy.value).toBe(true); + }); + + it('defaults to false when nothing has been stored yet', async () => { + const messenger = setupMessenger(); + await messenger.storage.local.set({ options: baseOptions }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + + expect(engine.backgroundBusy.value).toBe(false); + }); + + it('reacts live to the background script setting and clearing the flag', async () => { + const messenger = setupMessenger(); + await messenger.storage.local.set({ options: baseOptions }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: true }); + expect(engine.backgroundBusy.value).toBe(true); + + await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: false }); + expect(engine.backgroundBusy.value).toBe(false); + }); +}); + describe('useStatsData - summed view across accounts', () => { it('combines numbers and builds per-account comparison data for the "sum" view', async () => { const accountA = { diff --git a/test/helpers/messenger.js b/test/helpers/messenger.js index a6d9085..4153444 100644 --- a/test/helpers/messenger.js +++ b/test/helpers/messenger.js @@ -86,7 +86,8 @@ export function createMockMessenger(overrides = {}) { }, }, spaces: { - create: vi.fn(async () => {}), + create: vi.fn(async () => ({ id: 1, isBuiltIn: false, isSelfOwned: true, name: 'third_stats' })), + update: vi.fn(async () => {}), }, i18n: { getUILanguage: vi.fn(() => 'en'), From 6476f648d52749fce6511b86a144f9bc67481556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sun, 23 Aug 2026 01:07:03 +0200 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=A8=20Fix=20busy=20badge=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backgroundEngine.js | 85 ++++++++++++++++----------- src/composables/useStatsData.js | 19 ++++-- src/statsEngine.js | 27 ++++----- test/backgroundEngine.spec.js | 64 +++++++++++++++++++- test/composables/useStatsData.spec.js | 40 ++++++++++++- test/helpers/messenger.js | 1 + 6 files changed, 179 insertions(+), 57 deletions(-) diff --git a/src/backgroundEngine.js b/src/backgroundEngine.js index 5bc23af..5634463 100644 --- a/src/backgroundEngine.js +++ b/src/backgroundEngine.js @@ -1,27 +1,40 @@ -// background-script orchestration: schedules periodic stats reprocessing via -// messenger.alarms so it runs independent of the Stats page being open (#381). +// background-script: schedules periodic stats reprocessing via messenger.alarms independent from other pages import { defaultOptions } from '@/definitions.js'; import { filterActiveAccounts } from '@/utils.js'; -import { buildAllIdentities, PROCESSING_STORAGE_KEY, reprocessAccount } from '@/statsEngine.js'; +import { + buildAllIdentities, + PAGE_PROCESSING_STORAGE_KEY, + PROCESSING_STORAGE_KEY, + reprocessAccount, +} from '@/statsEngine.js'; export const ALARM_NAME = 'third-stats-refresh'; // in-memory guard against overlapping refresh runs (mirrors isLoading.value on the Stats page) let refreshing = false; +// mirrors whether any open Stats page (useStatsData.js) is currently processing, so the badge +// also reflects page-driven activity, not just this script's own scheduled refreshes +let pageProcessing = false; + // id of the spaces-toolbar entry, captured once registerSpacesIcon() resolves - used to -// badge the icon while a scheduled refresh is running, the only visual indication of -// background activity when no ThirdStats page happens to be open +// badge the icon while a scheduled refresh is running as visual activity indication let spaceId = null; +// tracked so setBadgeProcessing can wait it out +let spacesReady = null; + const setBadgeProcessing = async (isProcessing) => { + await spacesReady; if (!messenger.spaces || spaceId === null) return; await messenger.spaces.update(spaceId, {}, { badgeText: isProcessing ? '•' : '' }); }; -// get all add-on settings needed for background processing, applying the same -// normalization useStatsData.js's getOptions() applies (addresses is stored as a -// comma-separated string, not an array) +// recomputes the badge from both activity sources combined, so finishing one doesn't clear the +// badge while the other is still running +const updateBadge = async () => setBadgeProcessing(refreshing || pageProcessing); + +// get all add-on settings needed for background processing export const getOptions = async () => { const result = await messenger.storage.local.get('options'); const o = result?.options ?? {}; @@ -56,7 +69,7 @@ export const runScheduledRefresh = async () => { if (refreshing) return; refreshing = true; await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: true }); - await setBadgeProcessing(true); + await updateBadge(); try { const options = await getOptions(); const allAccounts = await messenger.accounts.list(); @@ -68,32 +81,29 @@ export const runScheduledRefresh = async () => { } finally { refreshing = false; await messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: false }); - await setBadgeProcessing(false); + await updateBadge(); } }; // registers the Stats page in Thunderbird's spaces toolbar export const registerSpacesIcon = async () => { - if (messenger.spaces) { - const space = await messenger.spaces.create('third_stats', '../index.stats.html', { - badgeBackgroundColor: '#e64db9', - badgeText: '', - defaultIcons: '../icon.svg', - themeIcons: [ - { - dark: '../icon-dark.svg', - light: '../icon-light.svg', - size: 16, - }, - { - dark: '../icon-dark.svg', - light: '../icon-light.svg', - size: 32, - }, - ], - title: 'ThirdStats', - }); + if (!messenger.spaces) return; + const buttonProperties = { + badgeBackgroundColor: '#e64db9', + badgeText: '', + defaultIcons: '../icon.svg', + title: 'ThirdStats', + }; + try { + const space = await messenger.spaces.create('third_stats', '../index.stats.html', buttonProperties); spaceId = space.id; + } catch { + // Look up the existing space to reuse if applicable + const [existing] = await messenger.spaces.query({ name: 'third_stats', isSelfOwned: true }); + if (existing) { + spaceId = existing.id; + await messenger.spaces.update(spaceId, {}, buttonProperties); + } } }; @@ -105,15 +115,20 @@ export const initBackground = () => { }); messenger.storage.onChanged.addListener((changes, area) => { if (area === 'local' && changes.options) syncAlarm(); + if (area === 'local' && changes[PAGE_PROCESSING_STORAGE_KEY]) { + pageProcessing = !!changes[PAGE_PROCESSING_STORAGE_KEY].newValue; + updateBadge(); + } }); - registerSpacesIcon(); + spacesReady = registerSpacesIcon(); // also sync once at load, not just on onInstalled/onStartup, so a reload during // development or a version bump doesn't leave no alarm registered syncAlarm(); - // PROCESSING_STORAGE_KEY is only ever set true by this module itself (see - // runScheduledRefresh), so at this point in a fresh background-script lifetime nothing - // can legitimately be mid-run yet - reset it in case a previous lifetime was killed - // before reaching its finally block and left the flag (and the Stats page's disabled - // refresh button) stuck true forever + // PROCESSING_STORAGE_KEY is only ever set true by this module itself (see runScheduledRefresh) messenger.storage.local.set({ [PROCESSING_STORAGE_KEY]: false }); + // pick up a Stats page that's already mid-refresh when this script (re)starts + messenger.storage.local.get(PAGE_PROCESSING_STORAGE_KEY).then((result) => { + pageProcessing = !!result[PAGE_PROCESSING_STORAGE_KEY]; + if (pageProcessing) updateBadge(); + }); }; diff --git a/src/composables/useStatsData.js b/src/composables/useStatsData.js index 09891c7..54698d1 100644 --- a/src/composables/useStatsData.js +++ b/src/composables/useStatsData.js @@ -14,6 +14,7 @@ import { } from '@/composables/statsAggregation.js'; import { buildAllIdentities, + PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY, reprocessAccount as engineReprocessAccount, } from '@/statsEngine.js'; @@ -60,9 +61,9 @@ export function useStatsData() { max: 0, // upper limit for progress indicator }); - // true while the background script (src/backgroundEngine.js) is running a scheduled - // refresh - read-only here, synced from messenger.storage.local; used to disable the - // manual refresh action so it can't start a second concurrent pass over the same accounts + // true while the background script (src/backgroundEngine.js) is running a scheduled refresh - read-only here, synced + // from messenger.storage.local and used to disable the manual refresh action so it can't start a second concurrent + // pass over the same accounts const backgroundBusy = ref(false); // preferences for stats page configuration @@ -629,6 +630,12 @@ export function useStatsData() { } ); + // mirror this page's own loading state to storage, so the background script can also badge + // the spaces-toolbar icon for page-driven activity, not just its own scheduled refreshes + watch(isLoading, (loading) => { + messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: loading }); + }); + // bootstraps the engine - call once from onMounted const init = async () => { // set initial tab title @@ -647,9 +654,9 @@ export function useStatsData() { // pick up whether a background refresh is already in flight when this page opens const { [PROCESSING_STORAGE_KEY]: initialProcessing } = await messenger.storage.local.get(PROCESSING_STORAGE_KEY); backgroundBusy.value = !!initialProcessing; - // periodic auto-refresh is scheduled by the background script (src/backgroundEngine.js) - // via messenger.alarms, independent of this page being open - see addStorageListener() - // above for how this page picks up the background-written cache updates live + // a page closed mid-refresh never gets to clear PAGE_PROCESSING_STORAGE_KEY itself, which + // would otherwise leave the spaces-toolbar badge stuck on - reset it opportunistically here + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: false }); }; return { diff --git a/src/statsEngine.js b/src/statsEngine.js index 9d7456b..9f725ee 100644 --- a/src/statsEngine.js +++ b/src/statsEngine.js @@ -1,19 +1,19 @@ -// portable stats fetch/aggregate/cache orchestration - no Vue/DOM dependencies. -// Callable identically from the Stats page (via useStatsData.js) and from the -// background script (via backgroundEngine.js), so the "reprocess an account" logic -// exists in exactly one place regardless of what triggered it. +// Portable stats fetch/aggregate/cache - no Vue/DOM dependencies. +// Callable from the Stats page (via useStatsData.js) and from the background script (via backgroundEngine.js) import { accentColors } from '@/definitions.js'; import { flattenSubfolders, queryMessages, sortAndLimitObject, statsCacheKey, traverseAccount } from '@/utils.js'; import { analyzeMessage, createStatsData } from '@/composables/statsAggregation.js'; -// messenger.storage.local key set by backgroundEngine.js while a scheduled refresh is in -// flight, and read by useStatsData.js to disable the manual refresh action meanwhile - -// prevents a user-triggered reprocess from running concurrently against the same account +// messenger.storage.local key set by backgroundEngine.js while a scheduled refresh is in progress. +// Prevents a user-triggered reprocess from running concurrently against the same account export const PROCESSING_STORAGE_KEY = 'statsProcessing'; -// combines identities of every account in with configured local -// (already-normalized lowercase array) - used for self-message context -// and for the folder/contact filter dropdowns +// messenger.storage.local key set by useStatsData.js while the Stats page is processing (manual +// refresh or filter change). backgroundEngine.js watches this to also badge the spaces-toolbar +// icon for page-driven activity, not just its own scheduled refreshes +export const PAGE_PROCESSING_STORAGE_KEY = 'statsPageProcessing'; + +// combines identities of every account in with configured local (lowercase) export function buildAllIdentities(accountList, addresses) { let activeIdentities = accountList.reduce((p, c) => p.concat(c.identities.map((i) => i.email.toLowerCase())), []); if (addresses.length && accountList.some((a) => ['none', 'local'].includes(a.type))) { @@ -117,10 +117,9 @@ export async function processAccount(account, addonOptions, filters = {}, hooks return { accountData, foldersList, error: err }; } -// fetch account , reprocess its data, and persist to the stats- cache -// when addonOptions.cache is enabled and no filter is active. Returns -// { accountData, foldersList, error } - this is the single entry point callable -// identically from the Stats page and from the background script. +// fetch account , reprocess its data, and persist to the stats- cache when addonOptions.cache is enabled +// and no filter is active. +// Returns { accountData, foldersList, error } // additionally accepts filterIsActive (boolean) export async function reprocessAccount(accountId, addonOptions, filters = {}, hooks = {}) { const account = await messenger.accounts.get(accountId); diff --git a/test/backgroundEngine.spec.js b/test/backgroundEngine.spec.js index 80b0548..36ff2dc 100644 --- a/test/backgroundEngine.spec.js +++ b/test/backgroundEngine.spec.js @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ALARM_NAME, initBackground, runScheduledRefresh, syncAlarm } from '@/backgroundEngine.js'; import { defaultOptions } from '@/definitions.js'; -import { PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; +import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; import { createMockMessenger } from './helpers/messenger.js'; @@ -206,6 +206,68 @@ describe('runScheduledRefresh - processing indicator', () => { expect(messenger.spaces.update).toHaveBeenNthCalledWith(1, spaceId, {}, { badgeText: '•' }); expect(messenger.spaces.update).toHaveBeenNthCalledWith(2, spaceId, {}, { badgeText: '' }); }); + + it('also badges the icon when a Stats page sets the page-processing flag, independent of a scheduled refresh', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + + initBackground(); + await vi.waitFor(() => expect(messenger.spaces.create).toHaveBeenCalled()); + const spaceId = (await messenger.spaces.create.mock.results[0].value).id; + + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: true }); + await vi.waitFor(() => expect(messenger.spaces.update).toHaveBeenCalledWith(spaceId, {}, { badgeText: '•' })); + + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: false }); + await vi.waitFor(() => expect(messenger.spaces.update).toHaveBeenLastCalledWith(spaceId, {}, { badgeText: '' })); + }); + + it('keeps the badge on when the scheduled refresh finishes while a Stats page is still processing', async () => { + const messenger = setupMessenger(); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + + initBackground(); + await vi.waitFor(() => expect(messenger.spaces.create).toHaveBeenCalled()); + const spaceId = (await messenger.spaces.create.mock.results[0].value).id; + + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: true }); + await vi.waitFor(() => expect(messenger.spaces.update).toHaveBeenCalledWith(spaceId, {}, { badgeText: '•' })); + + await runScheduledRefresh(); + + // the background run finished, but the page is still processing - badge must stay on + expect(messenger.spaces.update).toHaveBeenLastCalledWith(spaceId, {}, { badgeText: '•' }); + + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: false }); + await vi.waitFor(() => expect(messenger.spaces.update).toHaveBeenLastCalledWith(spaceId, {}, { badgeText: '' })); + }); +}); + +describe('registerSpacesIcon - reload recovery', () => { + it('falls back to querying the existing space when create() rejects because it already exists', async () => { + const messenger = setupMessenger({ + spaces: { + create: vi.fn(async () => { + throw new Error('Failed to create space with name third_stats: Space already exists for this extension.'); + }), + query: vi.fn(async () => [{ id: 7, isBuiltIn: false, isSelfOwned: true, name: 'third_stats' }]), + }, + }); + vi.stubGlobal('messenger', messenger); + await messenger.storage.local.set({ options: { ...defaultOptions, cache: true } }); + + initBackground(); + await vi.waitFor(() => + expect(messenger.spaces.query).toHaveBeenCalledWith({ name: 'third_stats', isSelfOwned: true }) + ); + expect(messenger.spaces.update).toHaveBeenCalledWith(7, {}, expect.objectContaining({ title: 'ThirdStats' })); + + await runScheduledRefresh(); + + expect(messenger.spaces.update).toHaveBeenCalledWith(7, {}, { badgeText: '•' }); + expect(messenger.spaces.update).toHaveBeenCalledWith(7, {}, { badgeText: '' }); + }); }); describe('initBackground - stuck-flag recovery', () => { diff --git a/test/composables/useStatsData.spec.js b/test/composables/useStatsData.spec.js index c9585e9..eb8111c 100644 --- a/test/composables/useStatsData.spec.js +++ b/test/composables/useStatsData.spec.js @@ -9,7 +9,7 @@ vi.mock('vue-i18n', () => ({ import { useStatsData } from '@/composables/useStatsData.js'; import { defaultOptions } from '@/definitions.js'; -import { PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; +import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; import { createMockMessenger } from '../helpers/messenger.js'; @@ -532,6 +532,44 @@ describe('useStatsData - backgroundBusy', () => { }); }); +describe('useStatsData - page processing flag', () => { + it('writes PAGE_PROCESSING_STORAGE_KEY true while reprocessing and false once done, so the background script can also badge for page-driven activity', async () => { + const list = vi.fn(async () => ({ id: null, messages: [makeMessage()] })); + const messenger = setupMessenger({ + folders: { get: vi.fn(async () => ({ isRoot: true, subFolders: [inboxFolder] })) }, + messages: { list }, + }); + await messenger.storage.local.set({ options: baseOptions }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + messenger.storage.local.set.mockClear(); + + await engine.loadAccount(fakeAccount.id, true); + + const flagWrites = messenger.storage.local.set.mock.calls + .map(([items]) => items[PAGE_PROCESSING_STORAGE_KEY]) + .filter((v) => v !== undefined); + expect(flagWrites).toEqual([true, false]); + }); + + it('resets a leftover "true" flag at startup, in case a previous page closed mid-refresh', async () => { + const messenger = setupMessenger(); + await messenger.storage.local.set({ options: baseOptions }); + await messenger.storage.local.set({ [PAGE_PROCESSING_STORAGE_KEY]: true }); + stubEnvironment(messenger); + + const engine = useStatsData(); + await engine.init(); + await flushPending(); + + const { [PAGE_PROCESSING_STORAGE_KEY]: flag } = await messenger.storage.local.get(PAGE_PROCESSING_STORAGE_KEY); + expect(flag).toBe(false); + }); +}); + describe('useStatsData - summed view across accounts', () => { it('combines numbers and builds per-account comparison data for the "sum" view', async () => { const accountA = { diff --git a/test/helpers/messenger.js b/test/helpers/messenger.js index 4153444..4b2dfec 100644 --- a/test/helpers/messenger.js +++ b/test/helpers/messenger.js @@ -88,6 +88,7 @@ export function createMockMessenger(overrides = {}) { spaces: { create: vi.fn(async () => ({ id: 1, isBuiltIn: false, isSelfOwned: true, name: 'third_stats' })), update: vi.fn(async () => {}), + query: vi.fn(async () => []), }, i18n: { getUILanguage: vi.fn(() => 'en'), From 4cfe1c063461192b3811a677ca03e1f878ee6630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sun, 23 Aug 2026 23:48:42 +0200 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=94=A8=20Move=20to=20dedicated=20engi?= =?UTF-8?q?nes=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/background.js | 2 +- src/composables/useStatsData.js | 4 ++-- src/{ => engines}/backgroundEngine.js | 2 +- src/{ => engines}/statsEngine.js | 0 test/composables/useStatsData.spec.js | 2 +- test/{ => engines}/backgroundEngine.spec.js | 6 +++--- test/{ => engines}/statsEngine.spec.js | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) rename src/{ => engines}/backgroundEngine.js (99%) rename src/{ => engines}/statsEngine.js (100%) rename test/{ => engines}/backgroundEngine.spec.js (98%) rename test/{ => engines}/statsEngine.spec.js (98%) diff --git a/src/background.js b/src/background.js index 10cdf9b..3b01587 100644 --- a/src/background.js +++ b/src/background.js @@ -1,3 +1,3 @@ -import { initBackground } from '@/backgroundEngine.js'; +import { initBackground } from '@/engines/backgroundEngine.js'; initBackground(); diff --git a/src/composables/useStatsData.js b/src/composables/useStatsData.js index 54698d1..09e603f 100644 --- a/src/composables/useStatsData.js +++ b/src/composables/useStatsData.js @@ -17,7 +17,7 @@ import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY, reprocessAccount as engineReprocessAccount, -} from '@/statsEngine.js'; +} from '@/engines/statsEngine.js'; export function useStatsData() { const { t } = useI18n(); @@ -61,7 +61,7 @@ export function useStatsData() { max: 0, // upper limit for progress indicator }); - // true while the background script (src/backgroundEngine.js) is running a scheduled refresh - read-only here, synced + // true while the background script (src/engines/backgroundEngine.js) is running a scheduled refresh - read-only here, synced // from messenger.storage.local and used to disable the manual refresh action so it can't start a second concurrent // pass over the same accounts const backgroundBusy = ref(false); diff --git a/src/backgroundEngine.js b/src/engines/backgroundEngine.js similarity index 99% rename from src/backgroundEngine.js rename to src/engines/backgroundEngine.js index 5634463..e63c356 100644 --- a/src/backgroundEngine.js +++ b/src/engines/backgroundEngine.js @@ -6,7 +6,7 @@ import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY, reprocessAccount, -} from '@/statsEngine.js'; +} from '@/engines/statsEngine.js'; export const ALARM_NAME = 'third-stats-refresh'; diff --git a/src/statsEngine.js b/src/engines/statsEngine.js similarity index 100% rename from src/statsEngine.js rename to src/engines/statsEngine.js diff --git a/test/composables/useStatsData.spec.js b/test/composables/useStatsData.spec.js index eb8111c..a04dbd7 100644 --- a/test/composables/useStatsData.spec.js +++ b/test/composables/useStatsData.spec.js @@ -9,7 +9,7 @@ vi.mock('vue-i18n', () => ({ import { useStatsData } from '@/composables/useStatsData.js'; import { defaultOptions } from '@/definitions.js'; -import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; +import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/engines/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; import { createMockMessenger } from '../helpers/messenger.js'; diff --git a/test/backgroundEngine.spec.js b/test/engines/backgroundEngine.spec.js similarity index 98% rename from test/backgroundEngine.spec.js rename to test/engines/backgroundEngine.spec.js index 36ff2dc..563ea27 100644 --- a/test/backgroundEngine.spec.js +++ b/test/engines/backgroundEngine.spec.js @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { ALARM_NAME, initBackground, runScheduledRefresh, syncAlarm } from '@/backgroundEngine.js'; +import { ALARM_NAME, initBackground, runScheduledRefresh, syncAlarm } from '@/engines/backgroundEngine.js'; import { defaultOptions } from '@/definitions.js'; -import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/statsEngine.js'; +import { PAGE_PROCESSING_STORAGE_KEY, PROCESSING_STORAGE_KEY } from '@/engines/statsEngine.js'; import { statsCacheKey } from '@/utils.js'; -import { createMockMessenger } from './helpers/messenger.js'; +import { createMockMessenger } from '../helpers/messenger.js'; const fakeAccount = { id: 'acc-1', diff --git a/test/statsEngine.spec.js b/test/engines/statsEngine.spec.js similarity index 98% rename from test/statsEngine.spec.js rename to test/engines/statsEngine.spec.js index 33de50c..7b9a4ef 100644 --- a/test/statsEngine.spec.js +++ b/test/engines/statsEngine.spec.js @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { buildAllIdentities, processAccount, processMessages, reprocessAccount } from '@/statsEngine.js'; +import { buildAllIdentities, processAccount, processMessages, reprocessAccount } from '@/engines/statsEngine.js'; import { createStatsData } from '@/composables/statsAggregation.js'; import { statsCacheKey } from '@/utils.js'; -import { createMockMessenger } from './helpers/messenger.js'; +import { createMockMessenger } from '../helpers/messenger.js'; const fakeAccount = { id: 'acc-1', From 23097bf42b6d5cec61f35e21b340bde4da65a156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sun, 23 Aug 2026 23:52:21 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=94=A8=20Use=20@=20alias=20for=20impo?= =?UTF-8?q?rts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/index.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ui/index.js b/src/ui/index.js index 1489fa8..66025af 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -1,13 +1,13 @@ -import TsIcon from './TsIcon.vue'; -import TsButton from './TsButton.vue'; -import TsCheckbox from './TsCheckbox.vue'; -import TsSwitch from './TsSwitch.vue'; -import TsInputGroup from './TsInputGroup.vue'; -import TsTag from './TsTag.vue'; -import TsLoader from './TsLoader.vue'; -import TsSelect from './TsSelect.vue'; -import TsCharInput from './TsCharInput.vue'; -import TsColorInput from './TsColorInput.vue'; +import TsIcon from '@/ui/TsIcon.vue'; +import TsButton from '@/ui/TsButton.vue'; +import TsCheckbox from '@/ui/TsCheckbox.vue'; +import TsSwitch from '@/ui/TsSwitch.vue'; +import TsInputGroup from '@/ui/TsInputGroup.vue'; +import TsTag from '@/ui/TsTag.vue'; +import TsLoader from '@/ui/TsLoader.vue'; +import TsSelect from '@/ui/TsSelect.vue'; +import TsCharInput from '@/ui/TsCharInput.vue'; +import TsColorInput from '@/ui/TsColorInput.vue'; import vTooltip from '@/directives/tooltip.js'; export default { From eb5f1e776e760afb9c316e53ef60035f1d23cabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Mon, 24 Aug 2026 00:01:38 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=9C=20Update=20documentation=20on?= =?UTF-8?q?=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- SECURITY.md | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8711075..f30a4f9 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,11 @@ ThirdStats runs entirely locally and will never contact, send or sell data to, a ThirdStats features require [Thunderbird permissions](https://developer.thunderbird.net/add-ons/mailextensions/supported-webextension-api) to function, but will always seeks to minimise permissions where possible, and use granular permissions where available (Example: Thunderbird Permissions API does not currently provide for reading only message headers, instead of the entire email, including the body): - Accounts: Access accounts and identities (read-only) +- Alarms: Schedule periodic background reprocessing of stats data. Can be disabled or adjusted in the add-on options. - Downloads: Export and download data as a file - Messages: Access messages to create statistics (read-only) -- Storage: Store processed data (cache) for performance. Can be cleared or disabled at any time. +- Messages Tags: Read the list of user-defined message tags (name and color) to enrich stats +- Storage: Store options and processed data (cache) for performance. Can be cleared or disabled at any time. See the [Security Policy](./SECURITY.md) for details how ThirdStats values your privacy. diff --git a/SECURITY.md b/SECURITY.md index 409c09e..9cf2036 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,11 +20,14 @@ ThirdStats does store the processed stats data in Thunderbirds own extension sto ### 3. What exactly are all the permissions used for? -ThirdStats needs 3 permissions to work: +ThirdStats needs 6 permissions to work: - `accountsRead`: _"See your mail accounts and their folders"_ - This is needed to iterate over all messages in all folders of your Thunderbird accounts to count and process them. - `messagesRead`: _"Read your email messages and mark or tag them"_ - This is needed to read the message header and retrieve the following information from it: _author_, _bccList_, _ccList_, _date_, _read_, _recipients_. ThirdStats never reads the email body or marks/tags emails. +- `storage`: _"Store unlimited amount of client-side data"_ - This is needed to store your configured options and the ThirdStats cache (processed stats data), so it doesn't need to be reprocessed on every visit. Can be cleared or disabled at any time in the add-on options. - `downloads`: _"Download files and read and modify the browser’s download history"_ - This is needed to export processed stats data as a JSON file and provide it as a file download. ThirdStats never reads or modifies the download history. +- `messagesTagsList`: _"Read your tags on messages"_ - This is needed to retrieve the name and color of user-defined message tags, so they can be used to enrich stats. ThirdStats never reads, sets or removes tags on individual messages. +- `alarms`: _"Schedule timed, periodic, or single trigger events"_ - This is needed to periodically reprocess stats data in the background, if automatic processing is enabled in the add-on options. ThirdStats never uses this to run code outside of that scheduled reprocessing. ### 4. Does it run as a web server with an open port which would expose it to vulnerabilities?