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:
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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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" }),
|
||||
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 を読み込み中…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user