// 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; } /** 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; } export interface LocalFileEntry { name: string; path: string; kind: 'directory' | 'file'; size: number; modifiedAt: string; } export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> { 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; } 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(); } 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()}`; } // ── Office プレビュー (Excel / PowerPoint) ─────────────────────────────── // サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。 export interface OfficeSpreadsheetSheet { name: string; rows: string[][]; rowCount: number; colCount: number; truncated: boolean; } export interface OfficeSpreadsheetPreview { kind: 'spreadsheet'; sheets: OfficeSpreadsheetSheet[]; truncated: boolean; } 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 type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview; /** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */ export class OfficePreviewError extends Error { /** サーバが返した error コード ('converter_unavailable' 等)。 */ code?: string; constructor(message: string, code?: string) { super(message); this.name = 'OfficePreviewError'; this.code = code; } } export async function fetchOfficePreview(url: string): Promise { 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); } 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 })); throw new Error(err.error || res.statusText); } }