Files
maestro/ui/src/api/task-files.ts
T
oss-sync 77ee3bc426
CI / build-and-test (push) Waiting to run
sync: update from private repo (ddadfd71)
2026-07-08 23:35:00 +00:00

192 lines
7.8 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;
}
/** 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;
}
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<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();
}
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<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);
}
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 }));
throw new Error(err.error || res.statusText);
}
}