Files
edgeever/packages/shared/src/table.ts
T
tianma-if c7f6e342be feat(web): add table attachments and a public entry form
Store attachment files in object storage and keep only resource references in the table. A separate public form link appends one row without showing existing records.
2026-09-23 13:03:12 +08:00

447 lines
18 KiB
TypeScript

import { Base64 } from "js-base64";
export const TABLE_SCHEMA_VERSION = 1 as const;
export const TABLE_FIELD_LIMIT = 40;
export const TABLE_RECORD_LIMIT = 2000;
export const TABLE_FIELD_TYPES = ["text", "number", "checkbox", "date", "select", "url", "attachment"] as const;
export const TABLE_FILTER_OPERATORS = ["contains", "eq", "empty", "notEmpty"] as const;
export const TABLE_ATTACHMENT_LIMIT = 10;
export const TABLE_ATTACHMENT_FILTER_OPERATORS = ["empty", "notEmpty"] as const;
export type TableFieldType = (typeof TABLE_FIELD_TYPES)[number];
export type TableFilterOperator = (typeof TABLE_FILTER_OPERATORS)[number];
export type TableAttachment = {
resourceId: string;
filename: string;
mimeType: string;
byteSize: number;
};
export type TableCellValue = string | number | boolean | null | TableAttachment[];
export type TableField = {
id: string;
name: string;
type: TableFieldType;
options?: string[];
};
export type TableRecord = {
id: string;
cells: Record<string, TableCellValue>;
};
export type TableFilter = {
fieldId: string;
operator: TableFilterOperator;
value?: string;
};
export type TableSort = {
fieldId: string;
direction: "asc" | "desc";
};
export type TableView = {
filters: TableFilter[];
sort: TableSort | null;
};
export type TableDocument = {
schemaVersion: typeof TABLE_SCHEMA_VERSION;
fields: TableField[];
records: TableRecord[];
view: TableView;
};
export type TableSeedLabels = {
name?: string;
status?: string;
date?: string;
sample?: string;
notStarted?: string;
inProgress?: string;
done?: string;
};
export type TableSummaryPreview = {
fieldCount: number;
recordCount: number;
fieldNames: string[];
};
const TABLE_MARKER = "edgeever-table-v1";
const TABLE_COMMENT = new RegExp(`<!--\\s*${TABLE_MARKER}:([\\s\\S]*?)\\s*-->`);
const FIELD_NAME_LIMIT = 80;
const OPTION_LIMIT = 40;
const CELL_TEXT_LIMIT = 2000;
const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
const ATTACHMENT_BYTE_LIMIT = 1024 * 1024 * 1024;
const encodeBase64Url = (value: string) => Base64.encodeURI(value);
const decodeBase64Url = (value: string) => Base64.decode(value);
export const createTableId = (prefix: "fld" | "rec") => {
const uuid = globalThis.crypto?.randomUUID?.();
const suffix = uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
return `${prefix}_${suffix}`;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === "object" && !Array.isArray(value);
const cleanName = (value: unknown, fallback: string) => {
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
return (name || fallback).slice(0, FIELD_NAME_LIMIT);
};
const cleanOptions = (value: unknown) => {
if (!Array.isArray(value)) return [];
const options: string[] = [];
for (const item of value) {
if (typeof item !== "string") continue;
const option = item.replace(/\s+/g, " ").trim().slice(0, FIELD_NAME_LIMIT);
if (!option || options.includes(option)) continue;
options.push(option);
if (options.length >= OPTION_LIMIT) break;
}
return options;
};
const cleanAttachment = (value: unknown): TableAttachment | null => {
if (!isRecord(value) || typeof value.resourceId !== "string" || !ATTACHMENT_ID_PATTERN.test(value.resourceId)) return null;
const filename = cleanName(value.filename, "attachment");
const mimeType = typeof value.mimeType === "string" ? value.mimeType.replace(/\s+/g, " ").trim().slice(0, 120) : "";
const byteSize = typeof value.byteSize === "number" ? value.byteSize : Number(value.byteSize);
if (!Number.isFinite(byteSize) || byteSize < 0) return null;
return {
resourceId: value.resourceId,
filename,
mimeType,
byteSize: Math.min(Math.floor(byteSize), ATTACHMENT_BYTE_LIMIT),
};
};
export const tableAttachmentUrl = (resourceId: string) =>
`/api/v1/resources/${encodeURIComponent(resourceId)}/blob`;
export const coerceTableCell = (field: TableField, value: unknown): TableCellValue => {
if (field.type === "attachment") {
const items = Array.isArray(value) ? value : [];
const attachments: TableAttachment[] = [];
for (const item of items) {
const attachment = cleanAttachment(item);
if (!attachment || attachments.some((current) => current.resourceId === attachment.resourceId)) continue;
attachments.push(attachment);
if (attachments.length >= TABLE_ATTACHMENT_LIMIT) break;
}
return attachments;
}
if (field.type === "checkbox") {
return value === true || value === "true" || value === 1 || value === "1";
}
if (field.type === "number") {
if (value === null || value === undefined || value === "") return null;
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
}
if (Array.isArray(value)) {
return value.map((item) => cleanAttachment(item)?.filename ?? "").filter(Boolean).join(", ").slice(0, CELL_TEXT_LIMIT);
}
if (value === null || value === undefined) return "";
return String(value).slice(0, CELL_TEXT_LIMIT);
};
const emptyCell = (field: TableField): TableCellValue => coerceTableCell(field, field.type === "checkbox" ? false : null);
export const listTableAttachmentResourceIds = (document: TableDocument | null | undefined) => {
const ids = new Set<string>();
if (!document) return ids;
for (const record of document.records) {
for (const field of document.fields) {
if (field.type !== "attachment") continue;
const value = record.cells[field.id];
if (!Array.isArray(value)) continue;
for (const attachment of value) ids.add(attachment.resourceId);
}
}
return ids;
};
const parseField = (value: unknown): TableField | null => {
if (!isRecord(value) || typeof value.id !== "string" || !value.id || value.id.length > 80) return null;
if (!TABLE_FIELD_TYPES.includes(value.type as TableFieldType)) return null;
const type = value.type as TableFieldType;
const field: TableField = { id: value.id, name: cleanName(value.name, "字段"), type };
if (type === "select") field.options = cleanOptions(value.options);
return field;
};
const parseView = (value: unknown, fieldIds: Set<string>): TableView => {
if (!isRecord(value)) return { filters: [], sort: null };
const filters: TableFilter[] = [];
if (Array.isArray(value.filters)) {
for (const item of value.filters) {
if (!isRecord(item) || typeof item.fieldId !== "string" || !fieldIds.has(item.fieldId)) continue;
if (!TABLE_FILTER_OPERATORS.includes(item.operator as TableFilterOperator)) continue;
const filter: TableFilter = { fieldId: item.fieldId, operator: item.operator as TableFilterOperator };
if (typeof item.value === "string") filter.value = item.value.slice(0, CELL_TEXT_LIMIT);
filters.push(filter);
if (filters.length >= 8) break;
}
}
const sort = isRecord(value.sort)
&& typeof value.sort.fieldId === "string"
&& fieldIds.has(value.sort.fieldId)
&& (value.sort.direction === "asc" || value.sort.direction === "desc")
? { fieldId: value.sort.fieldId, direction: value.sort.direction as "asc" | "desc" }
: null;
return { filters, sort };
};
export const parseTableDocument = (markdown: string | null | undefined): TableDocument | null => {
const encoded = markdown?.match(TABLE_COMMENT)?.[1]?.replace(/\s+/g, "");
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
try {
const value = JSON.parse(decodeBase64Url(encoded)) as Record<string, unknown>;
if (value.schemaVersion !== TABLE_SCHEMA_VERSION || !Array.isArray(value.fields) || value.fields.length < 1) return null;
const fields: TableField[] = [];
for (const item of value.fields) {
const field = parseField(item);
if (!field || fields.some((current) => current.id === field.id)) return null;
fields.push(field);
}
if (!Array.isArray(value.records)) return null;
const fieldIds = new Set(fields.map((field) => field.id));
const records: TableRecord[] = [];
const recordIds = new Set<string>();
for (const item of value.records) {
if (!isRecord(item) || typeof item.id !== "string" || !item.id || item.id.length > 80 || recordIds.has(item.id)) return null;
recordIds.add(item.id);
const rawCells = isRecord(item.cells) ? item.cells : {};
const cells: Record<string, TableCellValue> = {};
for (const field of fields) cells[field.id] = coerceTableCell(field, rawCells[field.id]);
records.push({ id: item.id, cells });
}
return { schemaVersion: TABLE_SCHEMA_VERSION, fields, records, view: parseView(value.view, fieldIds) };
} catch {
return null;
}
};
export const hasTableDocumentMarker = (markdown: string | null | undefined) =>
Boolean(markdown?.match(TABLE_COMMENT));
export const stripTableDocumentMarker = (markdown: string | null | undefined) =>
(markdown ?? "").replace(TABLE_COMMENT, "").trimEnd();
const markdownLabel = (value: string) => value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
const attachmentNames = (value: TableCellValue) =>
Array.isArray(value) ? value.map((item) => item.filename).filter(Boolean) : [];
const markdownCell = (field: TableField, value: TableCellValue) => {
if (field.type === "attachment" && Array.isArray(value)) {
return value.map((item) => `[${markdownLabel(item.filename)}](${tableAttachmentUrl(item.resourceId)})`).join(" ");
}
if (value === null || value === undefined || value === "") return "";
const text = field.type === "checkbox" ? (value === true ? "true" : "false") : String(value);
return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|");
};
export const tableFallbackMarkdown = (document: TableDocument) => {
const header = document.fields.map((field) => markdownCell({ ...field, type: "text" }, field.name) || "字段");
const divider = header.map(() => "---");
const rows = document.records.map((record) => document.fields.map((field) => markdownCell(field, record.cells[field.id] ?? null)));
return [header, divider, ...rows].map((row) => `| ${row.join(" | ")} |`).join("\n");
};
export const serializeTableDocument = (document: TableDocument) =>
`${tableFallbackMarkdown(document)}\n\n<!-- ${TABLE_MARKER}:${encodeBase64Url(JSON.stringify(document))} -->`;
export const createDefaultTableDocument = (labels: TableSeedLabels = {}): TableDocument => {
const nameId = "fld_name";
const statusId = "fld_status";
const dateId = "fld_date";
const notStarted = labels.notStarted ?? "未开始";
const inProgress = labels.inProgress ?? "进行中";
const done = labels.done ?? "完成";
return {
schemaVersion: TABLE_SCHEMA_VERSION,
fields: [
{ id: nameId, name: labels.name ?? "名称", type: "text" },
{ id: statusId, name: labels.status ?? "状态", type: "select", options: [notStarted, inProgress, done] },
{ id: dateId, name: labels.date ?? "日期", type: "date" },
],
records: [{
id: "rec_sample",
cells: {
[nameId]: labels.sample ?? "示例记录",
[statusId]: notStarted,
[dateId]: "",
},
}],
view: { filters: [], sort: null },
};
};
const csvCell = (value: TableCellValue) => {
const text = Array.isArray(value)
? attachmentNames(value).join("; ")
: value === null || value === undefined ? "" : value === true ? "true" : value === false ? "false" : String(value);
return /[",\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
};
export const tableDocumentToCsv = (document: TableDocument, records: TableRecord[] = document.records) => {
const lines = [
document.fields.map((field) => csvCell(field.name)),
...records.map((record) => document.fields.map((field) => csvCell(record.cells[field.id] ?? null))),
];
return `\uFEFF${lines.map((line) => line.join(",")).join("\n")}`;
};
const cellSortText = (field: TableField, value: TableCellValue) => {
if (field.type === "attachment") {
const names = attachmentNames(value);
return names.length ? `${String(names.length).padStart(4, "0")} ${names.join(" ")}` : null;
}
if (value === null || value === undefined || value === "") return null;
if (field.type === "number") return typeof value === "number" ? value : null;
if (field.type === "checkbox") return value === true ? 1 : 0;
return String(value);
};
const matchesFilter = (field: TableField, value: TableCellValue, filter: TableFilter) => {
if (field.type === "attachment") {
const names = attachmentNames(value);
const empty = names.length === 0;
if (filter.operator === "empty") return empty;
if (filter.operator === "notEmpty") return !empty;
const expected = (filter.value ?? "").trim().toLocaleLowerCase();
if (filter.operator === "eq") return names.some((name) => name.toLocaleLowerCase() === expected);
return names.some((name) => name.toLocaleLowerCase().includes(expected));
}
const empty = field.type === "checkbox" ? value !== true : value === null || value === "";
if (filter.operator === "empty") return empty;
if (filter.operator === "notEmpty") return !empty;
if (filter.operator === "eq") {
if (field.type === "number") {
const expected = Number(filter.value);
return Number.isFinite(expected) && value === expected;
}
if (field.type === "checkbox") return (filter.value === "true") === (value === true);
return String(value ?? "") === (filter.value ?? "");
}
return String(value ?? "").toLocaleLowerCase().includes((filter.value ?? "").trim().toLocaleLowerCase());
};
export const applyTableView = (document: TableDocument, records = document.records) => {
const fields = new Map(document.fields.map((field) => [field.id, field]));
const filtered = records.filter((record) => document.view.filters.every((filter) => {
const field = fields.get(filter.fieldId);
return field ? matchesFilter(field, record.cells[field.id] ?? null, filter) : true;
}));
const sort = document.view.sort ? fields.get(document.view.sort.fieldId) : undefined;
if (!sort || !document.view.sort) return filtered;
const direction = document.view.sort.direction === "desc" ? -1 : 1;
return [...filtered].sort((left, right) => {
const a = cellSortText(sort, left.cells[sort.id] ?? null);
const b = cellSortText(sort, right.cells[sort.id] ?? null);
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
if (typeof a === "number" && typeof b === "number") return (a - b) * direction;
return String(a).localeCompare(String(b)) * direction;
});
};
export const addTableField = (document: TableDocument, field: TableField): TableDocument => {
if (document.fields.length >= TABLE_FIELD_LIMIT || document.fields.some((item) => item.id === field.id)) return document;
const nextField = field.type === "select" ? { ...field, options: field.options ?? [] } : { ...field, options: undefined };
return {
...document,
fields: [...document.fields, nextField],
records: document.records.map((record) => ({ ...record, cells: { ...record.cells, [nextField.id]: emptyCell(nextField) } })),
};
};
export const updateTableField = (document: TableDocument, fieldId: string, patch: Partial<Pick<TableField, "name" | "type" | "options">>): TableDocument => {
const current = document.fields.find((field) => field.id === fieldId);
if (!current) return document;
const next: TableField = {
...current,
name: patch.name === undefined ? current.name : cleanName(patch.name, current.name),
type: patch.type ?? current.type,
};
if (next.type === "select") next.options = cleanOptions(patch.options ?? current.options ?? []);
return {
...document,
fields: document.fields.map((field) => field.id === fieldId ? next : field),
records: document.records.map((record) => ({
...record,
cells: { ...record.cells, [fieldId]: coerceTableCell(next, record.cells[fieldId]) },
})),
};
};
export const removeTableField = (document: TableDocument, fieldId: string): TableDocument => {
if (document.fields.length <= 1 || !document.fields.some((field) => field.id === fieldId)) return document;
const fields = document.fields.filter((field) => field.id !== fieldId);
const fieldIds = new Set(fields.map((field) => field.id));
return {
...document,
fields,
records: document.records.map((record) => ({
...record,
cells: Object.fromEntries(Object.entries(record.cells).filter(([id]) => fieldIds.has(id))),
})),
view: {
filters: document.view.filters.filter((filter) => filter.fieldId !== fieldId),
sort: document.view.sort?.fieldId === fieldId ? null : document.view.sort,
},
};
};
export const addTableRecord = (document: TableDocument, id = createTableId("rec")): TableDocument => {
if (document.records.length >= TABLE_RECORD_LIMIT || document.records.some((record) => record.id === id)) return document;
const cells: Record<string, TableCellValue> = {};
for (const field of document.fields) cells[field.id] = emptyCell(field);
return { ...document, records: [...document.records, { id, cells }] };
};
export const updateTableCell = (document: TableDocument, recordId: string, fieldId: string, value: unknown): TableDocument => {
const field = document.fields.find((item) => item.id === fieldId);
if (!field) return document;
return {
...document,
records: document.records.map((record) => record.id === recordId
? { ...record, cells: { ...record.cells, [fieldId]: coerceTableCell(field, value) } }
: record),
};
};
export const removeTableRecord = (document: TableDocument, recordId: string): TableDocument => ({
...document,
records: document.records.filter((record) => record.id !== recordId),
});
export const replaceTableView = (document: TableDocument, view: TableView): TableDocument => ({
...document,
view: parseView(view, new Set(document.fields.map((field) => field.id))),
});
export const getTableSummary = (markdown: string | null | undefined): {
structuredTable: boolean;
tablePreview?: TableSummaryPreview;
} => {
const document = parseTableDocument(markdown);
if (!document) return { structuredTable: false };
return {
structuredTable: true,
tablePreview: {
fieldCount: document.fields.length,
recordCount: document.records.length,
fieldNames: document.fields.map((field) => field.name).filter(Boolean).slice(0, 4),
},
};
};