From 1360abd6df2e407542a899fbd753f08485af51c0 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 17:34:45 -0700 Subject: [PATCH] Rewrite the small helpers and their tests Sizes and durations, the list filter, development logging, the logo cache, last visited pages, the Sieve playground link, JMAP error messages, the JMAP list helper, cn, the base path, the document title and the buffered value hooks are rewritten from what they do, with new tests written first: debug, the logo cache, last visited, JMAP errors and the JMAP list helper had none. Behavior, exports and strings are unchanged; the playground link keeps its format and last visited keeps its storage key. --- src/hooks/useBufferedValue.ts | 29 ++-- src/hooks/useDocumentTitle.ts | 14 +- src/lib/basePath.ts | 17 +- src/lib/debug.test.ts | 44 +++++ src/lib/debug.ts | 140 +++++++--------- src/lib/durationFormat.test.ts | 284 +++++++++------------------------ src/lib/durationFormat.ts | 120 +++++--------- src/lib/jmapErrors.test.ts | 45 ++++++ src/lib/jmapErrors.ts | 93 +++++------ src/lib/jmapUtils.test.ts | 26 +++ src/lib/jmapUtils.ts | 23 +-- src/lib/lastVisited.test.ts | 74 +++++++++ src/lib/lastVisited.ts | 35 ++-- src/lib/listFilter.test.ts | 205 ++++++++---------------- src/lib/listFilter.ts | 107 +++++-------- src/lib/logoCache.test.ts | 45 ++++++ src/lib/logoCache.ts | 55 ++++--- src/lib/sievepad.test.ts | 63 ++++---- src/lib/sievepad.ts | 76 ++++----- src/lib/utils.ts | 9 +- 20 files changed, 728 insertions(+), 776 deletions(-) create mode 100644 src/lib/debug.test.ts create mode 100644 src/lib/jmapErrors.test.ts create mode 100644 src/lib/jmapUtils.test.ts create mode 100644 src/lib/lastVisited.test.ts create mode 100644 src/lib/logoCache.test.ts diff --git a/src/hooks/useBufferedValue.ts b/src/hooks/useBufferedValue.ts index ed605d2..0f13e2d 100644 --- a/src/hooks/useBufferedValue.ts +++ b/src/hooks/useBufferedValue.ts @@ -1,28 +1,35 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { useState, type Dispatch, type SetStateAction } from 'react'; +/** + * A local copy of `value` that a field edits freely (half-typed text, say), + * replaced whenever `value` itself changes. `transform` turns the value into + * what the field edits. The copy is replaced during render, not in an + * effect, so the field never shows the old value for a frame. + */ export function useBufferedValue( value: T, transform: (v: T) => S = (v) => v as unknown as S, ): [S, Dispatch>] { - const [prev, setPrev] = useState(value); - const [local, setLocal] = useState(() => transform(value)); - if (!Object.is(value, prev)) { - setPrev(value); - setLocal(transform(value)); + const [source, setSource] = useState(value); + const [draft, setDraft] = useState(() => transform(value)); + if (!Object.is(source, value)) { + setSource(value); + setDraft(transform(value)); } - return [local, setLocal]; + return [draft, setDraft]; } +/** Calls `reset` during render whenever `key` changes, to drop state tied to the old key. */ export function useResetOnChange(key: K, reset: () => void): void { - const [prev, setPrev] = useState(key); - if (!Object.is(key, prev)) { - setPrev(key); + const [seen, setSeen] = useState(key); + if (!Object.is(seen, key)) { + setSeen(key); reset(); } } diff --git a/src/hooks/useDocumentTitle.ts b/src/hooks/useDocumentTitle.ts index e7643c9..2199932 100644 --- a/src/hooks/useDocumentTitle.ts +++ b/src/hooks/useDocumentTitle.ts @@ -1,21 +1,19 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ import { useEffect } from 'react'; -const APP_NAME = 'inbuxa Admin'; +const APP = 'inbuxa Admin'; -export function useDocumentTitle(title?: string | null) { +/** Titles the browser tab "Page · inbuxa Admin" while the page is shown. */ +export function useDocumentTitle(title?: string | null): void { useEffect(() => { - document.title = title ? `${title} · ${APP_NAME}` : APP_NAME; + document.title = title ? `${title} · ${APP}` : APP; return () => { - document.title = APP_NAME; + document.title = APP; }; }, [title]); } diff --git a/src/lib/basePath.ts b/src/lib/basePath.ts index 7cfcef1..87f7157 100644 --- a/src/lib/basePath.ts +++ b/src/lib/basePath.ts @@ -1,15 +1,16 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ -let cached: string | undefined; +let basePath: string | undefined; +/** + * Where the console is served, from the page's ``, without the + * trailing slash: '' at the root, '/admin' under a path. Read once. + */ export function getBasePath(): string { - if (cached !== undefined) return cached; - - const base = document.querySelector('base')?.getAttribute('href') ?? '/'; - cached = base.replace(/\/+$/, ''); - return cached; + basePath ??= (document.querySelector('base')?.getAttribute('href') ?? '/').replace(/\/+$/, ''); + return basePath; } diff --git a/src/lib/debug.test.ts b/src/lib/debug.test.ts new file mode 100644 index 0000000..7523bf2 --- /dev/null +++ b/src/lib/debug.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { describeInvocations, flagOn } from './debug'; + +describe('flagOn', () => { + it('turns on for any value but empty, 0 or false, in development only', () => { + expect(flagOn('1', true)).toBe(true); + expect(flagOn('yes', true)).toBe(true); + expect(flagOn('FALSE', true)).toBe(false); + expect(flagOn('0', true)).toBe(false); + expect(flagOn('', true)).toBe(false); + expect(flagOn(undefined, true)).toBe(false); + expect(flagOn('1', false)).toBe(false); + }); +}); + +describe('describeInvocations', () => { + it('sums up calls and responses', () => { + expect( + describeInvocations([ + ['x:Domain/get', { accountId: 'a', ids: null }, 'c0'], + ['x:Domain/set', { update: { d1: {}, d2: {} }, destroy: ['d3'] }, 'c1'], + ['x:Domain/query', { ids: ['a', 'b'], total: 2 }, 'c2'], + ['error', { type: 'forbidden' }, 'c3'], + ]), + ).toEqual([ + { method: 'x:Domain/get', callId: 'c0', ids: 'all' }, + { method: 'x:Domain/set', callId: 'c1', update: 2, destroy: 1 }, + { method: 'x:Domain/query', callId: 'c2', ids: 2, total: 2 }, + { method: 'error', callId: 'c3', error: 'forbidden' }, + ]); + }); + + it('copes with anything that is not a list of invocations', () => { + expect(describeInvocations(undefined)).toEqual([]); + expect(describeInvocations(['oops'])).toEqual([{ method: '?', callId: '?' }]); + }); +}); diff --git a/src/lib/debug.ts b/src/lib/debug.ts index 9519788..b450478 100644 --- a/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -1,108 +1,80 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ -const isDev = import.meta.env.DEV; +/** + * Console logging for development builds, switched on in `.env.local`: + * + * VITE_DEBUG_JMAP=1 every JMAP request and its responses + * VITE_DEBUG_FORMS=1 every form change, as the data that would be saved + * + * Production builds never log, whatever the variables say. + */ -function isEnabled(envVar: string | undefined): boolean { - if (!isDev || !envVar) return false; - const v = envVar.toLowerCase(); - return v !== '' && v !== 'false' && v !== '0'; +/** Whether a debug variable turns logging on: set, and not "0" or "false". */ +export function flagOn(value: string | undefined, dev = import.meta.env.DEV): boolean { + return dev && !!value && !['0', 'false'].includes(value.toLowerCase()); } -export const debugJmap = isEnabled(import.meta.env.VITE_DEBUG_JMAP); -export const debugForms = isEnabled(import.meta.env.VITE_DEBUG_FORMS); +export const debugJmap = flagOn(import.meta.env.VITE_DEBUG_JMAP); +export const debugForms = flagOn(import.meta.env.VITE_DEBUG_FORMS); -type JmapCall = [string, Record, string]; +const count = (v: unknown) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0); -function summarizeArgs(args: Record): string { - const parts: string[] = []; - if (typeof args.accountId === 'string') { - parts.push(`account=${truncate(args.accountId, 12)}`); - } - if (Array.isArray(args.ids)) { - parts.push(`ids=${args.ids.length}`); - } else if (args.ids === null) { - parts.push('ids=all'); - } - if (args.create && typeof args.create === 'object') { - parts.push(`create=${Object.keys(args.create).length}`); - } - if (args.update && typeof args.update === 'object') { - parts.push(`update=${Object.keys(args.update).length}`); - } - if (Array.isArray(args.destroy)) { - parts.push(`destroy=${args.destroy.length}`); - } - if (Array.isArray(args.list)) { - parts.push(`list=${args.list.length}`); - } - if (typeof args.total === 'number') { - parts.push(`total=${args.total}`); - } - return parts.join(' '); -} - -function truncate(s: string, max: number): string { - return s.length > max ? s.slice(0, max - 1) + '…' : s; -} - -function buildSummaryRows(calls: unknown): Array> { - if (!Array.isArray(calls)) return []; - return calls.map((call, idx) => { - if (!Array.isArray(call) || call.length < 3) { - return { '#': String(idx), method: '?', callId: '?', summary: '' }; +/** One row per method call or response: what it is and how much it touches. */ +export function describeInvocations(invocations: unknown): Record[] { + if (!Array.isArray(invocations)) return []; + return invocations.map((inv) => { + const [name, args, callId] = Array.isArray(inv) ? inv : []; + const a: Record = args && typeof args === 'object' ? args : {}; + const row: Record = { method: String(name ?? '?'), callId: String(callId ?? '?') }; + if (a.ids === null) row.ids = 'all'; + else if (Array.isArray(a.ids)) row.ids = a.ids.length; + for (const key of [ + 'create', + 'update', + 'destroy', + 'list', + 'created', + 'updated', + 'destroyed', + 'notCreated', + 'notUpdated', + 'notDestroyed', + ]) { + if (a[key] != null && count(a[key])) row[key] = count(a[key]); } - const [method, args, callId] = call as JmapCall; - return { - '#': String(idx), - method, - callId, - summary: args && typeof args === 'object' ? summarizeArgs(args) : '', - }; + if (typeof a.total === 'number') row.total = a.total; + if (name === 'error' && typeof a.type === 'string') row.error = a.type; + return row; }); } export function logJmapExchange(methodCalls: unknown, methodResponses: unknown, durationMs?: number): void { if (!debugJmap) return; - - const hasError = Array.isArray(methodResponses) && methodResponses.some((r) => Array.isArray(r) && r[0] === 'error'); - - const methodNames = Array.isArray(methodCalls) - ? methodCalls.map((c) => (Array.isArray(c) && typeof c[0] === 'string' ? c[0] : '?')).join(' · ') - : '?'; - - const durationLabel = typeof durationMs === 'number' ? ` (${Math.round(durationMs)}ms)` : ''; - - const headerColor = hasError ? '#dc2626' : '#2563eb'; - + const responses = describeInvocations(methodResponses); + const failed = responses.some((r) => r.method === 'error'); + const names = describeInvocations(methodCalls) + .map((r) => r.method) + .join(', '); + const took = durationMs === undefined ? '' : ` ${Math.round(durationMs)} ms`; console.groupCollapsed( - `%c[JMAP]%c ${methodNames}${durationLabel}${hasError ? ' (with errors)' : ''}`, - `color: ${headerColor}; font-weight: bold`, - 'color: inherit', + `%cJMAP%c ${names}${took}${failed ? ' — error' : ''}`, + `color:${failed ? '#dc2626' : '#0d9488'};font-weight:600`, + '', ); - - console.groupCollapsed('%c→ request', 'color: #2563eb; font-weight: bold'); - console.table(buildSummaryRows(methodCalls)); - console.log(JSON.stringify(methodCalls, null, 2)); - console.groupEnd(); - - console.groupCollapsed( - `%c← response${hasError ? ' (with errors)' : ''}`, - `color: ${hasError ? '#dc2626' : '#16a34a'}; font-weight: bold`, - ); - console.table(buildSummaryRows(methodResponses)); - console.log(JSON.stringify(methodResponses, null, 2)); - console.groupEnd(); - + console.table(describeInvocations(methodCalls)); + console.table(responses); + console.debug('request', methodCalls); + console.debug('responses', methodResponses); console.groupEnd(); } export function logFormChange(label: string, data: unknown): void { if (!debugForms) return; - console.groupCollapsed(`%c[Form]%c ${label}`, 'color: #9333ea; font-weight: bold', 'color: inherit'); - console.log(JSON.stringify(data, null, 2)); + console.groupCollapsed(`%cForm%c ${label}`, 'color:#7c3aed;font-weight:600', ''); + console.debug(data); console.groupEnd(); } diff --git a/src/lib/durationFormat.test.ts b/src/lib/durationFormat.test.ts index 50c7373..0d0af6e 100644 --- a/src/lib/durationFormat.test.ts +++ b/src/lib/durationFormat.test.ts @@ -1,229 +1,99 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; + import { - bytesToHuman, - humanToBytes, - formatSize, - msToHuman, - humanToMs, - formatDuration, - SIZE_UNITS, DURATION_UNITS, + SIZE_UNITS, + bytesToHuman, + formatDuration, + formatSize, + humanToBytes, + humanToMs, + msToHuman, } from './durationFormat'; -describe('SIZE_UNITS', () => { - it('should contain the expected units in order', () => { +describe('sizes', () => { + it('lists the units a size field offers, smallest first', () => { expect(SIZE_UNITS).toEqual(['B', 'KB', 'MB', 'GB', 'TB']); }); + + it.each([ + [0, 0, 'B'], + [1, 1, 'B'], + [1023, 1023, 'B'], + [1024, 1, 'KB'], + [1500, 1.46, 'KB'], + [1536, 1.5, 'KB'], + [5 * 1024 ** 2, 5, 'MB'], + [1024 ** 3 + 1, 1, 'GB'], + [3 * 1024 ** 4, 3, 'TB'], + [2048 * 1024 ** 4, 2048, 'TB'], + ])('shows %d bytes as %d %s', (bytes, value, unit) => { + expect(bytesToHuman(bytes)).toEqual({ value, unit }); + }); + + it('leaves values under one byte as bytes', () => { + expect(bytesToHuman(0.5)).toEqual({ value: 0.5, unit: 'B' }); + expect(bytesToHuman(-10)).toEqual({ value: -10, unit: 'B' }); + }); + + it('turns a value and unit back into whole bytes', () => { + expect(humanToBytes(1.5, 'KB')).toBe(1536); + expect(humanToBytes(1.46, 'KB')).toBe(1495); + expect(humanToBytes(2, 'GB')).toBe(2 * 1024 ** 3); + expect(humanToBytes(7, 'B')).toBe(7); + }); + + it('passes the value through for a unit it does not know', () => { + expect(humanToBytes(12, 'PB')).toBe(12); + }); + + it('formats as "value unit"', () => { + expect(formatSize(0)).toBe('0 B'); + expect(formatSize(1536)).toBe('1.5 KB'); + expect(formatSize(50 * 1024 ** 2)).toBe('50 MB'); + }); }); -describe('DURATION_UNITS', () => { - it('should contain the expected units in order', () => { +describe('durations', () => { + it('lists the units a duration field offers, smallest first', () => { expect(DURATION_UNITS).toEqual(['ms', 's', 'min', 'h', 'd']); }); -}); -describe('bytesToHuman', () => { - it('should return 0 B for 0 bytes', () => { - expect(bytesToHuman(0)).toEqual({ value: 0, unit: 'B' }); + it.each([ + [0, 0, 'ms'], + [999, 999, 'ms'], + [1000, 1, 's'], + [1500, 1.5, 's'], + [90_000, 1.5, 'min'], + [3_600_000, 1, 'h'], + [5_400_000, 1.5, 'h'], + [86_400_000, 1, 'd'], + [30 * 86_400_000, 30, 'd'], + [100_000, 1.67, 'min'], + ])('shows %d ms as %d %s', (ms, value, unit) => { + expect(msToHuman(ms)).toEqual({ value, unit }); }); - it('should keep small values in bytes', () => { - expect(bytesToHuman(500)).toEqual({ value: 500, unit: 'B' }); + it('turns a value and unit back into whole milliseconds', () => { + expect(humanToMs(1.5, 's')).toBe(1500); + expect(humanToMs(2, 'min')).toBe(120_000); + expect(humanToMs(1, 'd')).toBe(86_400_000); + expect(humanToMs(1.67, 'min')).toBe(100_200); + expect(humanToMs(9, 'weeks')).toBe(9); }); - it('should keep 512 bytes as B', () => { - expect(bytesToHuman(512)).toEqual({ value: 512, unit: 'B' }); - }); - - it('should convert 1024 bytes to 1 KB', () => { - expect(bytesToHuman(1024)).toEqual({ value: 1, unit: 'KB' }); - }); - - it('should convert 1536 bytes to 1.5 KB', () => { - expect(bytesToHuman(1536)).toEqual({ value: 1.5, unit: 'KB' }); - }); - - it('should convert 1500 bytes to 1.46 KB', () => { - expect(bytesToHuman(1500)).toEqual({ value: 1.46, unit: 'KB' }); - }); - - it('should convert 1048576 bytes to 1 MB', () => { - expect(bytesToHuman(1048576)).toEqual({ value: 1, unit: 'MB' }); - }); - - it('should convert 10485760 bytes to 10 MB', () => { - expect(bytesToHuman(10485760)).toEqual({ value: 10, unit: 'MB' }); - }); - - it('should convert 1073741824 bytes to 1 GB', () => { - expect(bytesToHuman(1073741824)).toEqual({ value: 1, unit: 'GB' }); - }); - - it('should convert 1099511627776 bytes to 1 TB', () => { - expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' }); - }); -}); - -describe('humanToBytes', () => { - it('should convert 1 KB to 1024 bytes', () => { - expect(humanToBytes(1, 'KB')).toBe(1024); - }); - - it('should convert 10 MB to 10485760 bytes', () => { - expect(humanToBytes(10, 'MB')).toBe(10485760); - }); - - it('should convert 1.5 GB to 1610612736 bytes', () => { - expect(humanToBytes(1.5, 'GB')).toBe(1610612736); - }); - - it('should convert 0 B to 0', () => { - expect(humanToBytes(0, 'B')).toBe(0); - }); - - it('should return the raw value for an unknown unit', () => { - expect(humanToBytes(42, 'XYZ')).toBe(42); - }); -}); - -describe('formatSize', () => { - it('should format 0 bytes as "0 B"', () => { - expect(formatSize(0)).toBe('0 B'); - }); - - it('should format 1024 bytes as "1 KB"', () => { - expect(formatSize(1024)).toBe('1 KB'); - }); - - it('should format 1572864 bytes as "1.5 MB"', () => { - expect(formatSize(1572864)).toBe('1.5 MB'); - }); - - it('should format large values in TB', () => { - expect(formatSize(1099511627776)).toBe('1 TB'); - }); -}); - -describe('msToHuman', () => { - it('should return 0 ms for 0', () => { - expect(msToHuman(0)).toEqual({ value: 0, unit: 'ms' }); - }); - - it('should keep 500 as ms', () => { - expect(msToHuman(500)).toEqual({ value: 500, unit: 'ms' }); - }); - - it('should convert 1000 ms to 1 s', () => { - expect(msToHuman(1000)).toEqual({ value: 1, unit: 's' }); - }); - - it('should convert 1500 ms to 1.5 s', () => { - expect(msToHuman(1500)).toEqual({ value: 1.5, unit: 's' }); - }); - - it('should convert 60000 ms to 1 min', () => { - expect(msToHuman(60000)).toEqual({ value: 1, unit: 'min' }); - }); - - it('should convert 300000 ms to 5 min', () => { - expect(msToHuman(300000)).toEqual({ value: 5, unit: 'min' }); - }); - - it('should convert 90000 ms to 1.5 min', () => { - expect(msToHuman(90000)).toEqual({ value: 1.5, unit: 'min' }); - }); - - it('should convert 3600000 ms to 1 h', () => { - expect(msToHuman(3600000)).toEqual({ value: 1, unit: 'h' }); - }); - - it('should convert 86400000 ms to 1 d', () => { - expect(msToHuman(86400000)).toEqual({ value: 1, unit: 'd' }); - }); -}); - -describe('humanToMs', () => { - it('should convert 5 s to 5000 ms', () => { - expect(humanToMs(5, 's')).toBe(5000); - }); - - it('should convert 1 min to 60000 ms', () => { - expect(humanToMs(1, 'min')).toBe(60000); - }); - - it('should convert 2 h to 7200000 ms', () => { - expect(humanToMs(2, 'h')).toBe(7200000); - }); - - it('should convert 1 d to 86400000 ms', () => { - expect(humanToMs(1, 'd')).toBe(86400000); - }); - - it('should return the raw value for an unknown unit', () => { - expect(humanToMs(99, 'eons')).toBe(99); - }); -}); - -describe('formatDuration', () => { - it('should format 0 as "0ms"', () => { + it('spells a duration out by its parts', () => { expect(formatDuration(0)).toBe('0ms'); - }); - - it('should format 500 ms as "500ms"', () => { - expect(formatDuration(500)).toBe('500ms'); - }); - - it('should format 5000 ms as "5s"', () => { - expect(formatDuration(5000)).toBe('5s'); - }); - - it('should format 65000 ms as "1m 5s"', () => { - expect(formatDuration(65000)).toBe('1m 5s'); - }); - - it('should format 3661000 ms as "1h 1m 1s"', () => { - expect(formatDuration(3661000)).toBe('1h 1m 1s'); - }); - - it('should format 86400000 ms as "1d"', () => { - expect(formatDuration(86400000)).toBe('1d'); - }); - - it('should format 90061500 ms as "1d 1h 1m 1s 500ms"', () => { - expect(formatDuration(90061500)).toBe('1d 1h 1m 1s 500ms'); - }); -}); - -describe('round-trip: bytes', () => { - it.each([1024, 1048576, 1073741824, 1099511627776])('bytesToHuman -> humanToBytes should recover %d', (original) => { - const { value, unit } = bytesToHuman(original); - expect(humanToBytes(value, unit)).toBe(original); - }); - - it('should be close for non-exact values', () => { - const original = 123456789; - const { value, unit } = bytesToHuman(original); - const recovered = humanToBytes(value, unit); - expect(Math.abs(recovered - original) / original).toBeLessThan(0.01); - }); -}); - -describe('round-trip: duration', () => { - it.each([1000, 60000, 3600000, 86400000])('msToHuman -> humanToMs should recover %d', (original) => { - const { value, unit } = msToHuman(original); - expect(humanToMs(value, unit)).toBe(original); - }); - - it('should be close for non-exact values', () => { - const original = 123456; - const { value, unit } = msToHuman(original); - const recovered = humanToMs(value, unit); - expect(Math.abs(recovered - original) / original).toBeLessThan(0.01); + expect(formatDuration(250)).toBe('250ms'); + expect(formatDuration(61_000)).toBe('1m 1s'); + expect(formatDuration(3_600_000)).toBe('1h'); + expect(formatDuration(90_061_001)).toBe('1d 1h 1m 1s 1ms'); + expect(formatDuration(2 * 86_400_000 + 30_000)).toBe('2d 30s'); }); }); diff --git a/src/lib/durationFormat.ts b/src/lib/durationFormat.ts index 1c540a7..a45bcc0 100644 --- a/src/lib/durationFormat.ts +++ b/src/lib/durationFormat.ts @@ -1,96 +1,64 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Sizes and durations as people read them: the field widgets show a number + * and a unit, and store bytes or milliseconds. + */ + +type Scale = Record; + +const SIZE_SCALE = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } satisfies Scale; +const DURATION_SCALE = { ms: 1, s: 1000, min: 60_000, h: 3_600_000, d: 86_400_000 } satisfies Scale; + export const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; +export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const; -const SIZE_FACTORS: Record = { - B: 1, - KB: 1024, - MB: 1024 ** 2, - GB: 1024 ** 3, - TB: 1024 ** 4, -}; - -export function bytesToHuman(bytes: number): { value: number; unit: string } { - if (bytes === 0) return { value: 0, unit: 'B' }; - - for (let i = SIZE_UNITS.length - 1; i >= 0; i--) { - const unit = SIZE_UNITS[i]; - const factor = SIZE_FACTORS[unit]; - const v = bytes / factor; - if (v >= 1) { - const rounded = Math.round(v * 100) / 100; - return { value: rounded, unit }; - } - } - - return { value: bytes, unit: 'B' }; +/** The largest unit that keeps the number at 1 or more, to two decimals. */ +function largestUnit(amount: number, scale: Scale, units: readonly string[]) { + const unit = [...units].reverse().find((u) => amount / scale[u] >= 1); + if (!unit) return { value: amount, unit: units[0] }; + return { value: Math.round((amount / scale[unit]) * 100) / 100, unit }; } -export function humanToBytes(value: number, unit: string): number { - const factor = SIZE_FACTORS[unit]; - if (factor === undefined) return value; - return Math.round(value * factor); +/** A number in a unit back to the base amount; an unknown unit leaves it as is. */ +function fromUnit(value: number, unit: string, scale: Scale) { + const factor = scale[unit]; + return factor === undefined ? value : Math.round(value * factor); } +export const bytesToHuman = (bytes: number) => largestUnit(bytes, SIZE_SCALE, SIZE_UNITS); +export const humanToBytes = (value: number, unit: string) => fromUnit(value, unit, SIZE_SCALE); + +/** "1.5 KB". */ export function formatSize(bytes: number): string { const { value, unit } = bytesToHuman(bytes); return `${value} ${unit}`; } -export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const; +export const msToHuman = (ms: number) => largestUnit(ms, DURATION_SCALE, DURATION_UNITS); +export const humanToMs = (value: number, unit: string) => fromUnit(value, unit, DURATION_SCALE); -const DURATION_FACTORS: Record = { - ms: 1, - s: 1_000, - min: 60_000, - h: 3_600_000, - d: 86_400_000, -}; - -export function msToHuman(ms: number): { value: number; unit: string } { - if (ms === 0) return { value: 0, unit: 'ms' }; - - for (let i = DURATION_UNITS.length - 1; i >= 0; i--) { - const unit = DURATION_UNITS[i]; - const factor = DURATION_FACTORS[unit]; - const v = ms / factor; - if (v >= 1) { - const rounded = Math.round(v * 100) / 100; - return { value: rounded, unit }; - } - } - - return { value: ms, unit: 'ms' }; -} - -export function humanToMs(value: number, unit: string): number { - const factor = DURATION_FACTORS[unit]; - if (factor === undefined) return value; - return Math.round(value * factor); -} +const PARTS: [string, number][] = [ + ['d', DURATION_SCALE.d], + ['h', DURATION_SCALE.h], + ['m', DURATION_SCALE.min], + ['s', DURATION_SCALE.s], + ['ms', 1], +]; +/** "1d 2h 3m", leaving out the parts that are zero; "0ms" for nothing. */ export function formatDuration(ms: number): string { if (ms === 0) return '0ms'; - - const d = Math.floor(ms / 86_400_000); - ms %= 86_400_000; - const h = Math.floor(ms / 3_600_000); - ms %= 3_600_000; - const m = Math.floor(ms / 60_000); - ms %= 60_000; - const s = Math.floor(ms / 1_000); - ms %= 1_000; - - const parts: string[] = []; - if (d > 0) parts.push(`${d}d`); - if (h > 0) parts.push(`${h}h`); - if (m > 0) parts.push(`${m}m`); - if (s > 0) parts.push(`${s}s`); - if (ms > 0) parts.push(`${ms}ms`); - - return parts.join(' '); + let rest = ms; + const out: string[] = []; + for (const [label, size] of PARTS) { + const n = size === 1 ? rest : Math.floor(rest / size); + rest -= size === 1 ? n : n * size; + if (n > 0) out.push(`${n}${label}`); + } + return out.join(' '); } diff --git a/src/lib/jmapErrors.test.ts b/src/lib/jmapErrors.test.ts new file mode 100644 index 0000000..bccffe2 --- /dev/null +++ b/src/lib/jmapErrors.test.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { friendlySetError, validationErrorMessage } from './jmapErrors'; + +describe('validationErrorMessage', () => { + it('says what the field needs', () => { + expect(validationErrorMessage({ type: 'Required', property: 'name' })).toBe('This field is required.'); + expect(validationErrorMessage({ type: 'MaxLength', property: 'name', required: 64 })).toBe('Maximum length is 64.'); + expect(validationErrorMessage({ type: 'MinValue', property: 'port', required: 1 })).toBe('Minimum value is 1.'); + }); + + it("gives the server's reason for an invalid value, when there is one", () => { + expect(validationErrorMessage({ type: 'Invalid', property: 'host', value: 'Not a hostname' })).toBe( + 'Not a hostname', + ); + expect(validationErrorMessage({ type: 'Invalid', property: 'host', value: '' })).toBe('Invalid value.'); + expect(validationErrorMessage({ type: 'Invalid', property: 'host' })).toBe('Invalid value.'); + }); +}); + +describe('friendlySetError', () => { + it("prefers the server's own description", () => { + expect(friendlySetError({ type: 'forbidden', description: 'Only tenant admins may do this.' })).toBe( + 'Only tenant admins may do this.', + ); + }); + + it('explains the known error types', () => { + expect(friendlySetError({ type: 'objectIsLinked' })).toBe( + 'This item is referenced by other items and cannot be deleted.', + ); + expect(friendlySetError({ type: 'singleton' })).toBe('This item is a singleton and cannot be deleted.'); + }); + + it('names a type it does not know, including ones that look like object keys', () => { + expect(friendlySetError({ type: 'somethingNew' })).toBe('Unexpected error (somethingNew).'); + expect(friendlySetError({ type: 'toString' })).toBe('Unexpected error (toString).'); + }); +}); diff --git a/src/lib/jmapErrors.ts b/src/lib/jmapErrors.ts index 9b483f2..0ae53aa 100644 --- a/src/lib/jmapErrors.ts +++ b/src/lib/jmapErrors.ts @@ -1,66 +1,49 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ -import type { JmapSetError, ValidationError } from '@/types/jmap'; +/** The server's JMAP errors in words an admin can act on. */ import i18n from '@/i18n'; +import type { JmapSetError, ValidationError } from '@/types/jmap'; -export function validationErrorMessage(ve: ValidationError): string { - switch (ve.type) { - case 'Required': - return i18n.t('form.required', 'This field is required.'); - case 'MaxLength': - return i18n.t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: ve.required }); - case 'MinLength': - return i18n.t('form.minLengthIs', 'Minimum length is {{min}}.', { min: ve.required }); - case 'MaxValue': - return i18n.t('form.maxValueIs', 'Maximum value is {{max}}.', { max: ve.required }); - case 'MinValue': - return i18n.t('form.minValueIs', 'Minimum value is {{min}}.', { min: ve.required }); - default: - if (ve.value && ve.value.length > 0) return ve.value; - return i18n.t('form.invalidValue', 'Invalid value.'); - } +const t = i18n.t.bind(i18n); + +/** The message under a field the server refused. */ +export function validationErrorMessage({ type, required, value }: ValidationError): string { + if (type === 'Required') return t('form.required', 'This field is required.'); + if (type === 'MaxLength') return t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: required }); + if (type === 'MinLength') return t('form.minLengthIs', 'Minimum length is {{min}}.', { min: required }); + if (type === 'MaxValue') return t('form.maxValueIs', 'Maximum value is {{max}}.', { max: required }); + if (type === 'MinValue') return t('form.minValueIs', 'Minimum value is {{min}}.', { min: required }); + // 'Invalid': the server's own reason, when it gave one. + return value || t('form.invalidValue', 'Invalid value.'); } +const SET_ERRORS: Record string> = { + forbidden: () => t('jmapErrors.forbidden', 'You do not have permission to perform this action.'), + notFound: () => t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.'), + objectIsLinked: () => t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.'), + invalidForeignKey: () => t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.'), + primaryKeyViolation: () => t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.'), + alreadyExists: () => t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.'), + overQuota: () => t('jmapErrors.overQuota', 'The operation exceeds your storage quota.'), + tooLarge: () => t('jmapErrors.tooLarge', 'The item is too large.'), + rateLimit: () => t('jmapErrors.rateLimit', 'Too many requests. Please try again later.'), + singleton: () => t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.'), + mailboxHasChild: () => t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.'), + mailboxHasEmail: () => t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.'), + calendarHasEvent: () => t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.'), + addressBookHasContents: () => + t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.'), + nodeHasChildren: () => t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.'), + validationFailed: () => t('jmapErrors.validationFailed', 'The data did not pass validation.'), +}; + +/** Why a create, update or delete failed: the server's description if it sent one, else ours for the type. */ export function friendlySetError(err: JmapSetError): string { if (err.description) return err.description; - switch (err.type) { - case 'forbidden': - return i18n.t('jmapErrors.forbidden', 'You do not have permission to perform this action.'); - case 'notFound': - return i18n.t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.'); - case 'objectIsLinked': - return i18n.t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.'); - case 'invalidForeignKey': - return i18n.t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.'); - case 'primaryKeyViolation': - return i18n.t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.'); - case 'alreadyExists': - return i18n.t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.'); - case 'overQuota': - return i18n.t('jmapErrors.overQuota', 'The operation exceeds your storage quota.'); - case 'tooLarge': - return i18n.t('jmapErrors.tooLarge', 'The item is too large.'); - case 'rateLimit': - return i18n.t('jmapErrors.rateLimit', 'Too many requests. Please try again later.'); - case 'singleton': - return i18n.t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.'); - case 'mailboxHasChild': - return i18n.t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.'); - case 'mailboxHasEmail': - return i18n.t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.'); - case 'calendarHasEvent': - return i18n.t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.'); - case 'addressBookHasContents': - return i18n.t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.'); - case 'nodeHasChildren': - return i18n.t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.'); - case 'validationFailed': - return i18n.t('jmapErrors.validationFailed', 'The data did not pass validation.'); - default: - return i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type }); - } + const known = Object.hasOwn(SET_ERRORS, err.type) ? SET_ERRORS[err.type] : undefined; + return known ? known() : t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type }); } diff --git a/src/lib/jmapUtils.test.ts b/src/lib/jmapUtils.test.ts new file mode 100644 index 0000000..d714379 --- /dev/null +++ b/src/lib/jmapUtils.test.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; + +import { jmapMapToArray } from './jmapUtils'; + +describe('jmapMapToArray', () => { + it('keeps an array', () => { + const list = ['a', 'b']; + expect(jmapMapToArray(list)).toBe(list); + }); + + it('orders an object by its numeric keys', () => { + expect(jmapMapToArray({ '10': 'k', '2': 'c', '0': 'a', '1': 'b' })).toEqual(['a', 'b', 'c', 'k']); + }); + + it('reads anything else as an empty list', () => { + expect(jmapMapToArray(null)).toEqual([]); + expect(jmapMapToArray(undefined)).toEqual([]); + expect(jmapMapToArray('abc')).toEqual([]); + }); +}); diff --git a/src/lib/jmapUtils.ts b/src/lib/jmapUtils.ts index f184b10..eed84df 100644 --- a/src/lib/jmapUtils.ts +++ b/src/lib/jmapUtils.ts @@ -1,17 +1,20 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** What the server sends in place of a secret's value. */ export const SECRET_MASK = '****'; -export function jmapMapToArray(val: unknown): T[] { - if (Array.isArray(val)) return val as T[]; - if (val && typeof val === 'object') { - return Object.entries(val as Record) - .sort(([a], [b]) => Number(a) - Number(b)) - .map(([, v]) => v); - } - return []; +/** + * A list the server may send as an array or as an object keyed by position + * ("0", "1", …), as an array in that order. Anything else is an empty list. + */ +export function jmapMapToArray(value: unknown): T[] { + if (Array.isArray(value)) return value as T[]; + if (value === null || typeof value !== 'object') return []; + const byIndex = Object.entries(value as Record); + byIndex.sort((a, b) => Number(a[0]) - Number(b[0])); + return byIndex.map((entry) => entry[1]); } diff --git a/src/lib/lastVisited.test.ts b/src/lib/lastVisited.test.ts new file mode 100644 index 0000000..6961e92 --- /dev/null +++ b/src/lib/lastVisited.test.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Layout, Schema } from '@/types/schema'; + +const layoutMock = vi.hoisted(() => ({ + accessible: new Set(), + firstAccessible: null as string | null, + firstVisible: null as string | null, +})); +vi.mock('@/lib/layout', () => ({ + isLinkAccessible: (_s: unknown, view: string) => layoutMock.accessible.has(view), + findFirstAccessibleLinkInLayout: () => layoutMock.firstAccessible, + findFirstVisibleLinkInLayout: () => layoutMock.firstVisible, +})); + +import { rememberLastVisited, sectionLandingLink } from './lastVisited'; + +const schema = {} as Schema; +const settings = { name: 'Settings' } as Layout; +const land = () => sectionLandingLink(schema, settings, 'community', () => true); + +describe('last visited page per section', () => { + // In memory: Node's own `localStorage` global, empty without a storage file, hides happy-dom's. + beforeAll(() => { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + clear: () => store.clear(), + }); + }); + + beforeEach(() => { + localStorage.clear(); + layoutMock.accessible = new Set(['x:Domain', 'x:Account']); + layoutMock.firstAccessible = 'x:Overview'; + layoutMock.firstVisible = 'x:Locked'; + }); + + it('opens a section on the page last used there', () => { + rememberLastVisited('Settings', 'x:Domain'); + rememberLastVisited('Management', 'x:Account'); + expect(land()).toBe('x:Domain'); + expect(JSON.parse(localStorage.getItem('inbuxa-last-visited')!)).toEqual({ + Settings: 'x:Domain', + Management: 'x:Account', + }); + }); + + it('skips a remembered page the admin can no longer open', () => { + rememberLastVisited('Settings', 'x:Gone'); + expect(land()).toBe('x:Overview'); + }); + + it('falls back to the first page shown when none may be opened', () => { + layoutMock.firstAccessible = null; + expect(land()).toBe('x:Locked'); + }); + + it('ignores garbled storage', () => { + localStorage.setItem('inbuxa-last-visited', '["x:Domain"]'); + expect(land()).toBe('x:Overview'); + localStorage.setItem('inbuxa-last-visited', '{not json'); + expect(land()).toBe('x:Overview'); + rememberLastVisited('Settings', 'x:Domain'); + expect(land()).toBe('x:Domain'); + }); +}); diff --git a/src/lib/lastVisited.ts b/src/lib/lastVisited.ts index 9101e98..fa928f7 100644 --- a/src/lib/lastVisited.ts +++ b/src/lib/lastVisited.ts @@ -1,12 +1,14 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Each section (Management, Settings, Account) opens on the page the admin + * last used in it, kept in this browser. A remembered page the admin can no + * longer open is skipped. + */ import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, @@ -16,28 +18,29 @@ import { } from '@/lib/layout'; import type { Layout, Schema } from '@/types/schema'; -const STORAGE_KEY = 'inbuxa-last-visited'; +const KEY = 'inbuxa-last-visited'; -function readAll(): Record { +/** Section name → view name; empty when storage is missing, blocked or garbled. */ +function remembered(): Record { try { - const raw = localStorage.getItem(STORAGE_KEY); - const parsed: unknown = raw ? JSON.parse(raw) : null; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; + const value: unknown = JSON.parse(localStorage.getItem(KEY) ?? '{}'); + return value !== null && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}; } catch { return {}; } } export function rememberLastVisited(section: string, viewName: string): void { + const all = remembered(); + if (all[section] === viewName) return; try { - const all = readAll(); - if (all[section] === viewName) return; - localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...all, [section]: viewName })); + localStorage.setItem(KEY, JSON.stringify({ ...all, [section]: viewName })); } catch { - return; + // Storage full or blocked: the section opens on its first page instead. } } +/** Where a section opens: the page last used there, else its first page the admin may open, else its first page. */ export function sectionLandingLink( schema: Schema, layout: Layout, @@ -45,10 +48,8 @@ export function sectionLandingLink( canGet: CanGet, hasPerm?: HasPermission, ): string | null { - const last = readAll()[layout.name]; - if (typeof last === 'string' && isLinkAccessible(schema, last, edition, canGet, hasPerm)) { - return last; - } + const last = remembered()[layout.name]; + if (typeof last === 'string' && isLinkAccessible(schema, last, edition, canGet, hasPerm)) return last; return ( findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm) ?? findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm) diff --git a/src/lib/listFilter.test.ts b/src/lib/listFilter.test.ts index b06a48f..b0ed6e3 100644 --- a/src/lib/listFilter.test.ts +++ b/src/lib/listFilter.test.ts @@ -1,155 +1,86 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ -import { describe, it, expect } from 'vitest'; -import { buildJmapFilter } from './listFilter'; -import type { Filter } from '@/types/schema'; +import { describe, expect, it } from 'vitest'; -const dateFilter: Filter = { type: 'date', field: 'timestamp', label: 'Date' }; -const textFilter: Filter = { type: 'text', field: 'text', label: 'Text' }; -const intFilter: Filter = { type: 'integer', field: 'size', label: 'Size' }; +import type { Filter } from '@/types/schema'; +import { buildJmapFilter } from './listFilter'; + +const dateFilter = [{ field: 'created', type: 'date' }] as Filter[]; +const x = (appliedFilters: Record) => + buildJmapFilter({ appliedFilters, filters: dateFilter, isXPrefixed: true }); describe('buildJmapFilter', () => { - it('returns an empty object when nothing is applied', () => { - expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: true })).toEqual({}); + it('starts from the static filters and adds what the reader picked', () => { + expect( + buildJmapFilter({ appliedFilters: { name: 'ann' }, filtersStatic: { kind: 'user' }, isXPrefixed: true }), + ).toEqual({ kind: 'user', name: 'ann' }); + expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: false })).toEqual({}); }); - it('merges filtersStatic', () => { - const filter = buildJmapFilter({ - appliedFilters: {}, - filtersStatic: { hasErrors: true }, - isXPrefixed: true, - }); - expect(filter).toEqual({ hasErrors: true }); + it('leaves out empty values, and operators on their own', () => { + expect( + buildJmapFilter({ appliedFilters: { name: '', size: '5', sizeOp: 'gt', quotaOp: 'lt' }, isXPrefixed: true }), + ).toEqual({ sizeIsGreaterThan: '5' }); }); - it('skips empty and Op-only keys', () => { - const filter = buildJmapFilter({ - appliedFilters: { text: '', textOp: 'eq' }, - filters: [textFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({}); - }); - - it('passes text filters through verbatim', () => { - const filter = buildJmapFilter({ - appliedFilters: { text: 'hello' }, - filters: [textFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ text: 'hello' }); - }); - - it('applies the operator suffix for integer filters', () => { - const filter = buildJmapFilter({ - appliedFilters: { size: '1024', sizeOp: 'gt' }, - filters: [intFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ sizeIsGreaterThan: '1024' }); - }); - - describe('date filters (x-prefixed, with operator UI)', () => { - it('expands a default date-only equality into a full-day range', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ - timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z', - timestampIsLessThan: '2026-06-18T00:00:00Z', - }); - }); - - it('treats an explicit eq operator the same as the default', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17', timestampOp: 'eq' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ - timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z', - timestampIsLessThan: '2026-06-18T00:00:00Z', - }); - }); - - it('appends midnight UTC for the After (gt) operator', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17', timestampOp: 'gt' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T00:00:00Z' }); - }); - - it('appends midnight UTC for the Before (lt) operator', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17', timestampOp: 'lt' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ timestampIsLessThan: '2026-06-17T00:00:00Z' }); - }); - - it('rolls over month boundaries', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-30' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ - timestampIsGreaterThanOrEqual: '2026-06-30T00:00:00Z', - timestampIsLessThan: '2026-07-01T00:00:00Z', - }); - }); - - it('rolls over year boundaries', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-12-31' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ - timestampIsGreaterThanOrEqual: '2026-12-31T00:00:00Z', - timestampIsLessThan: '2027-01-01T00:00:00Z', - }); - }); - - it('handles leap-year February', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2028-02-28' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ - timestampIsGreaterThanOrEqual: '2028-02-28T00:00:00Z', - timestampIsLessThan: '2028-02-29T00:00:00Z', - }); - }); - - it('passes a full datetime through with only the operator suffix applied', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17T08:30:00Z', timestampOp: 'gt' }, - filters: [dateFilter], - isXPrefixed: true, - }); - expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T08:30:00Z' }); + it.each([ + ['eq', 'size'], + ['gt', 'sizeIsGreaterThan'], + ['gte', 'sizeIsGreaterThanOrEqual'], + ['lt', 'sizeIsLessThan'], + ['lte', 'sizeIsLessThanOrEqual'], + ])('names the property for the %s operator: %s', (op, key) => { + expect(buildJmapFilter({ appliedFilters: { size: '10', sizeOp: op }, isXPrefixed: true })).toEqual({ + [key]: '10', }); }); - describe('date filters (non-x-prefixed, no operator UI)', () => { - it('appends midnight UTC as an exact match without range expansion', () => { - const filter = buildJmapFilter({ - appliedFilters: { timestamp: '2026-06-17' }, - filters: [dateFilter], - isXPrefixed: false, + describe('a date on an x: object', () => { + it('covers the whole UTC day when no operator, or eq, is picked', () => { + const day = { + createdIsGreaterThanOrEqual: '2026-03-14T00:00:00Z', + createdIsLessThan: '2026-03-15T00:00:00Z', + }; + expect(x({ created: '2026-03-14' })).toEqual(day); + expect(x({ created: '2026-03-14', createdOp: 'eq' })).toEqual(day); + }); + + it('runs the day over into the next month, year and leap day', () => { + expect(x({ created: '2026-01-31' }).createdIsLessThan).toBe('2026-02-01T00:00:00Z'); + expect(x({ created: '2025-12-31' }).createdIsLessThan).toBe('2026-01-01T00:00:00Z'); + expect(x({ created: '2028-02-28' }).createdIsLessThan).toBe('2028-02-29T00:00:00Z'); + expect(x({ created: '2027-02-28' }).createdIsLessThan).toBe('2027-03-01T00:00:00Z'); + }); + + it('compares against midnight UTC for before and after', () => { + expect(x({ created: '2026-03-14', createdOp: 'gt' })).toEqual({ createdIsGreaterThan: '2026-03-14T00:00:00Z' }); + expect(x({ created: '2026-03-14', createdOp: 'lt' })).toEqual({ createdIsLessThan: '2026-03-14T00:00:00Z' }); + }); + + it('sends a full date and time as it is, with the operator', () => { + expect(x({ created: '2026-03-14T08:30:00Z', createdOp: 'gte' })).toEqual({ + createdIsGreaterThanOrEqual: '2026-03-14T08:30:00Z', + }); + expect(x({ created: '2026-03-14T08:30:00Z' })).toEqual({ created: '2026-03-14T08:30:00Z' }); + }); + }); + + describe('a date on an object without operators', () => { + const plain = (appliedFilters: Record) => + buildJmapFilter({ appliedFilters, filters: dateFilter, isXPrefixed: false }); + + it('matches midnight UTC of that day exactly', () => { + expect(plain({ created: '2026-03-14' })).toEqual({ created: '2026-03-14T00:00:00Z' }); + }); + + it('sends a full date and time as it is, ignoring any operator', () => { + expect(plain({ created: '2026-03-14T08:30:00Z', createdOp: 'gt' })).toEqual({ + created: '2026-03-14T08:30:00Z', }); - expect(filter).toEqual({ timestamp: '2026-06-17T00:00:00Z' }); }); }); }); diff --git a/src/lib/listFilter.ts b/src/lib/listFilter.ts index 720237e..2d93eca 100644 --- a/src/lib/listFilter.ts +++ b/src/lib/listFilter.ts @@ -1,97 +1,74 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Turns the filters a reader picked above a list into a JMAP `/query` + * filter. A filter `size` with operator `sizeOp: 'gt'` becomes + * `sizeIsGreaterThan`. Dates picked as a day become midnight UTC, and on x: + * objects a plain day means the whole day. + */ import type { Filter } from '@/types/schema'; -const OP_SUFFIX: Record = { - eq: '', +const OPERATOR_SUFFIX: Record = { gt: 'IsGreaterThan', gte: 'IsGreaterThanOrEqual', lt: 'IsLessThan', lte: 'IsLessThanOrEqual', }; -const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; +const suffixFor = (op: string | undefined) => (op ? (OPERATOR_SUFFIX[op] ?? '') : ''); -function utcDayStart(date: string): string { - return `${date}T00:00:00Z`; +const isDay = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value); + +const midnight = (day: string) => `${day}T00:00:00Z`; + +function nextMidnight(day: string) { + const next = new Date(Date.parse(midnight(day)) + 86_400_000); + return `${next.toISOString().slice(0, 10)}T00:00:00Z`; } -function utcNextDayStart(date: string): string { - const dt = new Date(`${date}T00:00:00Z`); - dt.setUTCDate(dt.getUTCDate() + 1); - const y = dt.getUTCFullYear(); - const m = String(dt.getUTCMonth() + 1).padStart(2, '0'); - const d = String(dt.getUTCDate()).padStart(2, '0'); - return `${y}-${m}-${d}T00:00:00Z`; -} - -function applyDateFilter( - filter: Record, - field: string, - value: string, - op: string | undefined, - isXPrefixed: boolean, -): void { - if (!DATE_ONLY.test(value)) { - const suffix = isXPrefixed && op ? (OP_SUFFIX[op] ?? '') : ''; - filter[`${field}${suffix}`] = value; - return; +/** The filter entries for one date field. */ +function dateEntries(field: string, value: string, op: string | undefined, withOps: boolean): [string, string][] { + if (!isDay(value)) return [[field + (withOps ? suffixFor(op) : ''), value]]; + if (!withOps) return [[field, midnight(value)]]; + if (!op || op === 'eq') { + return [ + [`${field}IsGreaterThanOrEqual`, midnight(value)], + [`${field}IsLessThan`, nextMidnight(value)], + ]; } - - if (!isXPrefixed) { - filter[field] = utcDayStart(value); - return; - } - - const effectiveOp = op ?? 'eq'; - if (effectiveOp === 'eq') { - filter[`${field}IsGreaterThanOrEqual`] = utcDayStart(value); - filter[`${field}IsLessThan`] = utcNextDayStart(value); - return; - } - - const suffix = OP_SUFFIX[effectiveOp] ?? ''; - filter[`${field}${suffix}`] = utcDayStart(value); + return [[field + suffixFor(op), midnight(value)]]; } export interface BuildJmapFilterArgs { + /** What the reader picked: `field: value`, and `fieldOp: operator`. */ appliedFilters: Record; + /** The list's filter definitions; they say which fields are dates. */ filters?: Filter[]; + /** Always part of the query. */ filtersStatic?: Record; + /** x: objects take operators and date ranges; others match exactly. */ isXPrefixed: boolean; } export function buildJmapFilter({ appliedFilters, - filters, + filters = [], filtersStatic, isXPrefixed, }: BuildJmapFilterArgs): Record { - const filter: Record = {}; - if (filtersStatic) { - Object.assign(filter, filtersStatic); + const dates = new Set(filters.filter((f) => f.type === 'date').map((f) => f.field)); + const out: Record = { ...filtersStatic }; + for (const [field, value] of Object.entries(appliedFilters)) { + if (!value || field.endsWith('Op')) continue; + const op = appliedFilters[`${field}Op`]; + const entries: [string, string][] = dates.has(field) + ? dateEntries(field, value, op, isXPrefixed) + : [[field + suffixFor(op), value]]; + for (const [key, v] of entries) out[key] = v; } - - const dateFields = new Set((filters ?? []).filter((f) => f.type === 'date').map((f) => f.field)); - - for (const [key, val] of Object.entries(appliedFilters)) { - if (val === '' || val == null) continue; - if (key.endsWith('Op')) continue; - - const op = appliedFilters[`${key}Op`]; - - if (dateFields.has(key)) { - applyDateFilter(filter, key, val, op, isXPrefixed); - continue; - } - - const suffix = op ? (OP_SUFFIX[op] ?? '') : ''; - filter[`${key}${suffix}`] = val; - } - - return filter; + return out; } diff --git a/src/lib/logoCache.test.ts b/src/lib/logoCache.test.ts new file mode 100644 index 0000000..ae7b424 --- /dev/null +++ b/src/lib/logoCache.test.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/services/api', () => ({ getApiBaseUrl: () => 'https://mail.example' })); + +/** A fresh copy of the module, since it fetches once per page load. */ +async function load(response: Response | Error) { + vi.resetModules(); + const fetchMock = vi.fn(() => (response instanceof Error ? Promise.reject(response) : Promise.resolve(response))); + vi.stubGlobal('fetch', fetchMock); + const mod = await import('./logoCache'); + const changed = vi.fn(); + mod.subscribeToLogo(changed); + mod.loadLogoOnce(); + mod.loadLogoOnce(); + await vi.waitFor(() => expect(changed).toHaveBeenCalled()); + return { state: mod.getLogoState(), fetchMock }; +} + +describe('logo cache', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('uses an image the server sends, fetching it only once', async () => { + URL.createObjectURL = vi.fn(() => 'blob:logo'); + const { state, fetchMock } = await load( + new Response(new Blob(['png']), { headers: { 'content-type': 'image/png' } }), + ); + expect(state).toEqual({ status: 'custom', url: 'blob:logo' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('https://mail.example/logo'); + }); + + it('falls back to the inbuxa logo for anything else', async () => { + expect((await load(new Response('nope', { status: 404 }))).state).toEqual({ status: 'default' }); + expect((await load(new Response('', { headers: { 'content-type': 'text/html' } }))).state).toEqual({ + status: 'default', + }); + expect((await load(new Error('offline'))).state).toEqual({ status: 'default' }); + }); +}); diff --git a/src/lib/logoCache.ts b/src/lib/logoCache.ts index 457706f..aae71af 100644 --- a/src/lib/logoCache.ts +++ b/src/lib/logoCache.ts @@ -1,40 +1,45 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * The server's own logo, if an admin set one: fetched once per page load + * from `/logo` and kept as an object URL. Anything but an image (no logo, an + * error page, no answer) means the inbuxa logo. + */ import { getApiBaseUrl } from '@/services/api'; export type LogoState = { status: 'loading' } | { status: 'custom'; url: string } | { status: 'default' }; -let state: LogoState = { status: 'loading' }; -let started = false; -const listeners = new Set<() => void>(); +let logo: LogoState = { status: 'loading' }; +let requested = false; +const watchers = new Set<() => void>(); -export function getLogoState(): LogoState { - return state; +export const getLogoState = (): LogoState => logo; + +export function subscribeToLogo(watch: () => void): () => void { + watchers.add(watch); + return () => void watchers.delete(watch); } -export function subscribeToLogo(listener: () => void): () => void { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; +async function fetchLogo(): Promise { + try { + const res = await fetch(`${getApiBaseUrl()}/logo`); + if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) return null; + return URL.createObjectURL(await res.blob()); + } catch { + return null; + } } +/** Starts the fetch; later calls do nothing. */ export function loadLogoOnce(): void { - if (started) return; - started = true; - fetch(`${getApiBaseUrl()}/logo`) - .then(async (response) => { - const contentType = response.headers.get('content-type') ?? ''; - if (!response.ok || !contentType.startsWith('image/')) return null; - return URL.createObjectURL(await response.blob()); - }) - .catch(() => null) - .then((url) => { - state = url ? { status: 'custom', url } : { status: 'default' }; - listeners.forEach((notify) => notify()); - }); + if (requested) return; + requested = true; + void fetchLogo().then((url) => { + logo = url ? { status: 'custom', url } : { status: 'default' }; + for (const watch of watchers) watch(); + }); } diff --git a/src/lib/sievepad.test.ts b/src/lib/sievepad.test.ts index e7cbf7c..5ae8693 100644 --- a/src/lib/sievepad.test.ts +++ b/src/lib/sievepad.test.ts @@ -1,10 +1,7 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ import { inflateRawSync } from 'node:zlib'; @@ -12,52 +9,56 @@ import { describe, expect, it } from 'vitest'; import { isSieveScriptField, playgroundUrl, sievepadLink } from './sievepad'; -function decode(link: string): unknown { - const token = new URLSearchParams(new URL(link).hash.slice(1)).get('w') ?? ''; - return JSON.parse(inflateRawSync(Buffer.from(token, 'base64url')).toString('utf8')); +/** What the playground reads back out of a link. */ +function workspaceIn(link: string) { + const w = new URL(link).hash.match(/^#w=([A-Za-z0-9_-]+)$/)?.[1]; + if (!w) throw new Error(`no workspace in ${link}`); + return JSON.parse(inflateRawSync(Buffer.from(w, 'base64url')).toString('utf8')); } -describe('playgroundUrl', () => { - it('names the bundled playground, by content hash, beside the console', () => { +describe('the playground link', () => { + it('points at the playground shipped beside the console', () => { const url = new URL(playgroundUrl()); expect(url.origin).toBe(new URL(document.baseURI).origin); expect(url.pathname).toMatch(/\/sieve-playground\/[0-9a-f]{16}\/index\.html$/); }); -}); - -describe('sievepadLink', () => { - it('encodes the script as a single main entry', async () => { - const source = 'require "imap4flags";\r\naddflag "\\\\Seen";\r\n'; - const link = await sievepadLink('Filters é', source); + it('carries the script as the main entry, with Unix line ends', async () => { + const link = await sievepadLink('Vacation ✈', 'if true {\r\n keep;\r\n}\r\n'); expect(link.startsWith(`${playgroundUrl()}#w=`)).toBe(true); - expect(link.slice(`${playgroundUrl()}#w=`.length)).toMatch(/^[A-Za-z0-9_-]+$/); - expect(decode(link)).toEqual({ + expect(workspaceIn(link)).toEqual({ v: 1, - name: 'Filters é', - scripts: [{ name: 'main', source: 'require "imap4flags";\naddflag "\\\\Seen";\n' }], + name: 'Vacation ✈', + scripts: [{ name: 'main', source: 'if true {\n keep;\n}\n' }], messages: [], settings: {}, }); }); - it('carries the interpreter settings it is given', async () => { - const link = await sievepadLink('Filters', 'keep;', { cpuLimit: 5000, capabilities: ['fileinto'] }); - expect((decode(link) as { settings: unknown }).settings).toEqual({ cpuLimit: 5000, capabilities: ['fileinto'] }); + it("carries the interpreter's settings", async () => { + const settings = { maxInstructions: 1000, extensions: ['vacation'] }; + expect(workspaceIn(await sievepadLink('s', 'keep;', settings)).settings).toEqual(settings); }); - it('truncates long workspace names', async () => { - const link = await sievepadLink('x'.repeat(200), 'keep;'); - expect((decode(link) as { name: string }).name).toHaveLength(80); + it('cuts the name to 80 characters', async () => { + expect(workspaceIn(await sievepadLink('n'.repeat(81), 'keep;')).name).toBe('n'.repeat(80)); + }); + + it('holds a large script', async () => { + const source = Array.from({ length: 5000 }, (_, i) => `# rule ${i} ${Math.random()}`).join('\n'); + expect(workspaceIn(await sievepadLink('big', source)).scripts[0].source).toBe(source); }); }); describe('isSieveScriptField', () => { - it('matches only the known script fields', () => { - expect(isSieveScriptField('x:SieveUserScript', 'contents')).toBe(true); - expect(isSieveScriptField('x:SieveSystemScript', 'contents')).toBe(true); - expect(isSieveScriptField('SieveScript', 'blobId')).toBe(true); - expect(isSieveScriptField('SieveScript', 'name')).toBe(false); - expect(isSieveScriptField('x:Domain', 'contents')).toBe(false); + it.each([ + ['x:SieveUserScript', 'contents', true], + ['x:SieveSystemScript', 'contents', true], + ['SieveScript', 'blobId', true], + ['SieveScript', 'contents', false], + ['x:SieveUserScript', 'name', false], + ['x:Domain', 'contents', false], + ])('%s.%s → %s', (object, field, expected) => { + expect(isSieveScriptField(object, field)).toBe(expected); }); }); diff --git a/src/lib/sievepad.ts b/src/lib/sievepad.ts index 35f2f84..9862acc 100644 --- a/src/lib/sievepad.ts +++ b/src/lib/sievepad.ts @@ -1,64 +1,69 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Opening a Sieve script in the playground that ships with the console + * (`tools/sieve-playground`). The script travels in the link's fragment, so + * it never leaves the browser, and runs under the limits of the interpreter + * that runs it on the server (sieveLimits.ts). + */ import { fetchPlaygroundSettings, type SieveInterpreter } from './sieveLimits'; -// inbuxa: the Sieve playground ships with the console (sieve-playground.ts) -// and runs the server's own interpreter, so the script never leaves the -// console's origin. -export function playgroundUrl(): string { - return new URL(__SIEVE_PLAYGROUND__, document.baseURI).href; -} +/** The playground beside the console, at its content-hashed path. */ +export const playgroundUrl = (): string => new URL(__SIEVE_PLAYGROUND__, document.baseURI).href; -const SIEVEPAD_FORMAT_VERSION = 1; -const SIEVEPAD_MAX_NAME_LENGTH = 80; -const SIEVEPAD_MAIN_SCRIPT = 'main'; -const BASE64_CHUNK_SIZE = 0x8000; - -const SIEVE_SCRIPT_FIELDS: Record = { +/** Which field holds the script, for each object that has one. */ +const SCRIPT_FIELD: Record = { 'x:SieveSystemScript': 'contents', 'x:SieveUserScript': 'contents', SieveScript: 'blobId', }; -export function isSieveScriptField(objectName: string, fieldName: string): boolean { - return SIEVE_SCRIPT_FIELDS[objectName] === fieldName; +export const isSieveScriptField = (objectName: string, fieldName: string): boolean => + SCRIPT_FIELD[objectName] === fieldName; + +async function deflateRaw(text: string): Promise { + const compressed = new Blob([text]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return new Uint8Array(await new Response(compressed).arrayBuffer()); } -function toBase64Url(bytes: Uint8Array): string { +function base64Url(bytes: Uint8Array): string { let binary = ''; - for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) { - binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_SIZE)); + // In slices, so a large script doesn't overflow the argument list. + for (let at = 0; at < bytes.length; at += 32_768) { + binary += String.fromCharCode(...bytes.subarray(at, at + 32_768)); } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); } +/** + * A playground link holding one workspace: the script as its `main` entry, + * no test messages, and the interpreter settings. This is the playground's + * format version 1, deflated and base64url-encoded into `#w=`. + */ export async function sievepadLink( name: string, source: string, settings: Record = {}, base = playgroundUrl(), ): Promise { - const json = JSON.stringify({ - v: SIEVEPAD_FORMAT_VERSION, - name: name.slice(0, SIEVEPAD_MAX_NAME_LENGTH), - scripts: [{ name: SIEVEPAD_MAIN_SCRIPT, source: source.replace(/\r\n/g, '\n') }], + const workspace = { + v: 1, + name: name.slice(0, 80), + scripts: [{ name: 'main', source: source.replaceAll('\r\n', '\n') }], messages: [], settings, - }); - const stream = new Blob([new TextEncoder().encode(json)]).stream().pipeThrough(new CompressionStream('deflate-raw')); - const packed = new Uint8Array(await new Response(stream).arrayBuffer()); - return `${base}#w=${toBase64Url(packed)}`; + }; + return `${base}#w=${base64Url(await deflateRaw(JSON.stringify(workspace)))}`; } -// inbuxa: the script runs under the limits of the interpreter that runs it -// on the server (sieveLimits.ts). +/** + * Opens the script in a new tab. The tab opens first, while the click still + * counts as the reader's, and is pointed at the link once it is built. + */ export async function openInSievepad(name: string, source: string, interpreter: SieveInterpreter): Promise { const tab = window.open('about:blank', '_blank'); if (tab) tab.opener = null; @@ -69,9 +74,6 @@ export async function openInSievepad(name: string, source: string, interpreter: tab?.close(); throw err; } - if (tab) { - tab.location.replace(link); - } else { - window.open(link, '_blank', 'noopener,noreferrer'); - } + if (tab) tab.location.replace(link); + else window.open(link, '_blank', 'noopener,noreferrer'); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 39df27b..4a3d9c1 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,12 +1,11 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +/** Joins class names; where two Tailwind classes set the same thing, the later one wins. */ +export const cn = (...classes: ClassValue[]): string => twMerge(clsx(classes)); -- 2.54.0