Export what a legal hold keeps, from the Legal holds page #31
No files matched your search
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: exporting what a legal hold keeps (audit-hold-lock spec, LH-12).
|
||||
* The export runs on the server; this starts one, lists them, and downloads
|
||||
* a ready file for whoever started it.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Download, FileArchive, Loader2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
||||
import { useObjectLabel } from '@/lib/objectOptions';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { formatSize, type LegalHold } from './legalHold';
|
||||
import { downloadExport, exportFileName, startExport, type HoldExport } from './holdExport';
|
||||
|
||||
function AccountChip({ id, onRemove }: { id: string; onRemove?: () => void }) {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const { label } = useObjectLabel('x:Account', id, schema!);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs">
|
||||
{label ?? id}
|
||||
{onRemove && (
|
||||
<button type="button" onClick={onRemove} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportDialog({
|
||||
hold,
|
||||
onClose,
|
||||
onStarted,
|
||||
}: {
|
||||
hold: LegalHold;
|
||||
onClose: () => void;
|
||||
onStarted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [accounts, setAccounts] = useState<string[]>([]);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await startExport(hold.id, accounts, reason);
|
||||
toast({ title: t('hold.export.started', 'Export started') });
|
||||
onStarted();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('hold.export.notStarted', 'The export didn’t start'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('hold.export.title', 'Export {{name}}', { name: hold.name })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'hold.export.body',
|
||||
'Collects everything this hold covers into one ZIP: mail as .eml, calendars as .ics, contacts as .vcf, files as they are, and the deleted items it keeps, with a manifest of SHA-256 checksums. It runs on the server; you can leave this page.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{t('hold.export.accounts', 'People (leave empty for everyone the hold covers)')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{accounts.map((id) => (
|
||||
<AccountChip key={id} id={id} onRemove={() => setAccounts(accounts.filter((x) => x !== id))} />
|
||||
))}
|
||||
{schema && (
|
||||
<ObjectPicker
|
||||
schema={schema}
|
||||
objectName="x:Account/User"
|
||||
value=""
|
||||
onChange={(id) => {
|
||||
if (id && !accounts.includes(id)) setAccounts([...accounts, id]);
|
||||
}}
|
||||
placeholder={t('hold.addAccount', 'Add a person')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('hold.export.accountsHint', 'Someone the hold doesn’t cover is left out of the file.')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('hold.reason', 'Reason')}</span>
|
||||
<Textarea
|
||||
value={reason}
|
||||
maxLength={500}
|
||||
placeholder={t('hold.export.reasonHint', 'Why, for the audit log: a production request, a matter number…')}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('hold.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void run()} disabled={busy || !reason.trim()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileArchive className="mr-2 h-4 w-4" />}
|
||||
{t('hold.export.go', 'Start the export')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportRow({ hold, exp }: { hold: LegalHold; exp: HoldExport }) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const download = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await downloadExport(exp, exportFileName(hold.name, exp));
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('hold.export.downloadFailed', 'Couldn’t download the export'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 border-t pt-2 text-sm first:border-t-0 first:pt-0">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{exp.status === 'running' && (
|
||||
<Badge variant="outline">
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
{t('hold.export.running', 'Collecting')}
|
||||
</Badge>
|
||||
)}
|
||||
{exp.status === 'ready' && <Badge variant="secondary">{t('hold.export.ready', 'Ready')}</Badge>}
|
||||
{exp.status === 'failed' && <Badge variant="destructive">{t('hold.export.failed', 'Failed')}</Badge>}
|
||||
<span className="text-muted-foreground">
|
||||
{t('hold.export.startedBy', '{{when}} by {{who}}', {
|
||||
when: new Date(exp.createdAt).toLocaleString(),
|
||||
who: exp.createdBy,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">{exp.reason}</div>
|
||||
{exp.accountIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{exp.accountIds.map((id) => (
|
||||
<AccountChip key={id} id={id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{exp.status === 'ready' && (
|
||||
<div className="text-muted-foreground">
|
||||
{[
|
||||
exp.items === 1
|
||||
? t('hold.export.oneItem', '1 item')
|
||||
: t('hold.export.items', '{{n}} items', { n: String(exp.items) }),
|
||||
formatSize(exp.size),
|
||||
].join(' · ')}
|
||||
{exp.sha256 && <div className="break-all font-mono text-xs">SHA-256 {exp.sha256}</div>}
|
||||
</div>
|
||||
)}
|
||||
{exp.status === 'failed' && exp.error && <div className="text-destructive">{exp.error}</div>}
|
||||
</div>
|
||||
{exp.status === 'ready' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void download()} disabled={busy}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{t('hold.export.download', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A hold's exports, newest first. */
|
||||
export function ExportList({ hold, exports }: { hold: LegalHold; exports: HoldExport[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (!exports.length) return null;
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg bg-muted/40 p-3">
|
||||
<div className="text-sm text-muted-foreground">{t('hold.export.heading', 'Exports')}</div>
|
||||
{exports.map((exp) => (
|
||||
<ExportRow key={exp.id} hold={hold} exp={exp} />
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'hold.export.expiry',
|
||||
'Only the person who started an export can download it. The file is kept as a temporary upload (an hour, unless the upload time-to-live says otherwise); the record, with its checksum, stays.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Pencil, Scale, Unlock } from 'lucide-react';
|
||||
import { FileArchive, Pencil, Scale, Unlock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
@@ -20,6 +20,9 @@ import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchHolds, formatSize, HOLD_CHANGED, HoldsUnavailable, type LegalHold } from './legalHold';
|
||||
import { HoldDialog, ReleaseDialog, ScopeSummary } from './HoldDialogs';
|
||||
import { fetchExports, type HoldExport } from './holdExport';
|
||||
import { ExportDialog, ExportList } from './HoldExports';
|
||||
import { usePollWhileRunning } from './usePollWhileRunning';
|
||||
|
||||
type Load = { kind: 'loading' } | { kind: 'ready'; holds: LegalHold[] } | { kind: 'error'; message: string };
|
||||
|
||||
@@ -34,12 +37,17 @@ export function LegalHoldsPage() {
|
||||
const { t } = useTranslation();
|
||||
const canCreate = useAccountStore((s) => s.hasPermission('sysLegalHoldCreate'));
|
||||
const canUpdate = useAccountStore((s) => s.hasPermission('sysLegalHoldUpdate'));
|
||||
const canExport = useAccountStore((s) => s.hasPermission('sysLegalHoldExport'));
|
||||
const [load, setLoad] = useState<Load>({ kind: 'loading' });
|
||||
const [fetches, setFetches] = useState(0);
|
||||
const [placing, setPlacing] = useState(false);
|
||||
const [editing, setEditing] = useState<LegalHold | null>(null);
|
||||
const [releasing, setReleasing] = useState<LegalHold | null>(null);
|
||||
const [exporting, setExporting] = useState<LegalHold | null>(null);
|
||||
const [exports, setExports] = useState<HoldExport[]>([]);
|
||||
const [exportFetches, setExportFetches] = useState(0);
|
||||
const refetch = useCallback(() => setFetches((n) => n + 1), []);
|
||||
const refetchExports = useCallback(() => setExportFetches((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -62,6 +70,21 @@ export function LegalHoldsPage() {
|
||||
return () => controller.abort();
|
||||
}, [fetches, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canExport) return;
|
||||
const controller = new AbortController();
|
||||
fetchExports(controller.signal)
|
||||
.then((list) => {
|
||||
if (!controller.signal.aborted) setExports(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// An older server has no exports; the holds still show
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [canExport, exportFetches]);
|
||||
|
||||
usePollWhileRunning(exports, refetchExports);
|
||||
|
||||
const done = () => {
|
||||
setPlacing(false);
|
||||
setEditing(null);
|
||||
@@ -118,16 +141,26 @@ export function LegalHoldsPage() {
|
||||
</div>
|
||||
{hold.description && <div className="text-sm">{hold.description}</div>}
|
||||
</div>
|
||||
{canUpdate && (
|
||||
{(canUpdate || canExport) && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(hold)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('hold.widenAction', 'Widen…')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setReleasing(hold)}>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
{t('hold.releaseAction', 'Release…')}
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={() => setExporting(hold)}>
|
||||
<FileArchive className="mr-2 h-4 w-4" />
|
||||
{t('hold.export.action', 'Export…')}
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(hold)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('hold.widenAction', 'Widen…')}
|
||||
</Button>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReleasing(hold)}>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
{t('hold.releaseAction', 'Release…')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,6 +185,7 @@ export function LegalHoldsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExportList hold={hold} exports={exports.filter((e) => e.holdId === hold.id)} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -180,6 +214,16 @@ export function LegalHoldsPage() {
|
||||
|
||||
{placing && <HoldDialog onClose={() => setPlacing(false)} onDone={done} />}
|
||||
{editing && <HoldDialog existing={editing} onClose={() => setEditing(null)} onDone={done} />}
|
||||
{exporting && (
|
||||
<ExportDialog
|
||||
hold={exporting}
|
||||
onClose={() => setExporting(null)}
|
||||
onStarted={() => {
|
||||
setExporting(null);
|
||||
refetchExports();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{releasing && <ReleaseDialog hold={releasing} onClose={() => setReleasing(null)} onDone={done} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const jmapRequest = vi.fn();
|
||||
vi.mock('@/services/jmap/client', () => ({
|
||||
fetchSession: vi.fn(),
|
||||
getAccountId: () => 'admin',
|
||||
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
|
||||
}));
|
||||
vi.mock('@/services/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
const { exportFileName, fetchExports, startExport } = await import('./holdExport');
|
||||
|
||||
beforeEach(() => jmapRequest.mockReset());
|
||||
|
||||
describe('hold exports', () => {
|
||||
it('reads exports and settles unknown statuses as running', async () => {
|
||||
jmapRequest.mockResolvedValue([
|
||||
[
|
||||
'inbuxa:HoldExport/get',
|
||||
{
|
||||
list: [
|
||||
{
|
||||
id: 'e1',
|
||||
holdId: 'h',
|
||||
status: 'ready',
|
||||
blobId: 'b',
|
||||
size: 10,
|
||||
items: 2,
|
||||
sha256: 'ab',
|
||||
accountIds: ['x'],
|
||||
},
|
||||
{ id: 'e2', holdId: 'h', status: 'queued' },
|
||||
],
|
||||
},
|
||||
],
|
||||
]);
|
||||
const list = await fetchExports();
|
||||
expect(list[0]).toMatchObject({ id: 'e1', status: 'ready', blobId: 'b', items: 2, accountIds: ['x'] });
|
||||
expect(list[1]).toMatchObject({ status: 'running', blobId: null, accountIds: [], size: 0 });
|
||||
});
|
||||
|
||||
it('sends only the accounts asked for, and the reason trimmed', async () => {
|
||||
jmapRequest.mockResolvedValue([['inbuxa:HoldExport/set', { created: { x: { id: 'e3' } } }]]);
|
||||
expect(await startExport('h', [], ' production request ')).toBe('e3');
|
||||
const [[, args]] = jmapRequest.mock.calls[0][0];
|
||||
expect(args).toEqual({ accountId: 'admin', create: { x: { holdId: 'h', reason: 'production request' } } });
|
||||
|
||||
await startExport('h', ['a', 'b'], 'r');
|
||||
expect(jmapRequest.mock.calls[1][0][0][1].create.x.accountIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('passes the server’s refusal on', async () => {
|
||||
jmapRequest.mockResolvedValue([
|
||||
['inbuxa:HoldExport/set', { notCreated: { x: { type: 'invalidProperties', description: 'Say why.' } } }],
|
||||
]);
|
||||
await expect(startExport('h', [], 'r')).rejects.toThrow('Say why.');
|
||||
jmapRequest.mockResolvedValue([['error', { type: 'forbidden' }]]);
|
||||
await expect(fetchExports()).rejects.toThrow('forbidden');
|
||||
});
|
||||
|
||||
it('names the file after the case and the day', () => {
|
||||
const exp = { id: 'e1', createdAt: '2026-09-28T10:00:00Z' } as Parameters<typeof exportFileName>[1];
|
||||
expect(exportFileName('Matter 4411 / Acme', exp)).toBe('Matter-4411-Acme-2026-09-28-e1.zip');
|
||||
expect(exportFileName('///', exp)).toBe('hold-2026-09-28-e1.zip');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: `inbuxa:HoldExport` over JMAP (audit-hold-lock spec, LH-12):
|
||||
* collecting what a legal hold keeps as a ZIP, in the background. The file is
|
||||
* the blob of whoever started it, so only they download it.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
|
||||
const CAPABILITY = 'urn:inbuxa:jmap';
|
||||
const OBJECT = 'inbuxa:HoldExport';
|
||||
|
||||
export type ExportStatus = 'running' | 'ready' | 'failed';
|
||||
|
||||
export interface HoldExport {
|
||||
id: string;
|
||||
holdId: string;
|
||||
accountIds: string[];
|
||||
reason: string;
|
||||
status: ExportStatus;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
finishedAt: string | null;
|
||||
blobId: string | null;
|
||||
size: number;
|
||||
items: number;
|
||||
sha256: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function fromServer(item: Record<string, unknown>): HoldExport {
|
||||
const text = (v: unknown) => (typeof v === 'string' ? v : null);
|
||||
const status = String(item.status);
|
||||
return {
|
||||
id: String(item.id),
|
||||
holdId: String(item.holdId),
|
||||
accountIds: Array.isArray(item.accountIds) ? item.accountIds.map(String) : [],
|
||||
reason: text(item.reason) ?? '',
|
||||
status: status === 'ready' || status === 'failed' ? status : 'running',
|
||||
createdAt: text(item.createdAt) ?? '',
|
||||
createdBy: text(item.createdBy) ?? '',
|
||||
finishedAt: text(item.finishedAt),
|
||||
blobId: text(item.blobId),
|
||||
size: typeof item.size === 'number' ? item.size : 0,
|
||||
items: typeof item.items === 'number' ? item.items : 0,
|
||||
sha256: text(item.sha256),
|
||||
error: text(item.error),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(name: string | undefined, result: unknown): Error {
|
||||
const r = result as { type?: string; description?: string } | undefined;
|
||||
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
|
||||
}
|
||||
|
||||
/** Every export, newest first. */
|
||||
export async function fetchExports(signal?: AbortSignal): Promise<HoldExport[]> {
|
||||
const responses = await jmapRequest(
|
||||
[[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ids: null }, '0']],
|
||||
signal,
|
||||
[CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${OBJECT}/get`) throw failure(name, result);
|
||||
return ((result as { list?: Record<string, unknown>[] }).list ?? []).map(fromServer);
|
||||
}
|
||||
|
||||
/** Starts collecting what `holdId` keeps, for `accountIds` or all it covers. */
|
||||
export async function startExport(holdId: string, accountIds: string[], reason: string): Promise<string> {
|
||||
const create: Record<string, unknown> = { holdId, reason: reason.trim() };
|
||||
if (accountIds.length) create.accountIds = accountIds;
|
||||
const responses = await jmapRequest(
|
||||
[[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), create: { x: create } }, '0']],
|
||||
undefined,
|
||||
[CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${OBJECT}/set`) throw failure(name, result);
|
||||
const r = result as {
|
||||
created?: { x?: { id: string } };
|
||||
notCreated?: { x?: { description?: string; type?: string } };
|
||||
};
|
||||
if (!r.created?.x) throw new Error(r.notCreated?.x?.description ?? r.notCreated?.x?.type ?? 'Not started');
|
||||
return r.created.x.id;
|
||||
}
|
||||
|
||||
/** A name for the downloaded file. */
|
||||
export function exportFileName(holdName: string, exp: HoldExport): string {
|
||||
const slug = holdName.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'hold';
|
||||
return `${slug}-${exp.createdAt.slice(0, 10)}-${exp.id}.zip`;
|
||||
}
|
||||
|
||||
function pathOf(url: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://placeholder');
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads a ready export: the signed-in person's own blob. */
|
||||
export async function downloadExport(exp: HoldExport, fileName: string): Promise<void> {
|
||||
if (!exp.blobId) throw new Error('This export has no file.');
|
||||
const session = await fetchSession();
|
||||
const template =
|
||||
typeof session.downloadUrl === 'string'
|
||||
? session.downloadUrl
|
||||
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
|
||||
const url = template
|
||||
.replace('{accountId}', encodeURIComponent(getAccountId('x:Account')))
|
||||
.replace('{blobId}', encodeURIComponent(exp.blobId))
|
||||
.replace('{name}', encodeURIComponent(fileName))
|
||||
.replace('{type}', encodeURIComponent('application/zip'));
|
||||
const response = await apiFetch(pathOf(url));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status === 404 || response.status === 403
|
||||
? `The file has expired, or it's ${exp.createdBy}'s to download. Start a new export.`
|
||||
: `Download failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
const href = URL.createObjectURL(await response.blob());
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 10_000);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { HoldExport } from './holdExport';
|
||||
|
||||
/** Refetches every few seconds while any export is still collecting. */
|
||||
export function usePollWhileRunning(exports: HoldExport[], refetch: () => void) {
|
||||
const running = exports.some((e) => e.status === 'running');
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(refetch, 4000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, refetch]);
|
||||
}
|
||||
Reference in new issue
Block a user