192 lines
7.8 KiB
TypeScript
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);
|
|
}
|
|
}
|