Rewrite the small helpers and their tests #121
No files matched your search
@@ -1,28 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<T, S = T>(
|
||||
value: T,
|
||||
transform: (v: T) => S = (v) => v as unknown as S,
|
||||
): [S, Dispatch<SetStateAction<S>>] {
|
||||
const [prev, setPrev] = useState(value);
|
||||
const [local, setLocal] = useState<S>(() => transform(value));
|
||||
if (!Object.is(value, prev)) {
|
||||
setPrev(value);
|
||||
setLocal(transform(value));
|
||||
const [source, setSource] = useState(value);
|
||||
const [draft, setDraft] = useState<S>(() => 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<K>(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();
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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]);
|
||||
}
|
||||
+9
-8
@@ -1,15 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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 `<base href>`, 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;
|
||||
}
|
||||
@@ -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: '?' }]);
|
||||
});
|
||||
});
|
||||
+56
-84
@@ -1,108 +1,80 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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, unknown>, string];
|
||||
const count = (v: unknown) => (Array.isArray(v) ? v.length : v && typeof v === 'object' ? Object.keys(v).length : 0);
|
||||
|
||||
function summarizeArgs(args: Record<string, unknown>): 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<Record<string, string>> {
|
||||
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<string, string | number>[] {
|
||||
if (!Array.isArray(invocations)) return [];
|
||||
return invocations.map((inv) => {
|
||||
const [name, args, callId] = Array.isArray(inv) ? inv : [];
|
||||
const a: Record<string, unknown> = args && typeof args === 'object' ? args : {};
|
||||
const row: Record<string, string | number> = { 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();
|
||||
}
|
||||
+77
-207
@@ -1,229 +1,99 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
+44
-76
@@ -1,96 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string, number>;
|
||||
|
||||
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<string, number> = {
|
||||
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<string, number> = {
|
||||
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(' ');
|
||||
}
|
||||
@@ -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).');
|
||||
});
|
||||
});
|
||||
+38
-55
@@ -1,66 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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, () => 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 });
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+13
-10
@@ -1,17 +1,20 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<T>(val: unknown): T[] {
|
||||
if (Array.isArray(val)) return val as T[];
|
||||
if (val && typeof val === 'object') {
|
||||
return Object.entries(val as Record<string, T>)
|
||||
.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<T>(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<string, T>);
|
||||
byIndex.sort((a, b) => Number(a[0]) - Number(b[0]));
|
||||
return byIndex.map((entry) => entry[1]);
|
||||
}
|
||||
@@ -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<string>(),
|
||||
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<string, string>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
+18
-17
@@ -1,12 +1,14 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string, unknown> {
|
||||
/** Section name → view name; empty when storage is missing, blocked or garbled. */
|
||||
function remembered(): Record<string, unknown> {
|
||||
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<string, unknown>) : {};
|
||||
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)
|
||||
|
||||
+68
-137
@@ -1,155 +1,86 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string, string>) =>
|
||||
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<string, string>) =>
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
-65
@@ -1,97 +1,74 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string, string> = {
|
||||
eq: '',
|
||||
const OPERATOR_SUFFIX: Record<string, string> = {
|
||||
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<string, unknown>,
|
||||
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<string, string>;
|
||||
/** The list's filter definitions; they say which fields are dates. */
|
||||
filters?: Filter[];
|
||||
/** Always part of the query. */
|
||||
filtersStatic?: Record<string, unknown>;
|
||||
/** x: objects take operators and date ranges; others match exactly. */
|
||||
isXPrefixed: boolean;
|
||||
}
|
||||
|
||||
export function buildJmapFilter({
|
||||
appliedFilters,
|
||||
filters,
|
||||
filters = [],
|
||||
filtersStatic,
|
||||
isXPrefixed,
|
||||
}: BuildJmapFilterArgs): Record<string, unknown> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (filtersStatic) {
|
||||
Object.assign(filter, filtersStatic);
|
||||
const dates = new Set(filters.filter((f) => f.type === 'date').map((f) => f.field));
|
||||
const out: Record<string, unknown> = { ...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;
|
||||
}
|
||||
@@ -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('<html>', { headers: { 'content-type': 'text/html' } }))).state).toEqual({
|
||||
status: 'default',
|
||||
});
|
||||
expect((await load(new Error('offline'))).state).toEqual({ status: 'default' });
|
||||
});
|
||||
});
|
||||
+30
-25
@@ -1,40 +1,45 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string | null> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
+32
-31
@@ -1,10 +1,7 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
+39
-37
@@ -1,64 +1,69 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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<string, string> = {
|
||||
/** Which field holds the script, for each object that has one. */
|
||||
const SCRIPT_FIELD: Record<string, string> = {
|
||||
'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<Uint8Array> {
|
||||
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<string, unknown> = {},
|
||||
base = playgroundUrl(),
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
+4
-5
@@ -1,12 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* 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));
|
||||
Reference in new issue
Block a user