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.
This commit is contained in:
tianma-if committed 2026-09-23 13:03:12 +08:00
1 parent 615a25f501
commit c7f6e342be
23 files changed
+1787 -17

No files matched your search

@@ -1,6 +1,7 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { globSync, readFileSync } from "node:fs";
import { addTableField, createDefaultTableDocument, serializeTableDocument, updateTableCell } from "@edgeever/shared";
import {
acquireMaintenanceLease,
createMemoEditSession,
@@ -161,6 +162,55 @@ describe("database write optimizations", () => {
sqlite.close();
});
test("keeps a structured table intact and retires an attachment removed from it", async () => {
const { database, sqlite } = createDatabase();
const memo = getSeedMemo(sqlite);
let document = addTableField(createDefaultTableDocument(), { id: "fld_files", name: "附件", type: "attachment" });
document = updateTableCell(document, "rec_sample", "fld_files", [{
resourceId: "res_table_file",
filename: "brief.pdf",
mimeType: "application/pdf",
byteSize: 12,
}]);
sqlite.query(
`INSERT INTO resources (id, memo_id, object_key, kind, mime_type, filename, byte_size)
VALUES ('res_table_file', ?, 'workspaces/test/brief.pdf', 'attachment', 'application/pdf', 'brief.pdf', 12)`,
).run(memo.id);
const actor = { actorType: "user", actorId: "user_owner" };
const saved = await updateMemoRecord(
database,
memo.workspace_id,
memo.id,
{ contentMarkdown: serializeTableDocument(document) },
actor,
"owner",
);
expect(saved.memo.contentMarkdown).toContain("edgeever-table-v1");
const replaced = await updateMemoRecord(
database,
memo.workspace_id,
memo.id,
{ contentMarkdown: "普通笔记" },
actor,
"owner",
);
expect(replaced.error).toBe("table_update_required");
expect(sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = ?").get(memo.id).content_markdown).toContain("edgeever-table-v1");
const removed = await updateMemoRecord(
database,
memo.workspace_id,
memo.id,
{ contentMarkdown: serializeTableDocument(updateTableCell(document, "rec_sample", "fld_files", [])) },
actor,
"owner",
);
expect(removed.releasedResources?.map((resource) => resource.id)).toEqual(["res_table_file"]);
expect(sqlite.query("SELECT is_deleted FROM resources WHERE id = 'res_table_file'").get().is_deleted).toBe(1);
sqlite.close();
});
test("allows only one live maintenance lease owner", async () => {
const { database, sqlite } = createDatabase();
const first = await acquireMaintenanceLease(database, "demo-reset-test", 60_000);
+3
View File
@@ -123,6 +123,7 @@ import {
} from "./user-routes";
import { registerNotebookRoutes } from "./notebook-routes";
import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes";
import { registerPublicTableFormRoutes, registerTableFormRoutes } from "./table-form-routes";
import {
deleteStoredObjects,
getActiveObjectStorageConfig,
@@ -245,6 +246,7 @@ app.get("/api/health", async (c) => {
app.get("/api/openapi.json", (c) => c.json(apiProbe));
registerPublicShareRoutes(app);
registerPublicTableFormRoutes(app);
registerAuthRoutes(app, {
authenticateRequest: (...args) => authenticateRequest(...args),
@@ -331,6 +333,7 @@ registerPluginDistributionRoutes(app);
registerScheduledTaskRoutes(app);
registerWorkspaceExtensionRoutes(app, { isDemoMode: (...args) => isDemoMode(...args) });
registerMemoShareRoutes(app);
registerTableFormRoutes(app);
registerTemplateRoutes(app, {
createMemoRecord: (...args) => createMemoRecord(...args),
getMemoDetail: (...args) => getMemoDetail(...args),
+5 -1
View File
@@ -16,6 +16,7 @@ import { AppError } from "./app-error";
import { apiError, notFound } from "./http-errors";
import type { ListMemosInput, ListMemosResult } from "./memo-list-service";
import { getActorLabel, getAuditActor, getWorkspaceId, requireScopes } from "./request-auth";
import { deleteReleasedResourceObjects } from "./resource-service";
import type { DatabaseAdapter } from "./storage-contract";
type MemoRouteDependencies = {
@@ -101,7 +102,7 @@ type MemoRouteDependencies = {
actorLabel: string,
requireEditSession: boolean,
) => Promise<
| { memo: MemoDetail; error?: never; message?: never; status?: never; details?: never }
| { memo: MemoDetail; releasedResources?: { objectKey: string; storageConfigId: string }[]; error?: never; message?: never; status?: never; details?: never }
| { error: string; message: string; status?: number; details?: Record<string, unknown> }
>;
};
@@ -285,6 +286,9 @@ export const registerMemoRoutes = (
(result.status ?? (result.error === "not_found" ? 404 : 409)) as 400,
);
}
if (result.releasedResources?.length) {
await deleteReleasedResourceObjects(context.env, result.releasedResources);
}
return context.json({ memo: result.memo });
};
+38 -2
View File
@@ -10,7 +10,9 @@ import {
resolveMergedMemoTitle,
isSuspiciousMemoOverwrite,
isMemoEditBindingValid,
listTableAttachmentResourceIds,
normalizeTags,
parseTableDocument,
type MemoDetail,
type MemoEditSession,
type MemoSummary,
@@ -1175,6 +1177,12 @@ export const importMemosRecord = async (
};
};
export type ReleasedTableResource = {
id: string;
objectKey: string;
storageConfigId: string;
};
export const updateMemoRecord = async (
db: DatabaseAdapter,
workspaceId: string,
@@ -1185,7 +1193,7 @@ export const updateMemoRecord = async (
requireEditSession = false,
commit?: MemoMutationCommit,
): Promise<
| { memo: MemoDetail; error?: never; message?: never; status?: never; details?: never }
| { memo: MemoDetail; releasedResources?: ReleasedTableResource[]; error?: never; message?: never; status?: never; details?: never }
| { error: string; message: string; status?: number; details?: Record<string, unknown> }
> => {
const current = await getMemoDetailRow(db, workspaceId, id);
@@ -1334,6 +1342,33 @@ export const updateMemoRecord = async (
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
const excerpt = createExcerpt(contentText);
const notebookId = input.notebookId ?? current.notebook_id;
const currentTable = parseTableDocument(current.content_markdown);
const nextTable = parseTableDocument(contentMarkdown);
if (currentTable && !nextTable) {
return {
error: "table_update_required",
message: "Structured table content must stay a structured table.",
status: 409,
};
}
const removedAttachmentIds = currentTable && nextTable
? [...listTableAttachmentResourceIds(currentTable)].filter((resourceId) => !listTableAttachmentResourceIds(nextTable).has(resourceId))
: [];
const releasedResources = removedAttachmentIds.length
? await db.prepare(
`SELECT id, object_key, storage_config_id
FROM resources
WHERE memo_id = ? AND is_deleted = 0 AND id IN (${removedAttachmentIds.map(() => "?").join(", ")})`,
).bind(id, ...removedAttachmentIds).all<ReleasedTableResource & { object_key: string; storage_config_id: string }>()
: { results: [] };
const releasedResourceRows = (releasedResources.results ?? []).map((row) => ({
id: row.id,
objectKey: row.object_key,
storageConfigId: row.storage_config_id,
}));
const releaseResourceStatements = releasedResourceRows.map((resource) => db.prepare(
`UPDATE resources SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ? AND memo_id = ? AND is_deleted = 0`,
).bind(updatedAt, updatedAt, resource.id, id));
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const unchanged =
notebookId === current.notebook_id
@@ -1385,6 +1420,7 @@ export const updateMemoRecord = async (
await db.batch([
...(commit?.before ?? []),
...revisionStatements,
...releaseResourceStatements,
db
.prepare(
`UPDATE memos
@@ -1417,7 +1453,7 @@ export const updateMemoRecord = async (
return { error: "not_found", message: "Memo not found after update" };
}
return { memo };
return { memo, releasedResources: releasedResourceRows };
};
const parseDoc = (json: string): TiptapDoc => {
+13 -2
View File
@@ -4,7 +4,7 @@ import type {
ResourceStorageSummary,
} from "@edgeever/shared";
import { auditStatement } from "./audit";
import type { AppContext, AuditActor } from "./api-context";
import type { AppContext, AuditActor, Bindings } from "./api-context";
import { AppError } from "./app-error";
import { createId, isoNow } from "./entity-utils";
import { sha256Bytes } from "./hash-utils";
@@ -110,6 +110,16 @@ export const validateImageUpload = (mimeType: string, size: number) => {
}
};
export const deleteReleasedResourceObjects = async (
env: Bindings,
resources: { objectKey: string; storageConfigId: string }[],
) => {
for (const resource of resources) {
const source = await resolveObjectStorage(env, resource.storageConfigId);
await source.store.delete(resource.objectKey).catch(() => undefined);
}
};
export const validateAttachmentUpload = (size: number) => {
if (size <= 0 || size > MAX_ATTACHMENT_UPLOAD_BYTES) {
throw new AppError("upload_too_large", "Attachment must be between 1 byte and 1 GiB.", 413);
@@ -234,6 +244,7 @@ type ResourceCreateInput = {
mimeType: string;
bytes: Uint8Array;
actor: AuditActor;
metadata?: Record<string, unknown>;
};
const storeResource = async (
@@ -347,7 +358,7 @@ export const createAttachmentResource = async (
kind: "attachment",
width: null,
height: null,
metadata: {},
metadata: input.metadata ?? {},
auditMetadata: {},
});
};
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { globSync, readFileSync } from "node:fs";
import { Hono } from "hono";
import { createDefaultTableDocument, serializeTableDocument } from "@edgeever/shared";
import { hashPassword } from "./auth-crypto.ts";
import { updateMemoRecord } from "./memo-service.ts";
import { registerPublicTableFormRoutes } from "./table-form-routes.ts";
class SqliteD1PreparedStatement {
constructor(db, sql, bindings = []) {
this.db = db;
this.sql = sql;
this.bindings = bindings;
}
bind(...bindings) {
return new SqliteD1PreparedStatement(this.db, this.sql, bindings);
}
async all() {
return { results: this.db.query(this.sql).all(...this.bindings), success: true, meta: {} };
}
async first() {
return this.db.query(this.sql).get(...this.bindings) ?? null;
}
async run() {
this.db.query(this.sql).run(...this.bindings);
return { success: true, meta: {} };
}
}
class SqliteD1Database {
constructor(db) {
this.db = db;
}
prepare(sql) {
return new SqliteD1PreparedStatement(this.db, sql);
}
async batch(statements) {
return this.db.transaction(() => statements.map((statement) =>
this.db.query(statement.sql).run(...statement.bindings)))();
}
}
const token = "f".repeat(43);
const createApp = async () => {
const sqlite = new Database(":memory:");
sqlite.exec("PRAGMA foreign_keys = ON");
for (const migration of globSync("migrations/*.sql").sort()) sqlite.exec(readFileSync(migration, "utf8"));
sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES ('ws_form', 'Form', 1)").run();
sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES ('nb_form', 'ws_form', 'Inbox')").run();
sqlite.query("INSERT INTO memos (id, workspace_id, notebook_id, title) VALUES ('memo_form', 'ws_form', 'nb_form', '报名')").run();
sqlite.query("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash) VALUES ('memo_form', '{}', '', '', 'hash')").run();
const database = new SqliteD1Database(sqlite);
await updateMemoRecord(database, "ws_form", "memo_form", {
contentMarkdown: serializeTableDocument(createDefaultTableDocument()),
}, { actorType: "user", actorId: null }, "owner", false);
sqlite.query(
`INSERT INTO table_forms (id, memo_id, workspace_id, token, enabled, title, fields_json)
VALUES ('form_1', 'memo_form', 'ws_form', ?, 1, '报名表', ?)`,
).run(token, JSON.stringify([{ fieldId: "fld_name", required: true }]));
const app = new Hono();
registerPublicTableFormRoutes(app);
return { sqlite, app, environment: { storage: { db: database, resources: {} } } };
};
describe("public table forms", () => {
test("appends one row and does not publish existing records", async () => {
const { sqlite, app, environment } = await createApp();
const listed = await app.request(`/api/public/forms/${token}`, {}, environment);
expect(listed.status).toBe(200);
const body = JSON.stringify(await listed.json());
expect(body).toContain("名称");
expect(body).not.toContain("示例记录");
const submitted = await app.request(`/api/public/forms/${token}/submissions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cells: { fld_name: "外来记录" } }),
}, environment);
expect(submitted.status).toBe(201);
const stored = sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = 'memo_form'").get().content_markdown;
expect(stored).toContain("外来记录");
expect(sqlite.query("SELECT updated_by FROM memos WHERE id = 'memo_form'").get().updated_by).toBe("table-form");
const again = await app.request(`/api/public/forms/${token}`, {}, environment);
expect(JSON.stringify(await again.json())).not.toContain("外来记录");
const missing = await app.request(`/api/public/forms/${token}/submissions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ cells: { fld_name: "" } }),
}, environment);
expect(missing.status).toBe(400);
sqlite.close();
});
test("hides fields until the password is entered and stops when the form is closed", async () => {
const { sqlite, app, environment } = await createApp();
const passwordHash = await hashPassword("secret12");
sqlite.query("UPDATE table_forms SET password_hash = ? WHERE id = 'form_1'").run(passwordHash);
const locked = await app.request(`/api/public/forms/${token}`, {}, environment);
const lockedBody = await locked.json();
expect(lockedBody.form.passwordRequired).toBe(true);
expect(lockedBody.form.fields).toEqual([]);
expect(JSON.stringify(lockedBody)).not.toContain("示例记录");
sqlite.query("UPDATE table_forms SET enabled = 0 WHERE id = 'form_1'").run();
const closed = await app.request(`/api/public/forms/${token}`, {}, environment);
expect(closed.status).toBe(404);
sqlite.close();
});
});
+408
View File
@@ -0,0 +1,408 @@
import {
parseTableDocument,
PublicShareUnlockSchema,
PublicTableFormSubmissionSchema,
TableFormUpdateSchema,
type PublicTableForm,
type PublicTableFormField,
type TableAttachment,
type TableFormFieldSetting,
type TableFormSettings,
} from "@edgeever/shared";
import { zValidator } from "@hono/zod-validator";
import type { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import type { AppContext, AppEnv } from "./api-context";
import { AppError } from "./app-error";
import { audit } from "./audit";
import { authenticateRequest } from "./auth-service";
import { hashPassword, randomToken, verifyPassword } from "./auth-crypto";
import { createId, isoNow } from "./entity-utils";
import { apiError, notFound } from "./http-errors";
import { getMemoDetail } from "./memo-service";
import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth";
import {
createAttachmentResource,
createImageResource,
deleteReleasedResourceObjects,
SUPPORTED_IMAGE_MIME_TYPES,
} from "./resource-service";
import {
createShareAccessCookieValue,
isShareUnlockBlocked,
isValidShareAccessCookieValue,
nextShareUnlockFailure,
SHARE_ACCESS_MAX_AGE_SECONDS,
} from "./share-access";
import { generateSharePassword } from "./share-password";
import {
appendTableFormSubmission,
parseTableFormFields,
TABLE_FORM_ATTACHMENT_BYTES,
type TableFormRow,
} from "./table-form-service";
const FORM_TOKEN_BYTES = 32;
const FORM_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
const FORM_COOKIE = "ee_form";
const FORM_SUBMIT_LIMIT = 30;
const FORM_SUBMIT_WINDOW_MS = 10 * 60 * 1000;
const PENDING_ATTACHMENT_MS = 60 * 60 * 1000;
const selectFormSql = `SELECT id, memo_id, workspace_id, token, enabled, password_hash, title, description,
submit_label, fields_json, created_by, submit_count, submit_window_started_at
FROM table_forms`;
const normalizeToken = (value: string) => {
const token = value.trim();
return FORM_TOKEN_PATTERN.test(token) ? token : null;
};
const cookiePath = (token: string) => `/api/public/forms/${encodeURIComponent(token)}`;
const loadForm = (c: AppContext, token: string) => c.env.storage.db.prepare(
`${selectFormSql}
WHERE token = ? AND enabled = 1
AND EXISTS (
SELECT 1 FROM memos m
WHERE m.id = table_forms.memo_id AND m.workspace_id = table_forms.workspace_id AND m.is_deleted = 0
)`,
).bind(token).first<TableFormRow>();
const allowForm = async (c: AppContext, form: TableFormRow) => {
if (!form.password_hash) return true;
if (await isValidShareAccessCookieValue(getCookie(c, FORM_COOKIE), form.password_hash, form.token)) return true;
const auth = await authenticateRequest(c, false);
return auth?.workspaceId === form.workspace_id;
};
const publicFields = (form: TableFormRow, markdown: string): PublicTableFormField[] => {
const document = parseTableDocument(markdown);
if (!document) return [];
const fields = new Map(document.fields.map((field) => [field.id, field]));
return parseTableFormFields(form.fields_json).flatMap((setting) => {
const field = fields.get(setting.fieldId);
if (!field) return [];
const view: PublicTableFormField = {
id: field.id,
name: field.name,
type: field.type,
required: setting.required,
};
if (field.type === "select") view.options = field.options ?? [];
return [view];
});
};
const mapOwnerForm = (row: TableFormRow, password?: string): TableFormSettings => ({
enabled: row.enabled === 1,
token: row.token,
passwordProtected: Boolean(row.password_hash),
...(password ? { password } : {}),
title: row.title,
description: row.description,
submitLabel: row.submit_label,
fields: parseTableFormFields(row.fields_json),
});
const activeFieldSettings = (markdown: string, fields: TableFormFieldSetting[]) => {
const document = parseTableDocument(markdown);
const ids = new Set(document?.fields.map((field) => field.id) ?? []);
const seen = new Set<string>();
return fields.filter((field) => {
if (!ids.has(field.fieldId) || seen.has(field.fieldId)) return false;
seen.add(field.fieldId);
return true;
});
};
const reserveSubmission = async (c: AppContext, form: TableFormRow) => {
const nowMs = Date.now();
const windowStart = Date.parse(form.submit_window_started_at ?? "");
const inWindow = Number.isFinite(windowStart) && nowMs - windowStart < FORM_SUBMIT_WINDOW_MS;
const count = inWindow ? form.submit_count + 1 : 1;
if (count > FORM_SUBMIT_LIMIT) throw new AppError("table_form_rate_limited", "Too many submissions. Try again later.", 429);
const startedAt = inWindow ? form.submit_window_started_at : new Date(nowMs).toISOString();
await c.env.storage.db.prepare(
`UPDATE table_forms SET submit_count = ?, submit_window_started_at = ?, updated_at = ? WHERE id = ?`,
).bind(count, startedAt, isoNow(), form.id).run();
};
const pendingAttachments = async (c: AppContext, form: TableFormRow, resourceIds: string[]) => {
const attachments = new Map<string, TableAttachment>();
if (!resourceIds.length) return attachments;
const rows = await c.env.storage.db.prepare(
`SELECT id, filename, mime_type, byte_size
FROM resources
WHERE memo_id = ? AND is_deleted = 0
AND json_extract(metadata_json, '$.tableFormId') = ?
AND json_extract(metadata_json, '$.pending') = 1
AND id IN (${resourceIds.map(() => "?").join(", ")})`,
).bind(form.memo_id, form.id, ...resourceIds).all<{ id: string; filename: string | null; mime_type: string | null; byte_size: number }>();
for (const row of rows.results) {
attachments.set(row.id, {
resourceId: row.id,
filename: row.filename || "attachment",
mimeType: row.mime_type || "",
byteSize: row.byte_size,
});
}
return attachments;
};
const collectResourceIds = (cells: Record<string, unknown>) => {
const ids: string[] = [];
for (const value of Object.values(cells)) {
if (!Array.isArray(value)) continue;
for (const item of value) {
const resourceId = typeof item === "string"
? item
: item && typeof item === "object" && typeof (item as { resourceId?: unknown }).resourceId === "string"
? (item as { resourceId: string }).resourceId
: "";
if (resourceId) ids.push(resourceId);
}
}
return ids;
};
const retirePendingAttachments = async (c: AppContext, form: TableFormRow) => {
const cutoff = new Date(Date.now() - PENDING_ATTACHMENT_MS).toISOString();
const stale = await c.env.storage.db.prepare(
`SELECT id, object_key, storage_config_id
FROM resources
WHERE memo_id = ? AND is_deleted = 0
AND json_extract(metadata_json, '$.tableFormId') = ?
AND json_extract(metadata_json, '$.pending') = 1
AND created_at < ?`,
).bind(form.memo_id, form.id, cutoff).all<{ id: string; object_key: string; storage_config_id: string }>();
if (!stale.results.length) return;
const now = isoNow();
await c.env.storage.db.batch(stale.results.map((row) => c.env.storage.db.prepare(
`UPDATE resources SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ? AND is_deleted = 0`,
).bind(now, now, row.id)));
await deleteReleasedResourceObjects(c.env, stale.results.map((row) => ({
objectKey: row.object_key,
storageConfigId: row.storage_config_id,
})));
};
const markAttachmentsUsed = async (c: AppContext, form: TableFormRow, resourceIds: string[]) => {
if (!resourceIds.length) return;
const now = isoNow();
await c.env.storage.db.batch(resourceIds.map((resourceId) => c.env.storage.db.prepare(
`UPDATE resources
SET metadata_json = json_set(metadata_json, '$.pending', 0), updated_at = ?
WHERE id = ? AND memo_id = ? AND json_extract(metadata_json, '$.tableFormId') = ?`,
).bind(now, resourceId, form.memo_id, form.id)));
};
export const registerPublicTableFormRoutes = (app: Hono<AppEnv>) => {
app.get("/api/public/forms/:token", async (c) => {
const token = normalizeToken(c.req.param("token"));
if (!token) return notFound(c, "Form not found");
const form = await loadForm(c, token);
if (!form) return notFound(c, "Form not found");
c.header("Cache-Control", "private, no-store");
c.header("X-Robots-Tag", "noindex, nofollow, noarchive");
if (!(await allowForm(c, form))) {
const locked: PublicTableForm = {
passwordRequired: true,
title: "",
description: "",
submitLabel: "",
full: false,
fields: [],
};
return c.json({ form: locked });
}
const memo = await getMemoDetail(c.env.storage.db, form.workspace_id, form.memo_id);
const document = parseTableDocument(memo?.contentMarkdown);
const view: PublicTableForm = {
passwordRequired: false,
title: form.title || memo?.title || "",
description: form.description,
submitLabel: form.submit_label,
full: (document?.records.length ?? 0) >= 2000,
fields: memo ? publicFields(form, memo.contentMarkdown) : [],
};
return c.json({ form: view });
});
app.post("/api/public/forms/:token/unlock", zValidator("json", PublicShareUnlockSchema), async (c) => {
const token = normalizeToken(c.req.param("token"));
if (!token) return notFound(c, "Form not found");
const form = await loadForm(c, token);
if (!form?.password_hash) return notFound(c, "Form not found");
const gate = await c.env.storage.db.prepare(
`SELECT unlock_failed_count, unlock_window_started_at, unlock_blocked_until FROM table_forms WHERE id = ?`,
).bind(form.id).first<{ unlock_failed_count: number; unlock_window_started_at: string | null; unlock_blocked_until: string | null }>();
if (gate && isShareUnlockBlocked(gate.unlock_blocked_until)) {
return apiError(c, "table_form_rate_limited", "Too many password attempts. Try again later.", 429);
}
if (!(await verifyPassword(c.req.valid("json").password, form.password_hash))) {
const failure = nextShareUnlockFailure({
unlock_failed_count: gate?.unlock_failed_count ?? 0,
unlock_window_started_at: gate?.unlock_window_started_at ?? null,
unlock_blocked_until: gate?.unlock_blocked_until ?? null,
});
await c.env.storage.db.prepare(
`UPDATE table_forms SET unlock_failed_count = ?, unlock_window_started_at = ?, unlock_blocked_until = ? WHERE id = ?`,
).bind(failure.failureCount, failure.windowStartedAt, failure.blockedUntil, form.id).run();
return apiError(c, "table_form_password_invalid", "Incorrect form password", 403);
}
await c.env.storage.db.prepare(
`UPDATE table_forms SET unlock_failed_count = 0, unlock_window_started_at = NULL, unlock_blocked_until = NULL WHERE id = ?`,
).bind(form.id).run();
const value = await createShareAccessCookieValue(form.password_hash, form.token);
setCookie(c, FORM_COOKIE, value, {
httpOnly: true,
secure: new URL(c.req.url).protocol === "https:",
sameSite: "Lax",
path: cookiePath(form.token),
maxAge: SHARE_ACCESS_MAX_AGE_SECONDS,
});
return c.json({ ok: true });
});
app.post("/api/public/forms/:token/resources", async (c) => {
const token = normalizeToken(c.req.param("token"));
if (!token) return notFound(c, "Form not found");
const form = await loadForm(c, token);
if (!form || !(await allowForm(c, form))) return notFound(c, "Form not found");
await retirePendingAttachments(c, form);
const body = await c.req.raw.formData();
const file = body.get("file");
if (!(file instanceof File)) return apiError(c, "table_form_invalid", "Choose a file to upload.", 400);
if (file.size <= 0 || file.size > TABLE_FORM_ATTACHMENT_BYTES) {
return apiError(c, "upload_too_large", "Each form attachment must be between 1 byte and 20 MiB.", 413);
}
const actor = { actorType: "user" as const, actorId: form.created_by };
const input = {
memoId: form.memo_id,
filename: file.name,
mimeType: file.type || "application/octet-stream",
bytes: new Uint8Array(await file.arrayBuffer()),
actor,
metadata: { tableFormId: form.id, pending: 1 },
};
const resource = SUPPORTED_IMAGE_MIME_TYPES.has(input.mimeType)
? await createImageResource(c, { ...input, source: "upload" })
: await createAttachmentResource(c, input);
if (SUPPORTED_IMAGE_MIME_TYPES.has(input.mimeType)) {
await c.env.storage.db.prepare(
`UPDATE resources SET metadata_json = ? WHERE id = ?`,
).bind(JSON.stringify({ tableFormId: form.id, pending: 1 }), resource.id).run();
}
c.header("Cache-Control", "private, no-store");
return c.json({
resource: {
id: resource.id,
filename: resource.filename,
mimeType: resource.mimeType,
byteSize: resource.byteSize,
},
}, 201);
});
app.post("/api/public/forms/:token/submissions", zValidator("json", PublicTableFormSubmissionSchema), async (c) => {
const token = normalizeToken(c.req.param("token"));
if (!token) return notFound(c, "Form not found");
const form = await loadForm(c, token);
if (!form || !(await allowForm(c, form))) return notFound(c, "Form not found");
try {
await reserveSubmission(c, form);
await retirePendingAttachments(c, form);
const cells = c.req.valid("json").cells;
const attachments = await pendingAttachments(c, form, collectResourceIds(cells));
await appendTableFormSubmission(c.env.storage.db, form, cells, attachments);
await markAttachmentsUsed(c, form, [...attachments.keys()].filter((id) => collectResourceIds(cells).includes(id)));
c.header("Cache-Control", "private, no-store");
return c.json({ ok: true }, 201);
} catch (error) {
if (error instanceof AppError) return apiError(c, error.code, error.message, error.status);
throw error;
}
});
};
export const registerTableFormRoutes = (app: Hono<AppEnv>) => {
app.get("/api/v1/memos/:id/form", async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const row = await c.env.storage.db.prepare(
`${selectFormSql} WHERE memo_id = ? AND workspace_id = ?`,
).bind(c.req.param("id"), getWorkspaceId(c)).first<TableFormRow>();
return c.json({ form: row ? mapOwnerForm(row) : null });
});
app.put("/api/v1/memos/:id/form", zValidator("json", TableFormUpdateSchema), async (c) => {
const denied = requireUser(c);
if (denied) return denied;
const memoId = c.req.param("id");
const workspaceId = getWorkspaceId(c);
const memo = await getMemoDetail(c.env.storage.db, workspaceId, memoId);
if (!memo || !parseTableDocument(memo.contentMarkdown)) return notFound(c, "Table not found");
const input = c.req.valid("json");
const fields = activeFieldSettings(memo.contentMarkdown, input.fields);
if (input.enabled && fields.length === 0) {
return apiError(c, "table_form_invalid", "Choose at least one field for the form.", 400);
}
const existing = await c.env.storage.db.prepare(
`${selectFormSql} WHERE memo_id = ? AND workspace_id = ?`,
).bind(memoId, workspaceId).first<TableFormRow>();
const now = isoNow();
const actor = getAuditActor(c);
const enablePassword = input.passwordProtected;
const rotate = enablePassword && (input.rotatePassword === true || !existing?.password_hash);
const password = rotate ? generateSharePassword() : undefined;
const passwordHash = password ? await hashPassword(password) : existing?.password_hash ?? null;
const nextHash = enablePassword ? passwordHash : null;
if (!existing) {
const token = randomToken(FORM_TOKEN_BYTES);
await c.env.storage.db.prepare(
`INSERT INTO table_forms (
id, memo_id, workspace_id, token, enabled, password_hash, title, description, submit_label,
fields_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).bind(
createId("form"),
memoId,
workspaceId,
token,
input.enabled ? 1 : 0,
nextHash,
input.title,
input.description,
input.submitLabel,
JSON.stringify(fields),
actor.actorId,
now,
now,
).run();
} else {
await c.env.storage.db.prepare(
`UPDATE table_forms
SET enabled = ?, password_hash = ?, title = ?, description = ?, submit_label = ?, fields_json = ?, updated_at = ?,
unlock_failed_count = CASE WHEN ? IS NULL THEN 0 ELSE unlock_failed_count END
WHERE id = ?`,
).bind(
input.enabled ? 1 : 0,
nextHash,
input.title,
input.description,
input.submitLabel,
JSON.stringify(fields),
now,
nextHash,
existing.id,
).run();
}
await audit(c.env.storage.db, actor.actorType, actor.actorId, "table_form.update", "memo", memoId, { enabled: input.enabled });
const saved = await c.env.storage.db.prepare(
`${selectFormSql} WHERE memo_id = ? AND workspace_id = ?`,
).bind(memoId, workspaceId).first<TableFormRow>();
if (!saved) return notFound(c, "Form not found");
return c.json({ form: mapOwnerForm(saved, password) });
});
};
+175
View File
@@ -0,0 +1,175 @@
import {
addTableRecord,
createTableId,
markdownToDoc,
parseTableDocument,
serializeTableDocument,
tableFallbackMarkdown,
TABLE_ATTACHMENT_LIMIT,
TABLE_RECORD_LIMIT,
updateTableCell,
type TableAttachment,
type TableCellValue,
type TableDocument,
type TableField,
type TableFormFieldSetting,
} from "@edgeever/shared";
import { AppError } from "./app-error";
import { getMemoDetail, updateMemoRecord } from "./memo-service";
import type { DatabaseAdapter } from "./storage-contract";
export const TABLE_FORM_ATTACHMENT_BYTES = 20 * 1024 * 1024;
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const URL_PATTERN = /^https?:\/\/\S+$/i;
export type TableFormRow = {
id: string;
memo_id: string;
workspace_id: string;
token: string;
enabled: number;
password_hash: string | null;
title: string;
description: string;
submit_label: string;
fields_json: string;
created_by: string | null;
submit_count: number;
submit_window_started_at: string | null;
};
export const parseTableFormFields = (value: string): TableFormFieldSetting[] => {
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const field = item as { fieldId?: unknown; required?: unknown };
if (typeof field.fieldId !== "string" || !field.fieldId) return [];
return [{ fieldId: field.fieldId, required: field.required === true }];
});
} catch {
return [];
}
};
const submissionValue = (field: TableField, value: unknown, attachments: Map<string, TableAttachment>) => {
if (field.type === "attachment") {
const ids = Array.isArray(value) ? value : [];
const files: TableAttachment[] = [];
for (const item of ids) {
const resourceId = typeof item === "string"
? item
: item && typeof item === "object" && typeof (item as { resourceId?: unknown }).resourceId === "string"
? (item as { resourceId: string }).resourceId
: "";
const file = attachments.get(resourceId);
if (!file || files.some((current) => current.resourceId === file.resourceId)) continue;
files.push(file);
if (files.length >= TABLE_ATTACHMENT_LIMIT) break;
}
return files;
}
if (field.type === "select") {
const option = typeof value === "string" ? value.trim() : "";
if (option && !(field.options ?? []).includes(option)) {
throw new AppError("table_form_invalid", "Choose one of the listed options.", 400);
}
return option;
}
if (field.type === "url") {
const url = typeof value === "string" ? value.trim() : "";
if (url && !URL_PATTERN.test(url)) throw new AppError("table_form_invalid", "Enter a link that starts with http:// or https://.", 400);
return url;
}
if (field.type === "date") {
const date = typeof value === "string" ? value.trim() : "";
if (date && !DATE_PATTERN.test(date)) throw new AppError("table_form_invalid", "Enter a date as YYYY-MM-DD.", 400);
return date;
}
if (field.type === "number") {
if (value === null || value === undefined || value === "") return null;
const number = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(number)) throw new AppError("table_form_invalid", "Enter a number.", 400);
return number;
}
if (field.type === "checkbox") return value === true || value === "true" || value === 1 || value === "1";
return typeof value === "string" ? value.slice(0, 2000) : value == null ? "" : String(value).slice(0, 2000);
};
const valueMissing = (field: TableField, value: TableCellValue) => {
if (field.type === "checkbox") return value !== true;
if (field.type === "number") return value === null;
if (field.type === "attachment") return !Array.isArray(value) || value.length === 0;
return value === null || value === "";
};
export const buildTableFormSubmission = (
document: TableDocument,
configured: TableFormFieldSetting[],
cells: Record<string, unknown>,
attachments: Map<string, TableAttachment>,
) => {
const fields = new Map(document.fields.map((field) => [field.id, field]));
const nextCells: Record<string, TableCellValue> = {};
for (const setting of configured) {
const field = fields.get(setting.fieldId);
if (!field) continue;
const value = submissionValue(field, cells[field.id], attachments);
if (setting.required && valueMissing(field, value)) {
throw new AppError("table_form_required", "Fill in every required field.", 400);
}
nextCells[field.id] = value;
}
const requested = new Set<string>();
for (const value of Object.values(nextCells)) {
if (!Array.isArray(value)) continue;
for (const file of value) requested.add(file.resourceId);
}
for (const resourceId of requested) {
if (!attachments.has(resourceId)) throw new AppError("table_form_invalid", "Upload the attachment again.", 400);
}
return nextCells;
};
export const appendTableFormSubmission = async (
db: DatabaseAdapter,
form: TableFormRow,
cells: Record<string, unknown>,
attachments: Map<string, TableAttachment>,
) => {
for (let attempt = 0; attempt < 4; attempt += 1) {
const current = await getMemoDetail(db, form.workspace_id, form.memo_id);
if (!current) throw new AppError("not_found", "Form not found", 404);
const document = parseTableDocument(current.contentMarkdown);
if (!document) throw new AppError("not_found", "Form not found", 404);
if (document.records.length >= TABLE_RECORD_LIMIT) {
throw new AppError("table_record_limit", "This form is no longer accepting responses.", 409);
}
const nextCells = buildTableFormSubmission(document, parseTableFormFields(form.fields_json), cells, attachments);
const recordId = createTableId("rec");
let next = addTableRecord(document, recordId);
for (const [fieldId, value] of Object.entries(nextCells)) {
next = updateTableCell(next, recordId, fieldId, value);
}
const saved = await updateMemoRecord(
db,
form.workspace_id,
form.memo_id,
{
expectedRevision: current.revision,
contentMarkdown: serializeTableDocument(next),
contentJson: markdownToDoc(tableFallbackMarkdown(next)),
tags: current.tags,
},
{ actorType: "user", actorId: form.created_by },
"table-form",
false,
);
if ("memo" in saved) return saved.memo;
if (saved.error !== "revision_conflict" && saved.error !== "content_conflict") {
throw new AppError(saved.error, saved.message, saved.status ?? 409);
}
}
throw new AppError("revision_conflict", "The form was updated while submitting. Try again.", 409);
};
@@ -178,7 +178,7 @@ export const RevisionHistoryModal = ({
>
<Text style={[styles.revisionPillTitle, selectedRevision?.id === revision.id && styles.revisionPillTitleActive]}>{`版本 ${revision.revision}`}</Text>
<Text style={[styles.revisionPillMeta, selectedRevision?.id === revision.id && styles.revisionPillTitleActive]}>
{formatDate(revision.createdAt, localePreference)} · {formatRevisionActor(revision.createdBy)}
{formatDate(revision.createdAt, localePreference)} · {revision.createdBy === "table-form" ? "表单录入" : formatRevisionActor(revision.createdBy)}
</Text>
</Pressable>
))}
+2
View File
@@ -27,6 +27,7 @@ const EvernoteImportGuidePane = lazy(() =>
const LoginScreen = lazy(() => import("@/components/LoginScreen").then((module) => ({ default: module.LoginScreen })));
const WorkspaceApp = lazy(() => import("@/components/WorkspaceApp").then((module) => ({ default: module.WorkspaceApp })));
const PublicSharePage = lazy(() => import("@/components/PublicSharePage").then((module) => ({ default: module.PublicSharePage })));
const PublicTableFormPage = lazy(() => import("@/components/PublicTableFormPage").then((module) => ({ default: module.PublicTableFormPage })));
const AuthLoadingScreen = ({ title = "EdgeEver", detail }: { title?: string; detail?: string }) => (
<div className="flex h-[100dvh] items-center justify-center bg-slate-50 px-6 text-center text-slate-700">
@@ -228,6 +229,7 @@ export const App = () => {
<PwaInstallProvider>
<Routes>
<Route path="/share/:token" element={<Suspense fallback={<AuthLoadingScreen />}><PublicSharePage /></Suspense>} />
<Route path="/form/:token" element={<Suspense fallback={<AuthLoadingScreen />}><PublicTableFormPage /></Suspense>} />
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
@@ -0,0 +1,203 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router";
import type { PublicTableForm, PublicTableFormField } from "@edgeever/shared";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { withEnvironmentTitlePrefix } from "@/lib/environment-title";
const FORM_FILE_LIMIT = 20 * 1024 * 1024;
const submissionCells = (form: PublicTableForm, values: Record<string, unknown>) =>
Object.fromEntries(form.fields.map((field) => {
const value = values[field.id] ?? emptyValue(field);
if (field.type === "attachment" && Array.isArray(value)) {
return [field.id, value.map((file: { id: string }) => ({ resourceId: file.id }))];
}
if (field.type === "number") return [field.id, value === "" ? null : Number(value)];
return [field.id, value];
}));
const emptyValue = (field: PublicTableFormField) => {
if (field.type === "checkbox") return false;
if (field.type === "attachment") return [] as { id: string; filename: string }[];
return "";
};
export const PublicTableFormPage = () => {
const { token = "" } = useParams();
const { t } = useTranslation();
const queryClient = useQueryClient();
const queryKey = ["public-table-form", token] as const;
const formQuery = useQuery({
queryKey,
queryFn: () => api.getPublicTableForm(token),
enabled: Boolean(token),
retry: false,
});
const form = formQuery.data?.form;
const [password, setPassword] = useState("");
const [values, setValues] = useState<Record<string, unknown>>({});
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState<string | null>(null);
useEffect(() => {
if (!form || form.passwordRequired) return;
document.title = withEnvironmentTitlePrefix(form.title || t("structuredTable.form.title"), {
development: import.meta.env.DEV,
profile: __EDGEEVER_DEVELOPMENT_PROFILE__,
});
setValues(Object.fromEntries(form.fields.map((field) => [field.id, emptyValue(field)])));
}, [form, t]);
const unlockMutation = useMutation({
mutationFn: () => api.unlockPublicTableForm(token, password),
onSuccess: async () => {
setError(null);
await queryClient.invalidateQueries({ queryKey });
},
onError: (reason: Error) => setError(reason.message),
});
const submitMutation = useMutation({
mutationFn: (cells: Record<string, unknown>) => api.submitPublicTableForm(token, cells),
onSuccess: () => {
setDone(true);
setError(null);
},
onError: (reason: Error) => setError(reason.message),
});
const upload = async (fieldId: string, files: FileList | null) => {
if (!files?.length) return;
setUploading(fieldId);
setError(null);
try {
const current = Array.isArray(values[fieldId]) ? values[fieldId] as { id: string; filename: string }[] : [];
const next = [...current];
for (const file of files) {
if (file.size > FORM_FILE_LIMIT) throw new Error(t("structuredTable.form.uploadLimit"));
if (next.length >= 10) break;
const uploaded = await api.uploadPublicTableFormFile(token, file);
next.push({ id: uploaded.resource.id, filename: uploaded.resource.filename });
}
setValues((previous) => ({ ...previous, [fieldId]: next }));
} catch (reason) {
setError(reason instanceof Error ? reason.message : t("structuredTable.saveError"));
} finally {
setUploading(null);
}
};
if (formQuery.isLoading) {
return <div className="grid min-h-screen place-items-center"><LoaderCircle className="h-6 w-6 animate-spin text-emerald-700" /></div>;
}
if (formQuery.isError || !form) {
return <div className="grid min-h-screen place-items-center px-6 text-sm text-slate-600">{t("structuredTable.form.closed")}</div>;
}
if (form.passwordRequired) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center gap-4 px-6">
<h1 className="text-xl font-semibold">{t("structuredTable.form.passwordRequired")}</h1>
<Input
type="password"
value={password}
autoComplete="off"
aria-label={t("structuredTable.form.password")}
onChange={(event) => setPassword(event.target.value)}
/>
{error ? <p className="text-sm text-rose-600">{error}</p> : null}
<Button type="button" disabled={!password || unlockMutation.isPending} onClick={() => unlockMutation.mutate()}>
{t("structuredTable.form.unlock")}
</Button>
</main>
);
}
if (done) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center gap-4 px-6">
<h1 className="text-xl font-semibold">{t("structuredTable.form.submitted")}</h1>
<Button type="button" variant="outline" onClick={() => { setDone(false); setValues(Object.fromEntries(form.fields.map((field) => [field.id, emptyValue(field)]))); }}>
{t("structuredTable.form.submitAnother")}
</Button>
</main>
);
}
return (
<main className="mx-auto flex min-h-screen w-full max-w-lg flex-col gap-5 px-6 py-10">
<header className="space-y-2">
<h1 className="text-2xl font-semibold text-slate-950">{form.title || t("structuredTable.form.title")}</h1>
{form.description ? <p className="text-sm text-slate-600">{form.description}</p> : null}
</header>
{form.full ? <p className="text-sm text-slate-600">{t("structuredTable.form.full")}</p> : null}
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!form.full) submitMutation.mutate(submissionCells(form, values));
}}
>
{form.fields.map((field) => {
const value = values[field.id] ?? emptyValue(field);
const label = field.required ? `${field.name} *` : field.name;
return (
<label key={field.id} className="block space-y-1 text-sm">
<span className="font-medium text-slate-800">{label}</span>
{field.type === "checkbox" ? (
<input
type="checkbox"
className="h-4 w-4"
checked={value === true}
aria-label={field.name}
onChange={(event) => setValues((current) => ({ ...current, [field.id]: event.target.checked }))}
/>
) : field.type === "select" ? (
<select
className="h-10 w-full rounded-md border border-slate-200 px-3"
value={typeof value === "string" ? value : ""}
required={field.required}
aria-label={field.name}
onChange={(event) => setValues((current) => ({ ...current, [field.id]: event.target.value }))}
>
<option value="" />
{(field.options ?? []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : field.type === "attachment" ? (
<div className="space-y-1">
{Array.isArray(value) ? value.map((file) => (
<p key={file.id} className="truncate text-slate-600">{file.filename}</p>
)) : null}
<input
type="file"
multiple
aria-label={field.name}
disabled={uploading === field.id}
onChange={(event) => { void upload(field.id, event.target.files); event.target.value = ""; }}
/>
<p className="text-xs text-slate-500">{uploading === field.id ? t("structuredTable.uploading") : t("structuredTable.form.uploadLimit")}</p>
</div>
) : (
<Input
type={field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "url" ? "url" : "text"}
value={typeof value === "string" || typeof value === "number" ? String(value) : ""}
required={field.required}
aria-label={field.name}
onChange={(event) => setValues((current) => ({ ...current, [field.id]: field.type === "number" ? event.target.value : event.target.value }))}
/>
)}
</label>
);
})}
{error ? <p className="text-sm text-rose-600">{error}</p> : null}
<Button type="submit" disabled={form.full || submitMutation.isPending || Boolean(uploading)}>
{submitMutation.isPending ? t("structuredTable.form.saving") : form.submitLabel || t("structuredTable.form.submitDefault")}
</Button>
</form>
</main>
);
};
+186 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { ChevronLeft, Download, Plus, TableProperties, Trash2 } from "lucide-react";
import { ChevronLeft, ClipboardList, Download, Paperclip, Plus, TableProperties, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
addTableField,
@@ -12,16 +12,21 @@ import {
removeTableField,
removeTableRecord,
replaceTableView,
listTableAttachmentResourceIds,
serializeTableDocument,
TABLE_ATTACHMENT_FILTER_OPERATORS,
TABLE_ATTACHMENT_LIMIT,
TABLE_FIELD_LIMIT,
TABLE_FIELD_TYPES,
TABLE_FILTER_OPERATORS,
TABLE_RECORD_LIMIT,
tableAttachmentUrl,
tableDocumentToCsv,
tableFallbackMarkdown,
updateTableCell,
updateTableField,
type MemoDetail,
type TableAttachment,
type TableCellValue,
type TableDocument,
type TableField,
@@ -31,6 +36,7 @@ import {
type TableRecord,
} from "@edgeever/shared";
import { MemoTitleInput } from "@/components/MemoTitleInput";
import { TableFormDialog } from "@/components/dialogs/TableFormDialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -42,6 +48,7 @@ import {
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { toDesktopResourceUrl } from "@/lib/desktop-resources";
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
import { createLocalEditSession } from "@/components/editor/editor-pane-helpers";
import { isLocalMemoId } from "@/lib/local-mirror";
@@ -69,6 +76,7 @@ const parseOptionText = (value: string) => value.split(/[,,]/).map((item) => i
const displayCell = (field: TableField, value: TableCellValue) => {
if (field.type === "checkbox") return value === true ? "true" : "false";
if (Array.isArray(value)) return value.map((item) => item.filename).join(", ");
if (value === null || value === undefined || value === "") return "";
return String(value);
};
@@ -149,6 +157,110 @@ const FieldHeader = ({
);
};
const attachmentItems = (value: TableCellValue): TableAttachment[] => Array.isArray(value) ? value : [];
const AttachmentCell = ({
field,
record,
memoId,
readOnly,
repository,
onAdd,
onRemove,
onUploaded,
}: {
field: TableField;
record: TableRecord;
memoId: string;
readOnly: boolean;
repository: EdgeEverRepository;
onAdd: (attachment: TableAttachment) => boolean;
onRemove: (resourceId: string) => void;
onUploaded: (resourceId: string) => void;
}) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const items = attachmentItems(record.cells[field.id] ?? null);
const limitReached = items.length >= TABLE_ATTACHMENT_LIMIT;
const uploadFiles = async (files: FileList | null) => {
if (!files?.length || readOnly) return;
setUploading(true);
setUploadError(null);
try {
for (const file of files) {
const { resource } = await repository.uploadMemoResource(memoId, file);
const added = onAdd({
resourceId: resource.id,
filename: resource.filename || file.name,
mimeType: resource.mimeType || file.type,
byteSize: resource.byteSize ?? file.size,
});
if (!added) {
void repository.deleteResource(resource.id).catch(() => undefined);
break;
}
onUploaded(resource.id);
}
} catch (error) {
setUploadError(error instanceof Error ? error.message : t("structuredTable.saveError"));
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
};
return (
<div className="flex min-w-48 flex-col gap-1">
{items.map((item) => {
const href = toDesktopResourceUrl(tableAttachmentUrl(item.resourceId));
const image = item.mimeType.startsWith("image/");
return (
<div key={item.resourceId} className="flex items-center gap-1">
{image ? <img src={href} alt="" className="h-8 w-8 rounded object-cover" /> : <Paperclip className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden="true" />}
<a href={href} className="min-w-0 flex-1 truncate text-sm text-emerald-700 underline-offset-2 hover:underline" target="_blank" rel="noreferrer">{item.filename}</a>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={t("structuredTable.removeAttachment", { name: item.filename })}
disabled={readOnly}
onClick={() => onRemove(item.resourceId)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
<input
ref={inputRef}
type="file"
multiple
className="sr-only"
aria-label={t("structuredTable.addAttachment")}
disabled={readOnly || uploading || limitReached}
onChange={(event) => { void uploadFiles(event.target.files); }}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 justify-start px-1 text-xs"
disabled={readOnly || uploading || limitReached}
aria-label={limitReached ? t("structuredTable.attachmentLimit") : t("structuredTable.addAttachment")}
onClick={() => inputRef.current?.click()}
>
<Plus className="h-3.5 w-3.5" />
{uploading ? t("structuredTable.uploading") : limitReached ? t("structuredTable.attachmentLimit") : t("structuredTable.addAttachment")}
</Button>
{uploadError ? <p className="text-xs text-rose-600">{uploadError}</p> : null}
</div>
);
};
const RecordCell = ({
document,
field,
@@ -156,9 +268,14 @@ const RecordCell = ({
active,
readOnly,
onEdit,
memoId,
repository,
onCommit,
onDraft,
onChange,
onAdd,
onRemove,
onUploaded,
}: {
document: TableDocument;
field: TableField;
@@ -166,14 +283,33 @@ const RecordCell = ({
active: boolean;
readOnly: boolean;
onEdit: (cell: EditingCell | null) => void;
memoId: string;
repository: EdgeEverRepository;
onCommit: (value: string) => void;
onDraft: (value: string) => void;
onChange: (document: TableDocument) => void;
onAdd: (attachment: TableAttachment) => boolean;
onRemove: (resourceId: string) => void;
onUploaded: (resourceId: string) => void;
}) => {
const value = record.cells[field.id] ?? null;
const text = displayCell(field, value);
const [draft, setDraft] = useState(text);
useEffect(() => { if (active) setDraft(text); }, [active, text]);
if (field.type === "attachment") {
return (
<AttachmentCell
field={field}
record={record}
memoId={memoId}
readOnly={readOnly}
repository={repository}
onAdd={onAdd}
onRemove={onRemove}
onUploaded={onUploaded}
/>
);
}
if (field.type === "checkbox") {
return (
<Checkbox
@@ -256,12 +392,14 @@ export const TableEditorPane = ({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveFailed, setSaveFailed] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editSessionReady, setEditSessionReady] = useState(false);
const memoRef = useRef(memo);
const titleRef = useRef(title);
const documentRef = useRef(document);
const editingRef = useRef(editing);
const editSessionRef = useRef<MemoEditSession | null>(null);
const pendingUploadIdsRef = useRef(new Set<string>());
const savedSnapshotRef = useRef(parsed ? snapshotOf(memo.title ?? "", parsed) : "");
const saveRef = useRef<() => void>(() => undefined);
memoRef.current = memo;
@@ -336,6 +474,13 @@ export const TableEditorPane = ({
});
memoRef.current = result.memo;
savedSnapshotRef.current = nextSnapshot;
const keptAttachmentIds = listTableAttachmentResourceIds(nextDocument);
for (const resourceId of pendingUploadIdsRef.current) {
if (keptAttachmentIds.has(resourceId)) continue;
pendingUploadIdsRef.current.delete(resourceId);
void repository.deleteResource(resourceId).catch(() => undefined);
}
pendingUploadIdsRef.current.clear();
const hasNewChanges = snapshotOf(titleRef.current, documentRef.current ?? nextDocument) !== nextSnapshot;
setDirty(hasNewChanges);
if (!hasNewChanges) await onSaved(result.memo);
@@ -370,6 +515,27 @@ export const TableEditorPane = ({
record={row.original}
active={editing?.recordId === row.original.id && editing.fieldId === field.id}
readOnly={readOnly}
memoId={memo.id}
repository={repository}
onAdd={(attachment) => {
const current = documentRef.current;
const rowRecord = current?.records.find((item) => item.id === row.original.id);
const existing = attachmentItems(rowRecord?.cells[field.id] ?? null);
if (!current || existing.length >= TABLE_ATTACHMENT_LIMIT || existing.some((item) => item.resourceId === attachment.resourceId)) return false;
changeDocument(updateTableCell(current, row.original.id, field.id, [...existing, attachment]));
return true;
}}
onRemove={(resourceId) => {
const current = documentRef.current;
const rowRecord = current?.records.find((item) => item.id === row.original.id);
if (!current || !rowRecord) return;
if (pendingUploadIdsRef.current.has(resourceId)) {
pendingUploadIdsRef.current.delete(resourceId);
void repository.deleteResource(resourceId).catch(() => undefined);
}
changeDocument(updateTableCell(current, row.original.id, field.id, attachmentItems(rowRecord.cells[field.id] ?? null).filter((item) => item.resourceId !== resourceId)));
}}
onUploaded={(resourceId) => pendingUploadIdsRef.current.add(resourceId)}
onEdit={(cell) => {
editingRef.current = cell;
if (cell) draftRef.current = displayCell(field, row.original.cells[field.id] ?? null);
@@ -403,7 +569,7 @@ export const TableEditorPane = ({
),
},
];
}, [document, editing, readOnly, t]);
}, [document, editing, memo.id, readOnly, repository, t]);
const table = useReactTable({ data: visibleRecords, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id });
@@ -472,6 +638,10 @@ export const TableEditorPane = ({
</div>
<span className="text-xs text-slate-500">{countLabel}</span>
{saveLabel ? <span className={saveError ? "text-xs text-rose-600" : "text-xs text-slate-400"}>{saveLabel}</span> : null}
<Button type="button" variant="outline" size="sm" disabled={readOnly} onClick={() => setFormOpen(true)}>
<ClipboardList className="h-4 w-4" />
{t("structuredTable.openForm")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={exportCsv}>
<Download className="h-4 w-4" />
{t("structuredTable.exportCsv")}
@@ -524,7 +694,12 @@ export const TableEditorPane = ({
value={filter.fieldId}
aria-label={t("structuredTable.filter")}
disabled={readOnly}
onChange={(event) => setFilter(index, { ...filter, fieldId: event.target.value })}
onChange={(event) => {
const fieldId = event.target.value;
const attachmentField = document.fields.find((field) => field.id === fieldId)?.type === "attachment";
const operator = attachmentField && (filter.operator === "contains" || filter.operator === "eq") ? "notEmpty" : filter.operator;
setFilter(index, { ...filter, fieldId, operator });
}}
>
{document.fields.map((field) => <option key={field.id} value={field.id}>{field.name}</option>)}
</select>
@@ -535,7 +710,7 @@ export const TableEditorPane = ({
disabled={readOnly}
onChange={(event) => setFilter(index, { ...filter, operator: event.target.value as TableFilterOperator })}
>
{TABLE_FILTER_OPERATORS.map((operator) => <option key={operator} value={operator}>{t(`structuredTable.operators.${operator}`)}</option>)}
{(document.fields.find((field) => field.id === filter.fieldId)?.type === "attachment" ? TABLE_ATTACHMENT_FILTER_OPERATORS : TABLE_FILTER_OPERATORS).map((operator) => <option key={operator} value={operator}>{t(`structuredTable.operators.${operator}`)}</option>)}
</select>
{operatorNeedsValue ? (
<Input
@@ -608,6 +783,13 @@ export const TableEditorPane = ({
</select>
</label>
</div>
<TableFormDialog
memoId={memo.id}
memoTitle={title}
fields={document.fields}
open={formOpen}
onOpenChange={setFormOpen}
/>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-max min-w-full border-collapse">
<thead className="sticky top-0 z-10 bg-card">
+3 -3
View File
@@ -46,7 +46,7 @@ import {
} from "@/lib/mobile-editor";
import { cn } from "@/lib/utils";
import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status";
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, parseTableDocument, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, getNotebookDescendantIds, hasTableDocumentMarker, markdownToDoc, parseDiagramDocument, parseTableDocument, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown, type NoteCreateKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared";
import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import type {
Pane,
@@ -1619,7 +1619,7 @@ export const WorkspaceApp = ({
: null;
const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo;
const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown);
const selectedTable = parseTableDocument(selectedMemo?.contentMarkdown);
const selectedTableNote = hasTableDocumentMarker(selectedMemo?.contentMarkdown);
const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed);
const desktopFocusModeActive = Boolean(
isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive
@@ -3354,7 +3354,7 @@ export const WorkspaceApp = ({
onOpenExecutionCenter={handleOpenExecutionCenter}
/>
) : selectedMemo && selectedTable ? (
) : selectedMemo && selectedTableNote ? (
<TableEditorPane
key={selectedMemo.id}
memo={selectedMemo}
@@ -163,7 +163,7 @@ export const RevisionHistoryDialog = ({
</span>
<span className="mt-1 flex items-center gap-1.5 truncate text-[11px] text-slate-400">
<UserRound className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<span className="truncate">{formatRevisionActor(revision.createdBy)}</span>
<span className="truncate">{revision.createdBy === "table-form" ? t("revisions.formActor") : formatRevisionActor(revision.createdBy)}</span>
</span>
</button>
))}
@@ -0,0 +1,234 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ClipboardList, Copy } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TableField, TableFormFieldSetting, TableFormUpdateInput } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
const formUrl = (token: string) => {
const baseUrl = getConfiguredDesktopApiBaseUrl() || window.location.origin;
return `${baseUrl.replace(/\/$/, "")}/form/${encodeURIComponent(token)}`;
};
const passwordKey = (memoId: string) => `edgeever.tableFormPassword.${memoId}`;
const readPassword = (memoId: string, token: string) => {
try {
const parsed = JSON.parse(window.sessionStorage.getItem(passwordKey(memoId)) ?? "") as { token?: string; password?: string };
return parsed.token === token && parsed.password ? parsed.password : "";
} catch {
return "";
}
};
const writePassword = (memoId: string, token: string, password: string) => {
try {
window.sessionStorage.setItem(passwordKey(memoId), JSON.stringify({ token, password }));
} catch {
// The generated password stays visible for this dialog session.
}
};
export const TableFormDialog = ({
memoId,
memoTitle,
fields,
open,
onOpenChange,
}: {
memoId: string;
memoTitle: string;
fields: TableField[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const queryKey = ["table-form", memoId] as const;
const formQuery = useQuery({
queryKey,
queryFn: () => api.getTableForm(memoId),
enabled: open,
retry: false,
});
const saved = formQuery.data?.form ?? null;
const [enabled, setEnabled] = useState(false);
const [passwordProtected, setPasswordProtected] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitLabel, setSubmitLabel] = useState("");
const [selected, setSelected] = useState<TableFormFieldSetting[]>([]);
const [password, setPassword] = useState("");
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const nextFields = saved?.fields.length
? saved.fields.filter((field) => fields.some((item) => item.id === field.fieldId))
: fields.map((field) => ({ fieldId: field.id, required: false }));
setEnabled(saved?.enabled ?? false);
setPasswordProtected(saved?.passwordProtected ?? false);
setTitle(saved?.title || memoTitle);
setDescription(saved?.description ?? "");
setSubmitLabel(saved?.submitLabel ?? "");
setSelected(nextFields);
setPassword(saved?.token ? readPassword(memoId, saved.token) : "");
setError(null);
}, [fields, memoId, memoTitle, open, saved]);
const saveMutation = useMutation({
mutationFn: (payload: TableFormUpdateInput) => api.updateTableForm(memoId, payload),
onSuccess: (data) => {
queryClient.setQueryData(queryKey, { form: { ...data.form, password: undefined } });
if (data.form.password) {
writePassword(memoId, data.form.token, data.form.password);
setPassword(data.form.password);
} else if (!data.form.passwordProtected) {
setPassword("");
}
setError(null);
},
onError: (reason: Error) => setError(reason.message),
});
const payload = (rotatePassword = false): TableFormUpdateInput => ({
enabled,
passwordProtected,
rotatePassword,
title: title.trim(),
description: description.trim(),
submitLabel: submitLabel.trim(),
fields: selected,
});
const save = (rotatePassword = false) => {
if (enabled && selected.length === 0) {
setError(t("structuredTable.form.needField"));
return;
}
saveMutation.mutate(payload(rotatePassword));
};
const toggleField = (fieldId: string, included: boolean) => {
setSelected((current) => included
? [...current.filter((field) => field.fieldId !== fieldId), { fieldId, required: false }]
: current.filter((field) => field.fieldId !== fieldId));
};
const toggleRequired = (fieldId: string, required: boolean) => {
setSelected((current) => current.map((field) => field.fieldId === fieldId ? { ...field, required } : field));
};
const link = saved?.token ? formUrl(saved.token) : "";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("structuredTable.form.title")}</DialogTitle>
<DialogDescription>{t("structuredTable.form.description")}</DialogDescription>
</DialogHeader>
<label className="flex items-center justify-between gap-3 text-sm">
<span>{t("structuredTable.form.enabled")}</span>
<Switch checked={enabled} onCheckedChange={setEnabled} aria-label={t("structuredTable.form.enabled")} />
</label>
{link && saved?.enabled ? (
<div className="space-y-2">
<span className="text-xs text-slate-500">{t("structuredTable.form.link")}</span>
<div className="flex gap-2">
<Input readOnly value={link} aria-label={t("structuredTable.form.link")} />
<Button
type="button"
variant="outline"
onClick={() => {
void copyTextToClipboard(link).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copied ? t("structuredTable.form.copied") : t("structuredTable.form.copy")}
</Button>
</div>
</div>
) : null}
<label className="flex items-center justify-between gap-3 text-sm">
<span>{t("structuredTable.form.password")}</span>
<Switch checked={passwordProtected} onCheckedChange={setPasswordProtected} aria-label={t("structuredTable.form.password")} />
</label>
<p className="text-xs text-slate-500">{t("structuredTable.form.passwordHint")}</p>
{password ? <Input readOnly value={password} aria-label={t("structuredTable.form.password")} /> : null}
{saved?.passwordProtected ? (
<Button type="button" variant="outline" size="sm" onClick={() => save(true)} disabled={saveMutation.isPending}>
{t("structuredTable.form.regenerate")}
</Button>
) : null}
<label className="block space-y-1 text-sm">
<span>{t("structuredTable.form.formTitle")}</span>
<Input value={title} onChange={(event) => setTitle(event.target.value)} aria-label={t("structuredTable.form.formTitle")} />
</label>
<label className="block space-y-1 text-sm">
<span>{t("structuredTable.form.formDescription")}</span>
<textarea
className="min-h-20 w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
value={description}
onChange={(event) => setDescription(event.target.value)}
aria-label={t("structuredTable.form.formDescription")}
/>
</label>
<label className="block space-y-1 text-sm">
<span>{t("structuredTable.form.submitLabel")}</span>
<Input
value={submitLabel}
placeholder={t("structuredTable.form.submitDefault")}
onChange={(event) => setSubmitLabel(event.target.value)}
aria-label={t("structuredTable.form.submitLabel")}
/>
</label>
<div className="space-y-2">
<p className="text-sm">{t("structuredTable.form.fields")}</p>
{fields.map((field) => {
const setting = selected.find((item) => item.fieldId === field.id);
return (
<div key={field.id} className="flex items-center justify-between gap-3 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(setting)}
aria-label={field.name}
onChange={(event) => toggleField(field.id, event.target.checked)}
/>
<span>{field.name}</span>
<span className="text-xs text-slate-400">{t(`structuredTable.types.${field.type}`)}</span>
</label>
<label className="flex items-center gap-2 text-xs text-slate-500">
<input
type="checkbox"
checked={Boolean(setting?.required)}
disabled={!setting}
aria-label={`${field.name} ${t("structuredTable.form.required")}`}
onChange={(event) => toggleRequired(field.id, event.target.checked)}
/>
{t("structuredTable.form.required")}
</label>
</div>
);
})}
</div>
{error ? <p className="text-sm text-rose-600">{error}</p> : null}
{saveMutation.isSuccess && !error ? <p className="text-sm text-emerald-700">{t("structuredTable.form.saved")}</p> : null}
<Button type="button" onClick={() => save(false)} disabled={saveMutation.isPending || formQuery.isLoading}>
<ClipboardList className="h-4 w-4" />
{saveMutation.isPending ? t("structuredTable.form.saving") : t("structuredTable.form.save")}
</Button>
</DialogContent>
</Dialog>
);
};
+33
View File
@@ -0,0 +1,33 @@
PRAGMA foreign_keys = ON;
CREATE TABLE table_forms (
id TEXT PRIMARY KEY,
memo_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
token TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)),
password_hash TEXT,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
submit_label TEXT NOT NULL DEFAULT '',
fields_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(fields_json)),
unlock_failed_count INTEGER NOT NULL DEFAULT 0,
unlock_window_started_at TEXT,
unlock_blocked_until TEXT,
submit_count INTEGER NOT NULL DEFAULT 0,
submit_window_started_at TEXT,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (memo_id) REFERENCES memos(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
UNIQUE (memo_id),
UNIQUE (token)
);
CREATE INDEX idx_table_forms_workspace
ON table_forms(workspace_id, updated_at DESC);
+36
View File
@@ -34,6 +34,9 @@ import type {
MemoRevision,
MemoSummary,
MemoShare,
PublicTableForm,
TableFormSettings,
TableFormUpdateInput,
MemoTemplate,
ScheduledTask,
ScheduledTaskRun,
@@ -1507,6 +1510,39 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify(payload),
}),
getTableForm: (memoId: string) =>
request<{ form: TableFormSettings | null }>(`/api/v1/memos/${encodeURIComponent(memoId)}/form`),
updateTableForm: (memoId: string, payload: TableFormUpdateInput) =>
request<{ form: TableFormSettings }>(`/api/v1/memos/${encodeURIComponent(memoId)}/form`, {
method: "PUT",
body: JSON.stringify(payload),
}),
getPublicTableForm: (token: string) =>
request<{ form: PublicTableForm }>(`/api/public/forms/${encodeURIComponent(token)}`),
unlockPublicTableForm: (token: string, password: string) =>
request<{ ok: true }>(`/api/public/forms/${encodeURIComponent(token)}/unlock`, {
method: "POST",
body: JSON.stringify({ password }),
}),
submitPublicTableForm: (token: string, cells: Record<string, unknown>) =>
request<{ ok: true }>(`/api/public/forms/${encodeURIComponent(token)}/submissions`, {
method: "POST",
body: JSON.stringify({ cells }),
}),
uploadPublicTableFormFile: (token: string, file: File) => {
const form = new FormData();
form.append("file", file, file.name || "attachment");
return request<{ resource: { id: string; filename: string; mimeType: string; byteSize: number } }>(
`/api/public/forms/${encodeURIComponent(token)}/resources`,
{ method: "POST", body: form },
);
},
revokeMemoShare: (memoId: string) =>
request<{ ok: true }>(`/api/v1/memos/${memoId}/share`, { method: "DELETE" }),
+36
View File
@@ -311,6 +311,40 @@ export const enUS = {
unsaved: "Unsaved changes",
back: "Back to note list",
editField: "Edit field {{name}}",
openForm: "Form",
form: {
title: "Form",
description: "Share this link so someone can add one row. They cannot see existing rows.",
enabled: "Accept responses",
link: "Form link",
copy: "Copy link",
copied: "Copied",
password: "Access password",
passwordHint: "Turning this on generates a password. People need it to open the form.",
regenerate: "Generate a new password",
formTitle: "Form title",
formDescription: "Description",
submitLabel: "Submit button",
submitDefault: "Submit",
fields: "Fields on the form",
required: "Required",
save: "Save form",
saving: "Saving",
saved: "Form saved",
needField: "Choose at least one field",
closed: "This form is closed",
full: "This form is no longer accepting responses",
submitted: "Submitted",
submitAnother: "Submit another",
passwordRequired: "Enter the access password",
unlock: "Open form",
upload: "Upload attachment",
uploadLimit: "Each attachment must be 20MB or smaller",
},
addAttachment: "Add attachment",
removeAttachment: "Remove {{name}}",
attachmentLimit: "Up to 10 attachments per cell",
uploading: "Uploading",
types: {
text: "Text",
number: "Number",
@@ -318,6 +352,7 @@ export const enUS = {
date: "Date",
select: "Select",
url: "Link",
attachment: "Attachment",
},
operators: {
contains: "contains",
@@ -1670,6 +1705,7 @@ export const enUS = {
restoreConfirmTitle: "Restore this historical version",
restoreConfirmDescription: "The current content will be replaced by this historical version, and a new revision will still be created.",
restoreConfirmLabel: "Restore",
formActor: "Form submission",
},
pdfViewer: {
loading: "Loading PDF…",
+36
View File
@@ -311,6 +311,40 @@ export const ja = {
unsaved: "未保存の変更",
back: "ノート一覧に戻る",
editField: "フィールド {{name}} を編集",
openForm: "フォーム",
form: {
title: "フォーム入力",
description: "このリンクを共有すると、相手は1行だけ追加できます。既存の行は見えません。",
enabled: "回答を受け付ける",
link: "フォームのリンク",
copy: "リンクをコピー",
copied: "コピーしました",
password: "アクセスパスワード",
passwordHint: "オンにするとパスワードを生成します。開くにはパスワードが必要です。",
regenerate: "パスワードを再生成",
formTitle: "フォームのタイトル",
formDescription: "説明",
submitLabel: "送信ボタン",
submitDefault: "送信",
fields: "フォームに表示するフィールド",
required: "必須",
save: "フォームを保存",
saving: "保存中",
saved: "フォームを保存しました",
needField: "フィールドを1つ以上選んでください",
closed: "このフォームは閉じられています",
full: "このフォームは受付を終了しました",
submitted: "送信しました",
submitAnother: "もう一度入力",
passwordRequired: "アクセスパスワードを入力",
unlock: "フォームを開く",
upload: "添付ファイルをアップロード",
uploadLimit: "添付ファイルは1件20MBまで",
},
addAttachment: "添付ファイルを追加",
removeAttachment: "{{name}} を削除",
attachmentLimit: "1セルにつき添付は10件まで",
uploading: "アップロード中",
types: {
text: "テキスト",
number: "数値",
@@ -318,6 +352,7 @@ export const ja = {
date: "日付",
select: "選択",
url: "リンク",
attachment: "添付ファイル",
},
operators: {
contains: "含む",
@@ -1670,6 +1705,7 @@ export const ja = {
restoreConfirmTitle: "この履歴の版を復元",
restoreConfirmDescription: "現在の内容はこの履歴の版で置き換えられます。復元後も新しい版は作成されます。",
restoreConfirmLabel: "復元",
formActor: "フォーム入力",
},
pdfViewer: {
loading: "PDF を読み込み中…",
+36
View File
@@ -311,6 +311,40 @@ export const zhCN = {
unsaved: "有未保存的修改",
back: "返回笔记列表",
editField: "编辑字段 {{name}}",
openForm: "表单录入",
form: {
title: "表单录入",
description: "把链接发给别人,对方只能填写并提交一行,看不到表里已有的记录。",
enabled: "接受填写",
link: "表单链接",
copy: "复制链接",
copied: "已复制",
password: "访问密码",
passwordHint: "打开后自动生成密码。对方需要密码才能打开填写页。",
regenerate: "重新生成密码",
formTitle: "表单标题",
formDescription: "说明",
submitLabel: "提交按钮",
submitDefault: "提交",
fields: "出现在表单里的字段",
required: "必填",
save: "保存表单",
saving: "保存中",
saved: "表单已保存",
needField: "至少选择一个字段",
closed: "这个表单已经关闭",
full: "这个表单已经收满",
submitted: "已提交",
submitAnother: "再填一份",
passwordRequired: "请输入访问密码",
unlock: "打开表单",
upload: "上传附件",
uploadLimit: "每个附件不超过 20MB",
},
addAttachment: "添加附件",
removeAttachment: "移除 {{name}}",
attachmentLimit: "每格最多 10 个附件",
uploading: "正在上传",
types: {
text: "文本",
number: "数字",
@@ -318,6 +352,7 @@ export const zhCN = {
date: "日期",
select: "单选",
url: "链接",
attachment: "附件",
},
operators: {
contains: "包含",
@@ -1670,6 +1705,7 @@ export const zhCN = {
restoreConfirmTitle: "恢复到这个历史版本",
restoreConfirmDescription: "当前内容会被这个历史版本替换,恢复后仍会产生新的历史记录。",
restoreConfirmLabel: "恢复",
formActor: "表单录入",
},
pdfViewer: {
loading: "正在加载 PDF…",
+51
View File
@@ -394,6 +394,55 @@ export const PublicShareUnlockSchema = z.object({
password: z.string().min(1).max(64),
});
export const TableFormFieldSchema = z.object({
fieldId: z.string().trim().min(1).max(80),
required: z.boolean(),
});
export const TableFormUpdateSchema = z.object({
enabled: z.boolean(),
passwordProtected: z.boolean(),
rotatePassword: z.boolean().optional(),
title: z.string().trim().max(120),
description: z.string().trim().max(1000),
submitLabel: z.string().trim().max(40),
fields: z.array(TableFormFieldSchema).max(40),
});
export const PublicTableFormSubmissionSchema = z.object({
cells: z.record(z.string().max(80), z.unknown()),
});
export type TableFormFieldSetting = z.infer<typeof TableFormFieldSchema>;
export type TableFormSettings = {
enabled: boolean;
token: string;
passwordProtected: boolean;
password?: string;
title: string;
description: string;
submitLabel: string;
fields: TableFormFieldSetting[];
};
export type PublicTableFormField = {
id: string;
name: string;
type: "text" | "number" | "checkbox" | "date" | "select" | "url" | "attachment";
required: boolean;
options?: string[];
};
export type PublicTableForm = {
passwordRequired: boolean;
title: string;
description: string;
submitLabel: string;
full: boolean;
fields: PublicTableFormField[];
};
export type NotebookCreateInput = z.infer<typeof NotebookCreateSchema>;
export type NotebookUpdateInput = z.infer<typeof NotebookUpdateSchema>;
export type MemoCreateInput = z.infer<typeof MemoCreateSchema>;
@@ -432,3 +481,5 @@ export type AiPromptTemplateCreateInput = z.input<typeof AiPromptTemplateCreateS
export type AiPromptTemplateUpdateInput = z.infer<typeof AiPromptTemplateUpdateSchema>;
export type MemoShareUpdateInput = z.infer<typeof MemoShareUpdateSchema>;
export type PublicShareUnlockInput = z.infer<typeof PublicShareUnlockSchema>;
export type TableFormUpdateInput = z.infer<typeof TableFormUpdateSchema>;
export type PublicTableFormSubmissionInput = z.infer<typeof PublicTableFormSubmissionSchema>;
+38
View File
@@ -5,6 +5,8 @@ import {
addTableRecord,
applyTableView,
createDefaultTableDocument,
listTableAttachmentResourceIds,
tableAttachmentUrl,
hasTableDocumentMarker,
parseTableDocument,
removeTableField,
@@ -80,4 +82,40 @@ describe("structured table documents", () => {
expect(document.records[0]?.cells.fld_url).toBe("");
expect(document.fields.at(-1)?.name).toBe("链接");
});
test("stores attachment references and writes filename links into the readable table", () => {
const files = [
{ resourceId: "res_brief", filename: "brief.pdf", mimeType: "application/pdf", byteSize: 12 },
{ resourceId: "res_shot", filename: "shot.png", mimeType: "image/png", byteSize: 34 },
];
let document = addTableField(createDefaultTableDocument(), { id: "fld_files", name: "附件", type: "attachment" });
document = updateTableCell(document, "rec_sample", "fld_files", files);
expect(document.records[0]?.cells.fld_files).toEqual(files);
expect(listTableAttachmentResourceIds(document)).toEqual(new Set(["res_brief", "res_shot"]));
const markdown = tableFallbackMarkdown(document);
expect(markdown).toContain(`[brief.pdf](${tableAttachmentUrl("res_brief")})`);
expect(markdown).toContain(`[shot.png](${tableAttachmentUrl("res_shot")})`);
expect(parseTableDocument(serializeTableDocument(document))?.records[0]?.cells.fld_files).toEqual(files);
expect(tableDocumentToCsv(document)).toContain("brief.pdf; shot.png");
const filled = replaceTableView(document, { filters: [{ fieldId: "fld_files", operator: "notEmpty" }], sort: null });
expect(applyTableView(filled)).toHaveLength(1);
const empty = updateTableCell(document, "rec_sample", "fld_files", []);
expect(applyTableView(replaceTableView(empty, { filters: [{ fieldId: "fld_files", operator: "empty" }], sort: null }))).toHaveLength(1);
});
test("keeps ten unique attachments and turns a removed attachment field back into text", () => {
const files = Array.from({ length: 12 }, (_, index) => ({
resourceId: `res_${index}`,
filename: `file-${index}.txt`,
mimeType: "text/plain",
byteSize: index,
}));
files.push({ resourceId: "res_0", filename: "duplicate.txt", mimeType: "text/plain", byteSize: 1 });
let document = addTableField(createDefaultTableDocument(), { id: "fld_files", name: "附件", type: "attachment" });
document = updateTableCell(document, "rec_sample", "fld_files", files);
expect(document.records[0]?.cells.fld_files).toHaveLength(10);
document = updateTableField(document, "fld_files", { type: "text" });
expect(document.records[0]?.cells.fld_files).toContain("file-0.txt");
expect(listTableAttachmentResourceIds(document).size).toBe(0);
});
});
+81 -3
View File
@@ -3,12 +3,20 @@ 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"] as const;
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 TableCellValue = string | number | boolean | null;
export type TableAttachment = {
resourceId: string;
filename: string;
mimeType: string;
byteSize: number;
};
export type TableCellValue = string | number | boolean | null | TableAttachment[];
export type TableField = {
id: string;
@@ -66,6 +74,8 @@ 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);
@@ -97,7 +107,35 @@ const cleanOptions = (value: unknown) => {
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";
}
@@ -106,12 +144,29 @@ export const coerceTableCell = (field: TableField, value: unknown): TableCellVal
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;
@@ -179,7 +234,15 @@ export const hasTableDocumentMarker = (markdown: string | null | undefined) =>
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, "\\|");
@@ -222,7 +285,9 @@ export const createDefaultTableDocument = (labels: TableSeedLabels = {}): TableD
};
const csvCell = (value: TableCellValue) => {
const text = value === null || value === undefined ? "" : value === true ? "true" : value === false ? "false" : String(value);
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;
};
@@ -235,6 +300,10 @@ export const tableDocumentToCsv = (document: TableDocument, records: TableRecord
};
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;
@@ -242,6 +311,15 @@ const cellSortText = (field: TableField, value: TableCellValue) => {
};
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;