Files
maestro/ui/src/api/task-files.ts
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

594 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// api.ts から分割(挙動不変): タスクワークスペースのファイル(一覧・内容・Office プレビュー・アップロード/削除/zip)。
import { BASE, triggerBlobDownload } from './client';
// Mirrors the API's safe projection (local-files-api.ts): task IDs + source
// kind + piece/movement + timestamps only. Job UUIDs, checksum, and the
// free-text note are intentionally NOT sent to the client (adversarial-review D4).
export interface FileProvenance {
relPath: string;
sourceKind: string;
createdByTaskId: number | null;
createdByPiece: string | null;
createdByMovement: string | null;
firstSeenAt: string | null;
lastModifiedByTaskId: number | null;
lastModifiedAt: string | null;
/**
* 置いた人の表示名 (#1287)。users.id は返らない。名前が出せないとき(台帳に
* 人が入っていない・認証なしの環境・消えた利用者)は null。
*/
uploadedByName: string | null;
/** 最後に書き換えた人の表示名 (#1287)。null の意味は uploadedByName と同じ。 */
lastModifiedByName: string | null;
}
/** Fetch the provenance record for one workspace file (null when untracked). */
export async function fetchFileProvenance(
taskId: number,
section: string,
path: string,
): Promise<FileProvenance | null> {
const qs = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/provenance?${qs.toString()}`);
if (!res.ok) return null;
const data = await res.json();
return (data.provenance ?? null) as FileProvenance | null;
}
/**
* 一覧の 1 件に添えて返る台帳の要点(#1280 第 3 段)。台帳に行が無いファイルには
* 付かない。中身は FileProvenance と同じ安全な範囲の一部で、置いた人は入らない。
*/
export interface FileOrigin {
sourceKind: string;
createdByTaskId: number | null;
lastModifiedByTaskId: number | null;
}
export interface LocalFileEntry {
name: string;
path: string;
kind: 'directory' | 'file';
size: number;
modifiedAt: string;
/** 台帳に行があるときだけ入る。無いファイル(台帳を入れる前からあるもの)では undefined。 */
origin?: FileOrigin;
}
/**
* 1 フォルダぶんの一覧。
* truncated は、並べた項目のうち「在るのに読めなかった」ものを飛ばしたときに立つ
* (#1313)。消えたもの(リンク切れ・並べた直後に消えたファイル)では立たない。
* 意味は再帰の口(fetchLocalFilesRecursive)の truncated と同じ。画面の知らせは
* まだ足していない。
*/
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
/**
* フォルダ配下すべてのファイルを 1 回で取る(#1308)。ファイルタブの検索は、
* 検索語が入っているときだけこの口を叩く(空のときは fetchLocalFiles のまま)。
* サーバ側の歯止め(ファイル 1000 件・深さ 10・訪問 2000 件)のどれかに当たると
* truncated が true になる。フォルダが多い置き場では訪問数がいちばん先に効くので、
* 画面の文言に「1000 件まで見た」とは書かない(#1308 レビュー P2-2)。
* 読めないフォルダがあったときも truncated が立つので、文言で理由を 1 つに
* 決めつけない(#1308 レビュー P2-A)。
* フォルダは返らない。path は返る entries の path にも接頭辞として残る。
*/
export async function fetchLocalFilesRecursive(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
path: string = '',
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; count: number; truncated: boolean }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/recursive?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files recursively');
return data;
}
export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise<string> {
const params = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return await res.text();
}
/** 窓 1 回ぶんの読み取り結果。位置はすべてバイト数(サーバ側 FileWindow と対)。 */
export interface LocalFileWindow {
content: string;
/** 実際に読み始めた位置(文字・行の境界に寄せた後)。 */
offset: number;
/** 次に続きを取るとき渡す位置。 */
nextOffset: number;
/** ファイル全体のバイト数。 */
size: number;
/** 手前か後ろに読んでいない部分があるとき true。 */
truncated: boolean;
}
/**
* ファイルの一部だけを読む (#1002 / #1008)。
*
* `offset` を省くと末尾の `limit` バイトを読む。続きは戻り値の `nextOffset` を
* 渡して取る。実行中ずっと追記され続ける推論の記録を、全文取り直しなしで
* 追いかけるための入口。表示中に画面を離れたら中断できるよう signal を受ける。
*/
export async function fetchLocalFileWindow(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
path: string,
opts: { offset?: number; limit: number; signal?: AbortSignal },
): Promise<LocalFileWindow> {
const params = new URLSearchParams({ section, path, limit: String(opts.limit) });
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`, {
...(opts.signal ? { signal: opts.signal } : {}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// 状態コードを添える: まだ記録が無いだけの 404 と本当の失敗を、呼び出し側が
// 分けて扱えるようにする(無い状態でエラーを出すのは雑音)。
throw Object.assign(new Error(data?.error ?? 'Failed to read file'), { status: res.status });
}
// 位置のヘッダが欠けていたら失敗として扱う。0 に落とすと「先頭から読み直す」
// 指示になり、継ぎ足しの経路で最初の 1 窓を毎回二重に足し続ける。
// (逆プロキシが X-* を落とす構成で起きうる。同一オリジンの今は起きない)
const num = (name: string) => {
const raw = res.headers.get(name);
const value = raw === null ? NaN : Number(raw);
if (!Number.isFinite(value)) throw new Error(`Missing or invalid ${name} header`);
return value;
};
const offset = num('X-Content-Offset');
const nextOffset = num('X-Content-Next-Offset');
const size = num('X-Content-Size');
return {
content: await res.text(),
offset,
nextOffset,
size,
truncated: res.headers.get('X-Content-Truncated') === '1',
};
}
/**
* 書き込みがサーバに断られたときの型。`code` は画面が文言を選ぶための目印で、
* 権限不足なら `'no_edit_permission'` が入る(#1530)。
*/
export class FileWriteError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = 'FileWriteError';
if (code) this.code = code;
}
}
/** 保存が断られた理由を、画面側が翻訳して出せるようにするための型。 */
export class XlsxCellsSaveError extends Error {
/** サーバが返したコード('source_changed' 等)。 */
code?: string;
/**
* 断りの原因になったセル(#1351)。コピーした式のおおもとを直したときに、
* 残りのマスの式を書き直せなかった場合に付く。**どのセルが原因か**を画面に
* 出すために要る——理由だけ出しても、利用者はどこを直せばよいか分からない。
*/
cells?: string[];
/** そのかたまりの範囲(`C2:C4` など)。`cells` と同じ並び。 */
ranges?: string[];
/** 列を入れられなかった理由の符号(#1540)。画面が訳す。 */
reasons?: string[];
/** そのとき見つかったもの(要素名・関連付けの種類・部品名など・#1540)。 */
unsupportedElements?: string[];
/** 断られたシート(#1540)。 */
sheet?: string;
constructor(
message: string,
code?: string,
detail?: { cells?: unknown; ranges?: unknown; reasons?: unknown; unsupportedElements?: unknown; sheet?: unknown },
) {
super(message);
this.name = 'XlsxCellsSaveError';
if (code) this.code = code;
const cells = detail?.cells;
if (Array.isArray(cells)) this.cells = cells.map(String);
const ranges = detail?.ranges;
if (Array.isArray(ranges)) this.ranges = ranges.map(String);
const reasons = detail?.reasons;
if (Array.isArray(reasons)) this.reasons = reasons.map(String);
const found = detail?.unsupportedElements;
if (Array.isArray(found)) this.unsupportedElements = found.map(String);
if (typeof detail?.sheet === 'string') this.sheet = detail.sheet;
}
}
/**
* 保存の口が受けるセル 1 つぶんの指定。書式の欄名は**下線つなぎ**で、サーバ側の
* `parseCellEdits` が読む名前とそろえてある(#1322 段 1 で書式が乗った)。
*/
export interface XlsxCellEditPayload {
sheet?: string;
cell: string;
value?: string | number | boolean | null;
formula?: string;
style?: {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
font_name?: string | null;
font_size?: number | null;
font_color?: string | null;
fill_color?: string | null;
border?: { edges?: ('top' | 'right' | 'bottom' | 'left')[]; style?: string; color?: string } | null;
alignment?: { horizontal?: string; vertical?: string; wrap_text?: boolean;
text_rotation?: number | 'vertical' | null } | null;
};
}
export interface XlsxMergeEditPayload {
sheet?: string;
range: string;
merged: boolean;
}
/** 列幅(Excel の文字幅単位)。null で既定幅に戻す。 */
export interface XlsxColumnEditPayload {
sheet?: string;
columns: string;
width: number | null;
}
/** 行の高さ(ポイント)。null で自動に戻す。 */
export interface XlsxRowEditPayload {
sheet?: string;
row: number;
height: number | null;
}
/**
* 画面で直した Excel のセルを保存する(#1035 段階 2)。
*
* 指定したセル以外は変えない経路(サーバ側 xlsx-edit)を通る。実行中や
* 持ち主以外の保存はサーバが断る。
*
* `expectedSourceSha256` は**開いたときのファイルの検査値**。プレビューが返した
* 値をそのまま返す。その間に誰かが同じファイルを変えていたらサーバが断るので、
* タブを 2 枚開いても後勝ちで上書きしない(#1067)。
*
* 返事の `source_sha256` は**保存し終えたファイルの検査値**(#1463)。続けて
* 保存するときはこれを次の `expectedSourceSha256` に使う。取り直さずに続けて
* 保存できるのはこの値があるからで、無ければ画面を取り直して拾い直すしかない。
*/
export async function updateLocalXlsxCells(
taskId: number,
section: 'input' | 'output',
path: string,
edits: XlsxCellEditPayload[],
expectedSourceSha256: string,
rows: XlsxRowEditPayload[] = [],
columns: XlsxColumnEditPayload[] = [],
structural: XlsxStructuralOpPayload[] = [],
merges: XlsxMergeEditPayload[] = [],
): Promise<{ sheets: string[]; cells: number; rows?: number; structural?: number; source_sha256?: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/xlsx-cells`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
section, path, edits, expected_source_sha256: expectedSourceSha256,
...(rows.length > 0 ? { rows } : {}),
...(columns.length > 0 ? { columns } : {}),
// 列の挿入(#1540)。サーバは**これを先に**当ててから `edits` を当てるので、
// `edits` の番地は入れた後のものでなければならない。
...(structural.length > 0 ? { structural } : {}),
...(merges.length > 0 ? { merges } : {}),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new XlsxCellsSaveError(data?.error ?? 'Failed to save cells', data?.code, data);
return data as { sheets: string[]; cells: number; rows?: number; structural?: number; source_sha256?: string };
}
export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path, trusted: '1' });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
import type { SheetGrid } from '../lib/spreadsheet-grid';
import type { SheetStructuralEdit, XlsxStructuralOpPayload } from '../lib/spreadsheet-structural';
// 保存の口へ送る構造の操作(#1540)。型そのものは lib 側にあり、ここは通し口。
export type { XlsxStructuralOpPayload } from '../lib/spreadsheet-structural';
// ── Office プレビュー (Excel / PowerPoint / Word / Outlook .msg) ─────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)、
// .msg→メールのヘッダー+本文(JSON)に変換して返す。
export interface OfficeSpreadsheetSheet {
name: string;
rows: string[][];
rowCount: number;
colCount: number;
truncated: boolean;
/**
* 書式つきの中身(#1264)。表計算らしい格子はこちらを描く。素の表は `rows` の
* ままで、読み上げソフト向けの逃げ道として残る。
*
* 任意にしてあるのは、配信をまたいで開いたままのタブが古いサーバから受け取る
* 場合があるため。無ければ格子への切り替えを出さない。
*/
grid?: SheetGrid;
/**
* このシートで列を入れてよいか(#1540 段 1)。判定の持ち主はサーバ
* (`src/office/xlsx-structural-gate.ts`)で、画面は理由の符号を訳すだけ。
*
* `grid` と同じく任意にしてある。古いサーバに繋がったままのタブには届かず、
* そのときは列の挿入を通さない。
*/
structuralEdit?: SheetStructuralEdit;
}
export interface OfficeSpreadsheetPreview {
kind: 'spreadsheet';
sheets: OfficeSpreadsheetSheet[];
truncated: boolean;
/** いま映しているファイルの検査値。保存のときに送り返して、後勝ちを防ぐ。 */
sourceSha256: string;
}
export interface OfficePresentationPreview {
kind: 'presentation';
slides: { index: number; dataUrl: string }[];
slideCount: number;
truncated: boolean;
}
export interface OfficeDocumentPreview {
kind: 'document';
pages: { index: number; dataUrl: string }[];
pageCount: number;
truncated: boolean;
}
export interface OfficeEmailAddress {
name?: string;
email?: string;
}
export interface OfficeEmailPreview {
kind: 'email';
subject?: string;
from?: OfficeEmailAddress;
to: OfficeEmailAddress[];
cc: OfficeEmailAddress[];
date?: string;
body: {
/** 未 sanitize の HTML。描画側は DOMPurify + sandbox iframe を必ず通すこと。 */
html?: string;
text: string;
format: 'html' | 'plain' | 'none';
truncated: boolean;
};
attachments: { fileName: string; contentLength?: number; embeddedMessage?: boolean }[];
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview | OfficeEmailPreview;
/**
* 大きすぎて映せなかったときにサーバが返す中身(413)。**数値だけ**で、文言は
* 入っていない。画面の言語で組み直すため(#1084 レビュー P2-3)。
*/
export interface OfficePreviewTooLargeDetail {
/** 'unknown' はサーバと画面の版がずれたとき。画面は汎用の文を出す。 */
reason: 'file_size' | 'uncompressed' | 'entries' | 'unknown';
actualMb?: number;
limitMb?: number;
actualEntries?: number;
limitEntries?: number;
}
/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */
export class OfficePreviewError extends Error {
/** サーバが返した error コード ('converter_unavailable' 等)。 */
code?: string;
/** 413 のときだけ入る、断った理由と数値。 */
tooLarge?: OfficePreviewTooLargeDetail;
constructor(message: string, code?: string, tooLarge?: OfficePreviewTooLargeDetail) {
super(message);
this.name = 'OfficePreviewError';
this.code = code;
if (tooLarge) this.tooLarge = tooLarge;
}
}
const TOO_LARGE_REASONS = ['file_size', 'uncompressed', 'entries'] as const;
function parseTooLarge(data: Record<string, unknown>): OfficePreviewTooLargeDetail | undefined {
if (data?.error !== 'file_too_large') return undefined;
// reason が読めなくても 413 だったことは伝える。画面はそのとき汎用の文を出す
// (サーバと画面の版がずれても、英語の原文が漏れない)。
const reason = TOO_LARGE_REASONS.find(r => r === data?.reason) ?? 'unknown';
const num = (key: string): number | undefined => (typeof data?.[key] === 'number' ? data[key] as number : undefined);
const actualMb = num('actualMb');
const limitMb = num('limitMb');
const actualEntries = num('actualEntries');
const limitEntries = num('limitEntries');
return {
reason,
...(actualMb === undefined ? {} : { actualMb }),
...(limitMb === undefined ? {} : { limitMb }),
...(actualEntries === undefined ? {} : { actualEntries }),
...(limitEntries === undefined ? {} : { limitEntries }),
};
}
export async function fetchOfficePreview(url: string): Promise<OfficePreview> {
const res = await fetch(url);
if (!res.ok) {
const data = await res.json().catch(() => ({} as Record<string, unknown>)) as Record<string, unknown>;
const message = (typeof data?.message === 'string' ? data.message : undefined)
?? (typeof data?.error === 'string' ? data.error : undefined)
?? 'Failed to load preview';
const code = typeof data?.error === 'string' ? data.error : undefined;
throw new OfficePreviewError(message, code, parseTooLarge(data));
}
return (await res.json()) as OfficePreview;
}
export function getLocalFileOfficePreviewUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/office-preview?${params.toString()}`;
}
// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。
export type WritableTaskSection = 'input' | 'output';
// タスクワークスペースの input/output へファイルをアップロード(複数可)。サーバが
// O_EXCL で衝突回避リネーム + ensurePathWithin で section root に封じ込め。実行中は 409。
export async function uploadLocalFiles(
taskId: number,
section: WritableTaskSection,
path: string,
files: { name: string; contentBase64: string }[],
): Promise<{ uploaded: { name: string; path: string }[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files');
return data as { uploaded: { name: string; path: string }[] };
}
// タスクワークスペースの input/output ファイルを削除(複数選択は paths 配列)。サーバ側で
// section root に封じ込め、ファイルのみ対象、存在しないものは冪等スキップ。
export async function deleteLocalFiles(
taskId: number,
section: WritableTaskSection,
paths: string[],
): Promise<{ deleted: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files');
return data as { deleted: string[]; skipped: string[] };
}
// タスクワークスペースの複数ファイルを zip でまとめてダウンロード(全 section 可、read)。
// サーバが paths を section root に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadLocalFilesZip(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
paths: string[],
filename = 'files.zip',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/download-zip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to download files');
}
triggerBlobDownload(await res.blob(), filename);
}
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
// 断りの理由(`code`)を落とさずに投げる。サーバの英語の一文をそのまま
// 画面に出すと、権限の話が「Task not found」のような別の意味で読まれる(#1530)。
throw new FileWriteError(err.error || res.statusText, err.code);
}
}
/**
* ごみ箱の 1 件(#1530 第 2 段)。サーバの `GET files/trash` が返す形をそのまま写す
* (`src/bridge/local-files-trash-api.ts`)。`deletedByUserId` は使わないので載せない
* ——画面に出すのは表示名だけで、認証を使っていない環境や消えた利用者では null になる。
*/
export interface TaskTrashEntry {
/** ごみ箱の置き場の鍵。戻す・完全に消すはこれを渡す。 */
id: string;
/** 元の区分(input / output)。 */
section: string;
/** 区分から見た元の相対パス。 */
path: string;
/** 元の名前(パスの末尾)。 */
name: string;
kind: 'file' | 'directory';
size: number;
/** ISO8601。 */
deletedAt: string;
/** 消した人の表示名。出せないときは null。 */
deletedByName: string | null;
}
/** ごみ箱の中身。見られる人なら誰でも取れる(消せる人には絞られていない)。 */
export async function fetchTaskTrash(taskId: number): Promise<{ entries: TaskTrashEntry[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to load trash');
return data as { entries: TaskTrashEntry[] };
}
/** 1 件の復元の結果。`renamed` が true なら元の名前が空いておらず別名で戻っている。 */
export interface TaskTrashRestored {
id: string;
section: string;
/** 実際に戻した先(区分から見た相対パス)。 */
path: string;
renamed: boolean;
}
/** ごみ箱から元の場所へ戻す。戻し先が埋まっていればサーバが別名へ逃がす。 */
export async function restoreTaskTrash(
taskId: number,
ids: string[],
): Promise<{ restored: TaskTrashRestored[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to restore from trash');
return data as { restored: TaskTrashRestored[]; skipped: string[] };
}
/** ごみ箱の中身を本当に消す。戻せなくなる。 */
export async function purgeTaskTrash(
taskId: number,
ids: string[],
): Promise<{ purged: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash/purge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to purge trash');
return data as { purged: string[]; skipped: string[] };
}