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));