// 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 { 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 { 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 { 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): 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 { const res = await fetch(url); if (!res.ok) { const data = await res.json().catch(() => ({} as Record)) as Record; 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 { 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 { 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[] }; }