This commit is contained in:
+410
-8
@@ -13,6 +13,13 @@ export interface FileProvenance {
|
||||
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). */
|
||||
@@ -28,15 +35,34 @@ export async function fetchFileProvenance(
|
||||
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;
|
||||
}
|
||||
|
||||
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
|
||||
/**
|
||||
* 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()}`);
|
||||
@@ -45,6 +71,29 @@ export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'in
|
||||
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()}`);
|
||||
@@ -55,6 +104,202 @@ export async function fetchLocalFileContent(taskId: number, section: 'workspace'
|
||||
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()}`;
|
||||
@@ -65,8 +310,15 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
|
||||
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
|
||||
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。
|
||||
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;
|
||||
@@ -74,11 +326,29 @@ export interface OfficeSpreadsheetSheet {
|
||||
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';
|
||||
@@ -92,24 +362,85 @@ export interface OfficeDocumentPreview {
|
||||
pageCount: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview;
|
||||
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;
|
||||
constructor(message: string, 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 { error?: string; message?: string }));
|
||||
throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error);
|
||||
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;
|
||||
}
|
||||
@@ -186,6 +517,77 @@ export async function updateLocalFileContent(taskId: number, section: string, pa
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.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[] };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user