594 lines
26 KiB
TypeScript
594 lines
26 KiB
TypeScript
// 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[] };
|
||
}
|