diff --git a/apps/api/src/database-write-optimizations.test.mjs b/apps/api/src/database-write-optimizations.test.mjs index bd55a1f5..8607a559 100644 --- a/apps/api/src/database-write-optimizations.test.mjs +++ b/apps/api/src/database-write-optimizations.test.mjs @@ -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); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 04640b84..8adc9206 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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), diff --git a/apps/api/src/memo-routes.ts b/apps/api/src/memo-routes.ts index b98760c3..dde171e8 100644 --- a/apps/api/src/memo-routes.ts +++ b/apps/api/src/memo-routes.ts @@ -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 } >; }; @@ -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 }); }; diff --git a/apps/api/src/memo-service.ts b/apps/api/src/memo-service.ts index b92df940..0f23d8d4 100644 --- a/apps/api/src/memo-service.ts +++ b/apps/api/src/memo-service.ts @@ -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 } > => { 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() + : { 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 => { diff --git a/apps/api/src/resource-service.ts b/apps/api/src/resource-service.ts index b953e76f..cdb8ff0a 100644 --- a/apps/api/src/resource-service.ts +++ b/apps/api/src/resource-service.ts @@ -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; }; const storeResource = async ( @@ -347,7 +358,7 @@ export const createAttachmentResource = async ( kind: "attachment", width: null, height: null, - metadata: {}, + metadata: input.metadata ?? {}, auditMetadata: {}, }); }; diff --git a/apps/api/src/table-form-routes.test.mjs b/apps/api/src/table-form-routes.test.mjs new file mode 100644 index 00000000..b7fc615c --- /dev/null +++ b/apps/api/src/table-form-routes.test.mjs @@ -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(); + }); +}); diff --git a/apps/api/src/table-form-routes.ts b/apps/api/src/table-form-routes.ts new file mode 100644 index 00000000..82f9331e --- /dev/null +++ b/apps/api/src/table-form-routes.ts @@ -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(); + +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(); + 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(); + 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) => { + 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) => { + 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) => { + 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(); + 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(); + 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(); + if (!saved) return notFound(c, "Form not found"); + return c.json({ form: mapOwnerForm(saved, password) }); + }); +}; diff --git a/apps/api/src/table-form-service.ts b/apps/api/src/table-form-service.ts new file mode 100644 index 00000000..ef71b8f0 --- /dev/null +++ b/apps/api/src/table-form-service.ts @@ -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) => { + 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, + attachments: Map, +) => { + const fields = new Map(document.fields.map((field) => [field.id, field])); + const nextCells: Record = {}; + 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(); + 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, + attachments: Map, +) => { + 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); +}; diff --git a/apps/mobile/src/screens/WorkspaceRevisionHistory.tsx b/apps/mobile/src/screens/WorkspaceRevisionHistory.tsx index c60f8626..2174a030 100644 --- a/apps/mobile/src/screens/WorkspaceRevisionHistory.tsx +++ b/apps/mobile/src/screens/WorkspaceRevisionHistory.tsx @@ -178,7 +178,7 @@ export const RevisionHistoryModal = ({ > {`版本 ${revision.revision}`} - {formatDate(revision.createdAt, localePreference)} · {formatRevisionActor(revision.createdBy)} + {formatDate(revision.createdAt, localePreference)} · {revision.createdBy === "table-form" ? "表单录入" : formatRevisionActor(revision.createdBy)} ))} diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 73c3d26f..92ff296e 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -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 }) => (
@@ -228,6 +229,7 @@ export const App = () => { }>} /> + }>} /> } /> } /> } /> diff --git a/apps/web/src/components/PublicTableFormPage.tsx b/apps/web/src/components/PublicTableFormPage.tsx new file mode 100644 index 00000000..4d6e9328 --- /dev/null +++ b/apps/web/src/components/PublicTableFormPage.tsx @@ -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) => + 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>({}); + const [done, setDone] = useState(false); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(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) => 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
; + } + if (formQuery.isError || !form) { + return
{t("structuredTable.form.closed")}
; + } + if (form.passwordRequired) { + return ( +
+

{t("structuredTable.form.passwordRequired")}

+ setPassword(event.target.value)} + /> + {error ?

{error}

: null} + +
+ ); + } + if (done) { + return ( +
+

{t("structuredTable.form.submitted")}

+ +
+ ); + } + + return ( +
+
+

{form.title || t("structuredTable.form.title")}

+ {form.description ?

{form.description}

: null} +
+ {form.full ?

{t("structuredTable.form.full")}

: null} +
{ + 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 ( + + ); + })} + {error ?

{error}

: null} + +
+
+ ); +}; diff --git a/apps/web/src/components/TableEditorPane.tsx b/apps/web/src/components/TableEditorPane.tsx index 79fa6e41..5d586e87 100644 --- a/apps/web/src/components/TableEditorPane.tsx +++ b/apps/web/src/components/TableEditorPane.tsx @@ -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(null); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(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 ( +
+ {items.map((item) => { + const href = toDesktopResourceUrl(tableAttachmentUrl(item.resourceId)); + const image = item.mimeType.startsWith("image/"); + return ( +
+ {image ? :
+ ); + })} + { void uploadFiles(event.target.files); }} + /> + + {uploadError ?

{uploadError}

: null} +
+ ); +}; + 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 ( + + ); + } if (field.type === "checkbox") { return ( (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(null); + const pendingUploadIdsRef = useRef(new Set()); 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 = ({
{countLabel} {saveLabel ? {saveLabel} : null} +