sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+410 -8
View File
@@ -13,6 +13,13 @@ export interface FileProvenance {
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). */
@@ -28,15 +35,34 @@ export async function fetchFileProvenance(
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;
}
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
/**
* 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()}`);
@@ -45,6 +71,29 @@ export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'in
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()}`);
@@ -55,6 +104,202 @@ export async function fetchLocalFileContent(taskId: number, section: 'workspace'
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()}`;
@@ -65,8 +310,15 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。
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;
@@ -74,11 +326,29 @@ export interface OfficeSpreadsheetSheet {
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';
@@ -92,24 +362,85 @@ export interface OfficeDocumentPreview {
pageCount: number;
truncated: boolean;
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview;
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;
constructor(message: string, 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 { error?: string; message?: string }));
throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error);
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;
}
@@ -186,6 +517,77 @@ export async function updateLocalFileContent(taskId: number, section: string, pa
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.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[] };
}