diff --git a/apps/api/src/mcp-management-tools.test.mjs b/apps/api/src/mcp-management-tools.test.mjs index cb56b360..bed93f0f 100644 --- a/apps/api/src/mcp-management-tools.test.mjs +++ b/apps/api/src/mcp-management-tools.test.mjs @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { globSync, readFileSync } from "node:fs"; import { Database } from "bun:sqlite"; import { callMcpTool } from "./index.ts"; -import { parseDiagramDocument } from "@edgeever/shared"; +import { parseDiagramDocument, parseTableDocument, serializeTableDocument, createDefaultTableDocument } from "@edgeever/shared"; class SqliteD1PreparedStatement { constructor(db, sql, bindings = []) { @@ -198,6 +198,26 @@ describe("MCP template and AI instruction management", () => { expect(parseDiagramDocument(stored.content_markdown).nodes.map((node) => node.id)).toContain("redis"); }); + test("refuses to replace a structured table through update_memo", async () => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_tables", "ws_mcp", "Tables"); + const created = await callMcpTool(context, auth, "create_memo", { + notebookId: "nb_tables", + title: "阅读清单", + contentMarkdown: serializeTableDocument(createDefaultTableDocument()), + }); + const read = await callMcpTool(context, auth, "get_memo", { memoId: created.memo.id }); + expect(read.structuredTable).toMatchObject({ fieldCount: 3, recordCount: 1 }); + expect(JSON.stringify(read)).not.toContain("edgeever-table-v1"); + await expect(callMcpTool(context, auth, "update_memo", { + memoId: created.memo.id, + contentMarkdown: "plain text", + })).rejects.toMatchObject({ code: "table_update_required" }); + const stored = sqlite.query("SELECT content_markdown FROM memo_contents WHERE memo_id = ?").get(created.memo.id); + expect(parseTableDocument(stored.content_markdown)?.records).toHaveLength(1); + }); + test("keeps mind-map hierarchy edges consistent when a node is reparented", async () => { const { sqlite, auth, context } = createFixture(); sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index 76e2b38c..960c5ca2 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -6,9 +6,13 @@ import { TemplateUpdateSchema, docToText, markdownToDoc, + getTableSummary, + hasTableDocumentMarker, parseDiagramDocument, + parseTableDocument, serializeDiagramDocument, stripDiagramDocumentMarker, + stripTableDocumentMarker, type DiagramDocument, type DiagramNodeShape, type MemoDetail, @@ -383,6 +387,12 @@ const memoWithoutDiagramPayload = (memo: MemoDetail) => { return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) }; }; +const memoWithoutTablePayload = (memo: MemoDetail) => { + const contentMarkdown = stripTableDocumentMarker(memo.contentMarkdown); + const contentJson = markdownToDoc(contentMarkdown); + return { ...memo, contentMarkdown, contentJson, contentText: docToText(contentJson) }; +}; + type MutableDiagramEdge = ReturnType["edges"][number]; const applyDiagramOperations = async ( @@ -683,9 +693,10 @@ export const callMcpTool = async ( } const diagram = parseDiagramDocument(memo.contentMarkdown); - return diagram - ? { memo: memoWithoutDiagramPayload(memo), diagram: diagramSemanticGraph(diagram) } - : { memo }; + if (diagram) return { memo: memoWithoutDiagramPayload(memo), diagram: diagramSemanticGraph(diagram) }; + const table = parseTableDocument(memo.contentMarkdown); + if (table) return { memo: memoWithoutTablePayload(memo), structuredTable: getTableSummary(memo.contentMarkdown).tablePreview }; + return { memo }; } case "create_memo": { assertScope(auth, "write:memos"); @@ -796,6 +807,13 @@ export const callMcpTool = async ( 400, ); } + if (existing && hasTableDocumentMarker(existing.contentMarkdown)) { + throw new AppError( + "table_update_required", + "Structured table content cannot be replaced through update_memo.", + 400, + ); + } } const actor = getAuditActor(c); const actorLabel = getActorLabel(c); diff --git a/apps/api/src/memo-list-service.ts b/apps/api/src/memo-list-service.ts index 88663c94..46503541 100644 --- a/apps/api/src/memo-list-service.ts +++ b/apps/api/src/memo-list-service.ts @@ -4,6 +4,7 @@ import { docToText, markdownToDoc, getDiagramSummary, + getTableSummary, type MemoSummary, } from "@edgeever/shared"; import { parseJsonArray } from "./entity-utils"; @@ -70,6 +71,7 @@ export const mapMemoSummary = (row: MemoSummaryRow): MemoSummary => ({ createExcerpt(row.content_text ?? "") || createExcerpt(docToText(markdownToDoc(row.content_markdown ?? ""))), ...getDiagramSummary(row.content_markdown), + ...getTableSummary(row.content_markdown), tags: parseJsonArray(row.tags_json), isPinned: Boolean(row.is_pinned), isArchived: Boolean(row.is_archived), diff --git a/apps/ios/EdgeEver/Editor/TipTapContentSource.swift b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift index 9b3c9b19..3fbd9d99 100644 --- a/apps/ios/EdgeEver/Editor/TipTapContentSource.swift +++ b/apps/ios/EdgeEver/Editor/TipTapContentSource.swift @@ -26,6 +26,12 @@ enum TipTapContentSource: Sendable { if markdown.contains(""#, + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespacesAndNewlines) + } + /// True when TipTap JSON stores at least one image `width` (25–100 display size). static func jsonHasImageWidth(_ json: String) -> Bool { // Match `"width":50` / `"width": 50` near image nodes without full parse. diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift index 1b8e4161..11fa9315 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoDetailView.swift @@ -76,7 +76,7 @@ struct MemoDetailView: View { .background(AppTheme.card) // UIKit FAB in overlay — SwiftUI Button over WKWebView often receives zero taps. .overlay(alignment: .bottomTrailing) { - if let memo, !memo.isDeleted, !isVisualDiagram(memo) { + if let memo, !memo.isDeleted, !blocksRichTextEdit(memo) { EditFabButton( accessibilityLabel: env.preferences.t("编辑笔记", en: "Edit note") ) { @@ -316,10 +316,10 @@ struct MemoDetailView: View { titleVisibility: .visible ) { if let memo { - if !isVisualDiagram(memo) { + if !blocksRichTextEdit(memo) { Button(env.preferences.t("编辑", en: "Edit")) { onEdit(memo.id, .body) } } - if !memo.isDeleted && !isTemporaryMemoId(memo.id) && !isVisualDiagram(memo) { + if !memo.isDeleted && !isTemporaryMemoId(memo.id) && !blocksRichTextEdit(memo) { Button(env.preferences.t("AI 笔记助手", en: "AI note assistant")) { showAiAssistant = true } @@ -778,7 +778,7 @@ struct MemoDetailView: View { onImagePreview: { source, alt in imagePreview = (source, alt) }, - onDoubleTap: isVisualDiagram(memo) ? nil : { + onDoubleTap: blocksRichTextEdit(memo) ? nil : { onEdit(memo.id, .body) }, onPickImage: nil, @@ -898,11 +898,15 @@ struct MemoDetailView: View { .multilineTextAlignment(.leading) } .buttonStyle(.plain) - .disabled(memo.isDeleted || isVisualDiagram(memo)) - .accessibilityLabel(isVisualDiagram(memo) + .disabled(memo.isDeleted || blocksRichTextEdit(memo)) + .accessibilityLabel(isStructuredTable(memo) + ? env.preferences.t("表格标题", en: "Database title") + : isVisualDiagram(memo) ? env.preferences.t("图表标题", en: "Diagram title") : env.preferences.t("编辑笔记标题", en: "Edit note title")) - .accessibilityHint(isVisualDiagram(memo) + .accessibilityHint(isStructuredTable(memo) + ? env.preferences.t("多维表格请在 Web 或桌面端编辑", en: "Edit databases on Web or desktop") + : isVisualDiagram(memo) ? env.preferences.t("可视化图表请在 Web 或桌面端编辑", en: "Edit visual diagrams on Web or desktop") : env.preferences.t("进入编辑并聚焦标题", en: "Opens editing with the title focused")) .accessibilityIdentifier(DetailMemoChrome.title) @@ -925,6 +929,14 @@ struct MemoDetailView: View { memo.contentMarkdown.contains("" + let decision = TipTapContentSource.resolve( + mode: .viewer, + documentJSON: #"{"type":"doc","content":[{"type":"paragraph"}]}"#, + markdown: markdown + ) + XCTAssertFalse(decision.useJSON) + XCTAssertFalse(decision.payload.contains("edgeever-table-v1")) + XCTAssertTrue(decision.payload.contains("示例")) + } + func testEditorMarkdownRemainsAuthoritativeForRichStructures() { let markdown = """ 1. first diff --git a/apps/mobile/src/lib/mobile-diagram.test.ts b/apps/mobile/src/lib/mobile-diagram.test.ts index 0ac3824a..f9370466 100644 --- a/apps/mobile/src/lib/mobile-diagram.test.ts +++ b/apps/mobile/src/lib/mobile-diagram.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; -import { createDefaultDiagramDocument, diagramFallbackMarkdown, serializeDiagramDocument } from "@edgeever/shared"; -import { getMobileVisualDiagramKind, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram"; +import { createDefaultDiagramDocument, createDefaultTableDocument, diagramFallbackMarkdown, serializeDiagramDocument, serializeTableDocument, tableFallbackMarkdown } from "@edgeever/shared"; +import { getMobileVisualDiagramKind, hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "./mobile-diagram"; const hasMermaidCodeBlock = (doc: { content?: Array<{ type?: string; attrs?: { language?: string } }> }) => Boolean(doc.content?.some((node) => node.type === "codeBlock" && node.attrs?.language === "mermaid")); @@ -36,6 +36,17 @@ describe("mobile visual diagram viewer", () => { expect(hasMermaidCodeBlock(viewerContent)).toBe(true); }); + test("shows a structured table as a readable table and keeps the record marker out of the editor", () => { + const markdown = serializeTableDocument(createDefaultTableDocument()); + expect(hasMobileStructuredTable(markdown)).toBe(true); + const viewerContent = resolveMobileMemoViewerContent(null, markdown); + expect(JSON.stringify(viewerContent)).not.toContain("edgeever-table-v1"); + expect(viewerContent.content?.some((node) => node.type === "table")).toBe(true); + const broken = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n`; + expect(hasMobileStructuredTable(broken)).toBe(true); + expect(JSON.stringify(resolveMobileMemoViewerContent(null, broken))).not.toContain("edgeever-table-v1"); + }); + test("fills the remaining phone viewport instead of shrinking the canvas to a postage stamp", () => { const source = readFileSync(new URL("../components/LocalTiptapEditor.tsx", import.meta.url), "utf8"); expect(source).toContain(".edgeever-editor-scroll:has(.edgeever-x6-document) { display: flex; flex-direction: column; overflow: hidden; }"); diff --git a/apps/mobile/src/lib/mobile-diagram.ts b/apps/mobile/src/lib/mobile-diagram.ts index 64279877..2f206ac6 100644 --- a/apps/mobile/src/lib/mobile-diagram.ts +++ b/apps/mobile/src/lib/mobile-diagram.ts @@ -1,9 +1,13 @@ import { hasDiagramDocumentMarker, + hasTableDocumentMarker, markdownToDoc, parseDiagramDocument, + parseTableDocument, resolveMemoContentDoc, stripDiagramDocumentMarker, + stripTableDocumentMarker, + tableFallbackMarkdown, type TiptapDoc, type DiagramKind, } from "@edgeever/shared"; @@ -14,6 +18,9 @@ export const getMobileVisualDiagramKind = (contentMarkdown: string): DiagramKind export const hasMobileVisualDiagram = (contentMarkdown: string) => hasDiagramDocumentMarker(contentMarkdown); +export const hasMobileStructuredTable = (contentMarkdown: string) => + hasTableDocumentMarker(contentMarkdown); + /** Viewer TipTap payload for a visual-diagram envelope. Valid IR is drawn by read-only X6, so this returns an empty doc instead of a hidden Mermaid projection. Invalid envelopes keep the stripped Mermaid fence as degraded content. */ export const resolveMobileMemoViewerContent = ( contentJson: TiptapDoc | null | undefined, @@ -25,5 +32,10 @@ export const resolveMobileMemoViewerContent = ( if (hasDiagramDocumentMarker(contentMarkdown)) { return markdownToDoc(stripDiagramDocumentMarker(contentMarkdown)); } + const table = parseTableDocument(contentMarkdown); + if (table) return markdownToDoc(tableFallbackMarkdown(table)); + if (hasTableDocumentMarker(contentMarkdown)) { + return markdownToDoc(stripTableDocumentMarker(contentMarkdown)); + } return resolveMemoContentDoc(contentJson, contentMarkdown); }; diff --git a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx index d803e3ec..12f7c30c 100644 --- a/apps/mobile/src/screens/WorkspaceMemoDetail.tsx +++ b/apps/mobile/src/screens/WorkspaceMemoDetail.tsx @@ -18,7 +18,7 @@ import { MobileAiAssistantModal } from "../components/MobileAiAssistantModal"; import { MobileResourceActions } from "../components/MobileResourceActions"; import { SAFE_DOM_WEBVIEW_PROPS } from "../lib/mobile-dom"; import { getNextMobileNoteSearchIndex } from "../lib/mobile-note-search"; -import { hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram"; +import { hasMobileStructuredTable, hasMobileVisualDiagram, resolveMobileMemoViewerContent } from "../lib/mobile-diagram"; import { safeDomCall } from "../lib/safe-dom-call"; import { getMobileImageTarget, @@ -524,6 +524,11 @@ export const MemoDetailModal = ({ () => (memo ? hasMobileVisualDiagram(memo.contentMarkdown) : false), [memo] ); + const isStructuredTable = useMemo( + () => (memo ? hasMobileStructuredTable(memo.contentMarkdown) : false), + [memo] + ); + const locksRichTextEdit = isVisualDiagram || isStructuredTable; const visualDiagramJson = useMemo(() => { const diagram = memo ? parseDiagramDocument(memo.contentMarkdown) : null; return diagram ? JSON.stringify(diagram) : undefined; @@ -1113,7 +1118,7 @@ export const MemoDetailModal = ({ ) : ( - {!memo.isDeleted && !isVisualDiagram ? ( + {!memo.isDeleted && !locksRichTextEdit ? ( { + onDoublePress={isEditing || locksRichTextEdit ? undefined : async () => { beginEditorStartup(); onRichEdit(memo, "body"); }} @@ -1269,7 +1274,7 @@ export const MemoDetailModal = ({ 笔记加载失败 )} - {memo && !memo.isDeleted && !isVisualDiagram && !isEditing ? ( + {memo && !memo.isDeleted && !locksRichTextEdit && !isEditing ? ( {resolvedLocale !== "zh-CN" ? "Note actions" : "笔记操作"} - {!memo.isDeleted && !isVisualDiagram ? ( + {!memo.isDeleted && !locksRichTextEdit ? ( } label={resolvedLocale !== "zh-CN" ? "AI note assistant" : "AI 笔记助手"} diff --git a/apps/web/src/components/MemoCard.tsx b/apps/web/src/components/MemoCard.tsx index 56d7f2e1..b37d5565 100644 --- a/apps/web/src/components/MemoCard.tsx +++ b/apps/web/src/components/MemoCard.tsx @@ -1,7 +1,7 @@ import { useRef, useState, useEffect, type DragEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; -import { GitBranch, Network, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react"; +import { GitBranch, Network, Table, Workflow, Star, Check, MoreHorizontal, RotateCcw, Trash2 } from "lucide-react"; import { getMemoListTimestamp, type MemoSummary } from "@edgeever/shared"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; @@ -91,6 +91,7 @@ export const MemoCard = ({ const diagramLabel = memo.diagramKind ? t(`diagram.${memo.diagramKind === "mind-map" ? "mindMap" : memo.diagramKind}`) : null; + const tableLabel = !diagramLabel && memo.structuredTable ? t("structuredTable.name") : null; const DiagramIcon = memo.diagramKind === "mind-map" ? GitBranch : memo.diagramKind === "architecture" ? Network : Workflow; const listTimestamp = getMemoListTimestamp(memo, sortMode); const listTimestampLabel = formatMemoPreviewDate( @@ -413,6 +414,18 @@ export const MemoCard = ({
{memo.diagramPreview.labels.join(" · ")}
) : null} + ) : tableLabel ? ( +
+
+ + {tableLabel} + + {memo.tablePreview ? {t("structuredTable.listCounts", { records: memo.tablePreview.recordCount, fields: memo.tablePreview.fieldCount })} : null} + + {listDensity !== "compact" && memo.tablePreview?.fieldNames.length ? ( +
{memo.tablePreview.fieldNames.join(" · ")}
+ ) : null} + ) : (
); -const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: DiagramKind) => void }) => { +const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: NoteCreateKind) => void }) => { const { t } = useTranslation(); return ( @@ -321,6 +322,13 @@ const CreateMemoTypeItems = ({ onCreateMemo }: { onCreateMemo: (kind?: DiagramKi {t("diagram.architecture")} + onCreateMemo("table")}> +
+ {t("structuredTable.name")} + + Beta + + ); }; @@ -505,7 +513,7 @@ export const NotebookPane = ({ onOpenTrash: () => void; onEmptyTrash: () => void; onOpenSettings: () => void; - onCreateMemo: (kind?: DiagramKind) => void; + onCreateMemo: (kind?: NoteCreateKind) => void; canCreateMemo: boolean; isCreatingMemo: boolean; syncSummary: SyncQueueSummary; diff --git a/apps/web/src/components/TableEditorPane.tsx b/apps/web/src/components/TableEditorPane.tsx new file mode 100644 index 00000000..7f5499cf --- /dev/null +++ b/apps/web/src/components/TableEditorPane.tsx @@ -0,0 +1,644 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; +import { ChevronLeft, Download, Plus, Table, Trash2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + addTableField, + addTableRecord, + applyTableView, + createTableId, + markdownToDoc, + parseTableDocument, + removeTableField, + removeTableRecord, + replaceTableView, + serializeTableDocument, + TABLE_FIELD_LIMIT, + TABLE_FIELD_TYPES, + TABLE_FILTER_OPERATORS, + TABLE_RECORD_LIMIT, + tableDocumentToCsv, + tableFallbackMarkdown, + updateTableCell, + updateTableField, + type MemoDetail, + type TableCellValue, + type TableDocument, + type TableField, + type TableFieldType, + type TableFilter, + type TableFilterOperator, + type TableRecord, +} from "@edgeever/shared"; +import { MemoTitleInput } from "@/components/MemoTitleInput"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { api } from "@/lib/api"; +import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers"; +import { createLocalEditSession } from "@/components/editor/editor-pane-helpers"; +import { isLocalMemoId } from "@/lib/local-mirror"; +import { isBrowserOffline } from "@/lib/network-status"; +import type { EdgeEverRepository } from "@/lib/repository"; +import type { MemoEditSession } from "@edgeever/shared"; + +type TableEditorPaneProps = { + memo: MemoDetail; + repository: EdgeEverRepository; + readOnly: boolean; + onBackToList: () => void; + onSaved: (memo: MemoDetail) => Promise; +}; + +type EditingCell = { recordId: string; fieldId: string }; + +const controlClassName = "h-8 rounded-md border border-slate-200 bg-card px-2 text-xs text-slate-700 outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 disabled:cursor-not-allowed disabled:opacity-50"; + +const snapshotOf = (title: string, document: TableDocument) => JSON.stringify({ title, document }); + +const optionText = (field: TableField) => (field.options ?? []).join(", "); + +const parseOptionText = (value: string) => value.split(/[,,]/).map((item) => item.trim()).filter(Boolean); + +const displayCell = (field: TableField, value: TableCellValue) => { + if (field.type === "checkbox") return value === true ? "true" : "false"; + if (value === null || value === undefined || value === "") return ""; + return String(value); +}; + +const FieldHeader = ({ + document, + field, + readOnly, + onChange, +}: { + document: TableDocument; + field: TableField; + readOnly: boolean; + onChange: (document: TableDocument) => void; +}) => { + const { t } = useTranslation(); + const [name, setName] = useState(field.name); + const [options, setOptions] = useState(optionText(field)); + useEffect(() => setName(field.name), [field.name]); + useEffect(() => setOptions(optionText(field)), [field.id, field.options]); + const commitName = () => { + const next = name.trim(); + if (next && next !== field.name) onChange(updateTableField(document, field.id, { name: next })); + else setName(field.name); + }; + const commitOptions = () => onChange(updateTableField(document, field.id, { options: parseOptionText(options) })); + + return ( + + + + + + + + {field.type === "select" ? ( + + ) : null} + onChange(removeTableField(document, field.id))} + > + + {t("structuredTable.deleteField")} + + + + ); +}; + +const RecordCell = ({ + document, + field, + record, + active, + readOnly, + onEdit, + onCommit, + onDraft, + onChange, +}: { + document: TableDocument; + field: TableField; + record: TableRecord; + active: boolean; + readOnly: boolean; + onEdit: (cell: EditingCell | null) => void; + onCommit: (value: string) => void; + onDraft: (value: string) => void; + onChange: (document: TableDocument) => 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 === "checkbox") { + return ( + onChange(updateTableCell(document, record.id, field.id, checked === true))} + /> + ); + } + if (!active) { + return ( + + ); + } + if (field.type === "select") { + const options = field.options ?? []; + const choices = draft && !options.includes(draft) ? [draft, ...options] : options; + return ( + + ); + } + return ( + { + setDraft(event.target.value); + onDraft(event.target.value); + }} + onBlur={() => onCommit(draft)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + onCommit(draft); + } + if (event.key === "Escape") onEdit(null); + }} + /> + ); +}; + +export const TableEditorPane = ({ + memo, + repository, + readOnly, + onBackToList, + onSaved, +}: TableEditorPaneProps) => { + const { t } = useTranslation(); + const parsed = useMemo(() => parseTableDocument(memo.contentMarkdown), [memo.contentMarkdown]); + const [title, setTitle] = useState(memo.title ?? ""); + const [document, setDocument] = useState(parsed); + const [editing, setEditing] = useState(null); + const draftRef = useRef(""); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saveFailed, setSaveFailed] = 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 savedSnapshotRef = useRef(parsed ? snapshotOf(memo.title ?? "", parsed) : ""); + const saveRef = useRef<() => void>(() => undefined); + memoRef.current = memo; + titleRef.current = title; + documentRef.current = document; + editingRef.current = editing; + + useEffect(() => { + editSessionRef.current = null; + setEditSessionReady(false); + setSaveError(null); + setSaveFailed(false); + if (readOnly || !parsed) return; + if (isLocalMemoId(memo.id) || isBrowserOffline() || window.edgeeverDesktop?.isAvailable) { + editSessionRef.current = createLocalEditSession(memo); + setEditSessionReady(true); + return; + } + let cancelled = false; + void api.createMemoEditSession(memo.id).then(({ editSession }) => { + if (!cancelled) { + editSessionRef.current = editSession; + setEditSessionReady(true); + } + }).catch(() => { + if (!cancelled) setSaveError(t("structuredTable.editSessionError")); + }); + return () => { cancelled = true; }; + }, [memo.id, memo.contentHash, memo.revision, parsed, readOnly, t]); + + const changeDocument = (next: TableDocument) => { + setDocument(next); + documentRef.current = next; + setDirty(snapshotOf(titleRef.current, next) !== savedSnapshotRef.current); + setSaveFailed(false); + }; + + const commitEditing = (value: string) => { + const current = documentRef.current; + const cell = editingRef.current; + if (!current || !cell) return; + changeDocument(updateTableCell(current, cell.recordId, cell.fieldId, value)); + setEditing(null); + editingRef.current = null; + }; + + const save = async () => { + const currentMemo = memoRef.current; + const currentDocument = documentRef.current; + const editSession = editSessionRef.current; + if (!currentDocument || !editSession || readOnly || saving) return false; + if (editingRef.current) commitEditing(draftRef.current); + const nextDocument = documentRef.current; + if (!nextDocument) return false; + const nextSnapshot = snapshotOf(titleRef.current, nextDocument); + if (savedSnapshotRef.current === nextSnapshot) { + setDirty(false); + return true; + } + setSaving(true); + setSaveError(null); + setSaveFailed(false); + try { + const result = await repository.updateMemo(currentMemo, { + expectedRevision: currentMemo.revision, + expectedContentHash: currentMemo.contentHash, + editSessionId: editSession.id, + title: titleRef.current, + contentJson: markdownToDoc(tableFallbackMarkdown(nextDocument)), + contentMarkdown: serializeTableDocument(nextDocument), + tags: currentMemo.tags, + }); + memoRef.current = result.memo; + savedSnapshotRef.current = nextSnapshot; + const hasNewChanges = snapshotOf(titleRef.current, documentRef.current ?? nextDocument) !== nextSnapshot; + setDirty(hasNewChanges); + if (!hasNewChanges) await onSaved(result.memo); + return true; + } catch (error) { + setSaveFailed(true); + setSaveError(error instanceof Error ? error.message : t("structuredTable.saveError")); + return false; + } finally { + setSaving(false); + } + }; + saveRef.current = () => { void save(); }; + + useEffect(() => { + if (readOnly || !dirty || saving || !editSessionReady || saveFailed || editing) return; + const timer = window.setTimeout(() => saveRef.current(), EDITOR_LOCAL_SAVE_DELAY_MS); + return () => window.clearTimeout(timer); + }, [dirty, editSessionReady, editing, readOnly, saveFailed, saving]); + + const visibleRecords = useMemo(() => (document ? applyTableView(document) : []), [document]); + const columns = useMemo[]>(() => { + if (!document) return []; + return [ + ...document.fields.map((field): ColumnDef => ({ + id: field.id, + header: () => , + cell: ({ row }) => ( + { + editingRef.current = cell; + if (cell) draftRef.current = displayCell(field, row.original.cells[field.id] ?? null); + setEditing(cell); + }} + onCommit={commitEditing} + onDraft={(value) => { draftRef.current = value; }} + onChange={changeDocument} + /> + ), + })), + { + id: "record-actions", + header: () => null, + cell: ({ row }) => ( + + + + + {t("structuredTable.deleteRecord")} + + ), + }, + ]; + }, [document, editing, readOnly, t]); + + const table = useReactTable({ data: visibleRecords, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id }); + + if (!document) { + return ( +
+
+ +
+

{t("structuredTable.unreadable")}

+
+ ); + } + + const filtered = document.view.filters.length > 0; + const countLabel = filtered + ? t("structuredTable.recordCountFiltered", { count: visibleRecords.length, total: document.records.length }) + : t("structuredTable.recordCount", { count: document.records.length }); + const saveLabel = saveError ? saveError : saving ? t("structuredTable.saving") : dirty ? t("structuredTable.unsaved") : editSessionReady ? t("structuredTable.saved") : ""; + const fieldLimitReached = document.fields.length >= TABLE_FIELD_LIMIT; + const recordLimitReached = document.records.length >= TABLE_RECORD_LIMIT; + + const exportCsv = () => { + const blob = new Blob([tableDocumentToCsv(document, visibleRecords)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = window.document.createElement("a"); + const fileName = (title.trim() || t("structuredTable.name")).replace(/[\\/:*?"<>|]/g, "-").slice(0, 80); + link.href = url; + link.download = `${fileName}.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const setFilter = (index: number, filter: TableFilter) => { + const filters = document.view.filters.map((item, itemIndex) => itemIndex === index ? filter : item); + changeDocument(replaceTableView(document, { ...document.view, filters })); + }; + + return ( +
+
+ + + + + {t("structuredTable.back")} + +
+
+ { + setTitle(value); + titleRef.current = value; + if (documentRef.current) setDirty(snapshotOf(value, documentRef.current) !== savedSnapshotRef.current); + }} + /> +
+ {countLabel} + {saveLabel ? {saveLabel} : null} + + + + + + {fieldLimitReached ? t("structuredTable.fieldLimit") : t("structuredTable.addField")} + + + + + + {recordLimitReached ? t("structuredTable.recordLimit") : t("structuredTable.addRecord")} + + +
+ {t("structuredTable.filter")} + {document.view.filters.map((filter, index) => { + const operatorNeedsValue = filter.operator === "contains" || filter.operator === "eq"; + return ( +
+ + + {operatorNeedsValue ? ( + setFilter(index, { ...filter, value: event.target.value })} + /> + ) : null} + +
+ ); + })} + + +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ {visibleRecords.length === 0 ? ( +

{filtered ? t("structuredTable.noMatches") : t("structuredTable.empty")}

+ ) : null} +
+
+ ); +}; + +export default TableEditorPane; diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 58c680e9..46f2dc2b 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -46,7 +46,7 @@ import { } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status"; -import { createDefaultDiagramDocument, diagramFallbackMarkdown, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; +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 { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, @@ -139,6 +139,7 @@ import { findMatchingMemoResource } from "@/lib/staged-resource-repair"; const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane }))); const DiagramEditorPane = lazy(() => import("./DiagramEditorPane")); +const TableEditorPane = lazy(() => import("./TableEditorPane")); const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane }))); const SettingsPane = lazy(() => import("./SettingsPane").then((module) => ({ default: module.SettingsPane }))); const PluginMarketplacePane = lazy(() => import("./PluginMarketplacePane").then((module) => ({ default: module.PluginMarketplacePane }))); @@ -1230,7 +1231,7 @@ export const WorkspaceApp = ({ const revealCreatedMemo = (memo: MemoDetail) => { const targetNotebookId = memo.notebookId; - const isDiagram = Boolean(parseDiagramDocument(memo.contentMarkdown)); + const isStructuredNote = Boolean(parseDiagramDocument(memo.contentMarkdown) || parseTableDocument(memo.contentMarkdown)); setMemoView("notebook"); setSearch(""); @@ -1252,11 +1253,11 @@ export const WorkspaceApp = ({ navigateWorkspaceHome(); setRightView("editor"); pendingCreatedMemoIdRef.current = memo.id; - setCreatedMemoEditId(isDiagram ? null : memo.id); + setCreatedMemoEditId(isStructuredNote ? null : memo.id); setSelectedMemoId(memo.id); setActivePane("editor"); - if (!isDesktopViewport() && !isDiagram) { + if (!isDesktopViewport() && !isStructuredNote) { openStandaloneMobileEditor(memo.id); } }; @@ -1618,6 +1619,7 @@ export const WorkspaceApp = ({ : null; const selectedMemo = memoQuery.data?.memo ?? cachedSelectedMemo; const selectedDiagram = parseDiagramDocument(selectedMemo?.contentMarkdown); + const selectedTable = parseTableDocument(selectedMemo?.contentMarkdown); const desktopNotebookSidebarCollapsed = Boolean(isDesktop && notebookSidebarCollapsed); const desktopFocusModeActive = Boolean( isDesktop && desktopFocusMode && rightView === "editor" && selectedMemo && !memoSelectionModeActive @@ -1875,7 +1877,7 @@ export const WorkspaceApp = ({ for (const payload of pending) void handleImportWeChatChat(payload); }, [handleImportWeChatChat]); - const handleCreateMemo = (kind?: DiagramKind) => { + const handleCreateMemo = (kind?: NoteCreateKind) => { const targetNotebookId = createMemoNotebookId; if (!targetNotebookId || memoView === "trash") { @@ -1892,6 +1894,25 @@ export const WorkspaceApp = ({ setTemplatesOpen(false); setMobileBottomNavActive("home"); creatingMemoSelectionRef.current = true; + if (kind === "table") { + const table = createDefaultTableDocument({ + name: t("structuredTable.defaultFields.name"), + status: t("structuredTable.defaultFields.status"), + date: t("structuredTable.defaultFields.date"), + sample: t("structuredTable.defaultFields.sample"), + notStarted: t("structuredTable.defaultFields.notStarted"), + inProgress: t("structuredTable.defaultFields.inProgress"), + done: t("structuredTable.defaultFields.done"), + }); + createMemoMutation.mutate({ + notebookId: targetNotebookId, + title: t("structuredTable.name"), + contentJson: markdownToDoc(tableFallbackMarkdown(table)), + contentMarkdown: serializeTableDocument(table), + tags: [], + }); + return; + } const diagram = kind ? createDefaultDiagramDocument(kind) : null; createMemoMutation.mutate({ notebookId: targetNotebookId, @@ -3333,6 +3354,26 @@ export const WorkspaceApp = ({ onOpenExecutionCenter={handleOpenExecutionCenter} /> + ) : selectedMemo && selectedTable ? ( + { + clearPendingCreatedMemo(); + setActivePane("memos"); + }} + onSaved={async (memo) => { + await putLocalMemo(localDataScope, memo); + cacheMemoDetail(queryClient, memo, memoView); + updateMemoSummaryInLists(queryClient, memoToSummary(memo)); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), + queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), + ]); + }} + /> ) : ( void; + onCreateMemo: (kind?: NoteCreateKind) => void; onHome: () => void; onOpenSettings: () => void; }) => { @@ -89,6 +89,13 @@ export const MobileBottomNav = ({ onCreateMemo("mind-map")}>{t("diagram.mindMap")} onCreateMemo("flowchart")}>{t("diagram.flowchart")} onCreateMemo("architecture")}>{t("diagram.architecture")} + onCreateMemo("table")}> + + {t("structuredTable.name")} + + Beta + + } label={t("nav.mine")} onClick={onOpenSettings} /> diff --git a/apps/web/src/lib/local-mirror.ts b/apps/web/src/lib/local-mirror.ts index 2c6c6a8b..0e78dff8 100644 --- a/apps/web/src/lib/local-mirror.ts +++ b/apps/web/src/lib/local-mirror.ts @@ -7,6 +7,7 @@ import { markdownToDoc, mergeMemoDocs, getDiagramSummary, + getTableSummary, getNotebookDescendantIds, resolveMemoContentDoc, resolveMergedMemoTitle, @@ -77,6 +78,7 @@ const toSummary = (memo: MemoDetail): MemoSummary => ({ title: memo.title, excerpt: memo.excerpt, ...getDiagramSummary(memo.contentMarkdown), + ...getTableSummary(memo.contentMarkdown), tags: memo.tags, isPinned: memo.isPinned, isArchived: memo.isArchived, diff --git a/apps/web/src/lib/public-share-body.test.mjs b/apps/web/src/lib/public-share-body.test.mjs index b1d35333..c8ae1265 100644 --- a/apps/web/src/lib/public-share-body.test.mjs +++ b/apps/web/src/lib/public-share-body.test.mjs @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { createDefaultDiagramDocument, + createDefaultTableDocument, diagramFallbackMarkdown, markdownToDoc, serializeDiagramDocument, + serializeTableDocument, + tableFallbackMarkdown, } from "@edgeever/shared"; import { resolvePublicShareBody } from "./public-share-body"; @@ -37,6 +40,26 @@ describe("public share body", () => { } }); + test("renders a structured table note as the readable markdown table", () => { + const document = createDefaultTableDocument(); + const body = resolvePublicShareBody(shareOf(serializeTableDocument(document)), "share-token"); + expect(body.type).toBe("rich-text"); + if (body.type === "rich-text") { + expect(JSON.stringify(body.content)).not.toContain("edgeever-table-v1"); + expect(body.content.content?.some((node) => node.type === "table")).toBe(true); + } + }); + + test("falls back to the readable table when the structured-table envelope cannot be parsed", () => { + const markdown = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n`; + const body = resolvePublicShareBody(shareOf(markdown), "share-token"); + expect(body.type).toBe("rich-text"); + if (body.type === "rich-text") { + expect(JSON.stringify(body.content)).not.toContain("edgeever-table-v1"); + expect(body.content.content?.some((node) => node.type === "table")).toBe(true); + } + }); + test("falls back to the Mermaid fence when the visual-diagram IR cannot be parsed", () => { const markdown = `${diagramFallbackMarkdown(createDefaultDiagramDocument("mind-map"))}\n\n`; const body = resolvePublicShareBody(shareOf(markdown), "share-token"); diff --git a/apps/web/src/lib/public-share-body.ts b/apps/web/src/lib/public-share-body.ts index 5c8335f6..399a97f9 100644 --- a/apps/web/src/lib/public-share-body.ts +++ b/apps/web/src/lib/public-share-body.ts @@ -1,10 +1,14 @@ import { hasDiagramDocumentMarker, + hasTableDocumentMarker, markdownToDoc, parseDiagramDocument, + parseTableDocument, resolveMemoContentDoc, rewriteMemoResourcesForShare, stripDiagramDocumentMarker, + stripTableDocumentMarker, + tableFallbackMarkdown, type DiagramDocument, type PublicMemoShare, type TiptapDoc, @@ -18,8 +22,12 @@ export type PublicShareBody = export const resolvePublicShareBody = (share: PublicMemoShare, token: string): PublicShareBody => { const diagram = parseDiagramDocument(share.contentMarkdown); if (diagram) return { type: "diagram", diagram }; - const richText = hasDiagramDocumentMarker(share.contentMarkdown) - ? markdownToDoc(stripDiagramDocumentMarker(share.contentMarkdown)) + const table = parseTableDocument(share.contentMarkdown); + if (table) { + return { type: "rich-text", content: rewriteMemoResourcesForShare(markdownToDoc(tableFallbackMarkdown(table)), token, share.memoShareTokens) }; + } + const richText = hasDiagramDocumentMarker(share.contentMarkdown) || hasTableDocumentMarker(share.contentMarkdown) + ? markdownToDoc(hasTableDocumentMarker(share.contentMarkdown) ? stripTableDocumentMarker(share.contentMarkdown) : stripDiagramDocumentMarker(share.contentMarkdown)) : resolveMemoContentDoc(share.contentJson, share.contentMarkdown); return { type: "rich-text", diff --git a/apps/web/src/lib/workspace-memo-cache.ts b/apps/web/src/lib/workspace-memo-cache.ts index f37ec192..8d2480bc 100644 --- a/apps/web/src/lib/workspace-memo-cache.ts +++ b/apps/web/src/lib/workspace-memo-cache.ts @@ -3,6 +3,7 @@ import { createExcerpt, docToText, getDiagramSummary, + getTableSummary, resolveMemoContentDoc, type MemoDetail, type MemoSummary, @@ -23,6 +24,7 @@ export const memoToSummary = (memo: MemoDetail): MemoSummary => ({ title: memo.title, excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))), ...getDiagramSummary(memo.contentMarkdown), + ...getTableSummary(memo.contentMarkdown), tags: memo.tags, isPinned: memo.isPinned, isArchived: memo.isArchived, diff --git a/bun.lock b/bun.lock index 952ac78f..b717aff4 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "@radix-ui/react-toggle-group": "^1.1.19", "@radix-ui/react-tooltip": "^1.2.16", "@tanstack/react-query": "latest", + "@tanstack/react-table": "8", "@tanstack/react-virtual": "^3.14.13", "@tiptap/core": "3.31.0", "@tiptap/extension-collaboration": "3.31.0", @@ -102,7 +103,7 @@ }, "apps/desktop": { "name": "@edgeever/desktop", - "version": "1.79.0", + "version": "1.82.0", "dependencies": { "@edgeever/public-network": "workspace:*", "croner": "^10.0.1", @@ -1362,8 +1363,12 @@ "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.101.2", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.101.2" }, "peerDependencies": { "@tanstack/react-query": "^5.101.2", "react": "^18 || ^19" } }, "sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.13", "", { "dependencies": { "@tanstack/virtual-core": "3.17.11" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.11", "", {}, "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g=="], "@tiptap/core": ["@tiptap/core@3.31.0", "", { "peerDependencies": { "@tiptap/pm": "3.31.0" } }, "sha512-SY3yE2ACharFvk0BKgotV6SbwV8R4tN5fGtWOM6deY4JNF3v6CXOD78RFwjs2HSohGWBRyJVPDvrX9nfi8Nsxg=="], diff --git a/package.json b/package.json index 4bcec774..6b955b19 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@radix-ui/react-toggle-group": "^1.1.19", "@radix-ui/react-tooltip": "^1.2.16", "@tanstack/react-query": "latest", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.14.13", "@tiptap/core": "3.31.0", "@tiptap/extension-collaboration": "3.31.0", diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index f26c0d78..28389625 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -275,6 +275,66 @@ export const enUS = { createDisabled: "You cannot create a note in this view", creating: "Creating", }, + structuredTable: { + name: "Database", + title: "Database title", + addField: "Add field", + addRecord: "Add record", + deleteField: "Delete field", + deleteRecord: "Delete this record", + fieldName: "Field name", + fieldType: "Field type", + selectOptions: "Options", + selectOptionsHint: "Separate with commas", + filter: "Filter", + addFilter: "Add filter", + clearFilters: "Clear filters", + filterValue: "Filter value", + sort: "Sort", + noSort: "No sort", + ascending: "Ascending", + descending: "Descending", + empty: "No records yet", + noMatches: "No records match the filters", + exportCsv: "Export CSV", + recordCount: "{{count}} records", + recordCountFiltered: "{{count}} / {{total}} records", + listCounts: "{{records}} records · {{fields}} fields", + newField: "Field {{index}}", + fieldLimit: "Up to 40 fields", + recordLimit: "Up to 2,000 records", + unreadable: "This database could not be read", + saveError: "Could not save the database", + editSessionError: "Could not start a safe editing session. Try again later.", + saving: "Saving", + saved: "Saved", + unsaved: "Unsaved changes", + back: "Back to note list", + editField: "Edit field {{name}}", + types: { + text: "Text", + number: "Number", + checkbox: "Checkbox", + date: "Date", + select: "Select", + url: "Link", + }, + operators: { + contains: "contains", + eq: "equals", + empty: "is empty", + notEmpty: "is not empty", + }, + defaultFields: { + name: "Name", + status: "Status", + date: "Date", + sample: "Sample record", + notStarted: "Not started", + inProgress: "In progress", + done: "Done", + }, + }, diagram: { normalNote: "Regular note", mindMap: "Mind map", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 7f3be65e..6dcd43dd 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -275,6 +275,66 @@ export const ja = { createDisabled: "この表示ではノートを作成できません", creating: "作成中", }, + structuredTable: { + name: "データベース", + title: "データベースのタイトル", + addField: "フィールドを追加", + addRecord: "レコードを追加", + deleteField: "フィールドを削除", + deleteRecord: "このレコードを削除", + fieldName: "フィールド名", + fieldType: "フィールドの種類", + selectOptions: "選択肢", + selectOptionsHint: "カンマで区切る", + filter: "フィルタ", + addFilter: "フィルタを追加", + clearFilters: "フィルタをクリア", + filterValue: "フィルタ値", + sort: "並べ替え", + noSort: "並べ替えなし", + ascending: "昇順", + descending: "降順", + empty: "レコードはまだありません", + noMatches: "条件に一致するレコードがありません", + exportCsv: "CSV を書き出す", + recordCount: "{{count}} 件", + recordCountFiltered: "{{count}} / {{total}} 件", + listCounts: "{{records}} 件 · {{fields}} フィールド", + newField: "フィールド {{index}}", + fieldLimit: "フィールドは 40 個まで", + recordLimit: "レコードは 2000 件まで", + unreadable: "このデータベースを読み取れません", + saveError: "データベースを保存できませんでした", + editSessionError: "安全な編集セッションを開始できません。しばらくしてから再度お試しください。", + saving: "保存中", + saved: "保存しました", + unsaved: "未保存の変更", + back: "ノート一覧に戻る", + editField: "フィールド {{name}} を編集", + types: { + text: "テキスト", + number: "数値", + checkbox: "チェック", + date: "日付", + select: "選択", + url: "リンク", + }, + operators: { + contains: "含む", + eq: "等しい", + empty: "空", + notEmpty: "空ではない", + }, + defaultFields: { + name: "名前", + status: "状態", + date: "日付", + sample: "サンプル", + notStarted: "未着手", + inProgress: "進行中", + done: "完了", + }, + }, diagram: { normalNote: "通常ノート", mindMap: "マインドマップ", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 78cc5253..bd1e7a02 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -275,6 +275,66 @@ export const zhCN = { createDisabled: "当前视图不可新建笔记", creating: "正在创建", }, + structuredTable: { + name: "多维表格", + title: "表格标题", + addField: "添加字段", + addRecord: "添加记录", + deleteField: "删除字段", + deleteRecord: "删除这条记录", + fieldName: "字段名", + fieldType: "字段类型", + selectOptions: "选项", + selectOptionsHint: "用逗号分隔", + filter: "筛选", + addFilter: "添加筛选", + clearFilters: "清除筛选", + filterValue: "筛选值", + sort: "排序", + noSort: "不排序", + ascending: "升序", + descending: "降序", + empty: "还没有记录", + noMatches: "没有符合筛选的记录", + exportCsv: "导出 CSV", + recordCount: "{{count}} 条记录", + recordCountFiltered: "{{count}} / {{total}} 条记录", + listCounts: "{{records}} 条记录 · {{fields}} 个字段", + newField: "字段 {{index}}", + fieldLimit: "最多 40 个字段", + recordLimit: "最多 2000 条记录", + unreadable: "这份多维表格无法读取", + saveError: "表格保存失败", + editSessionError: "暂时无法建立安全编辑会话,请稍后重试。", + saving: "保存中", + saved: "已保存", + unsaved: "有未保存的修改", + back: "返回笔记列表", + editField: "编辑字段 {{name}}", + types: { + text: "文本", + number: "数字", + checkbox: "勾选", + date: "日期", + select: "单选", + url: "链接", + }, + operators: { + contains: "包含", + eq: "等于", + empty: "为空", + notEmpty: "不为空", + }, + defaultFields: { + name: "名称", + status: "状态", + date: "日期", + sample: "示例记录", + notStarted: "未开始", + inProgress: "进行中", + done: "完成", + }, + }, diagram: { normalNote: "普通笔记", mindMap: "思维导图", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 953cc523..528c986e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -34,6 +34,7 @@ export * from "./desktop-rpc"; export * from "./details"; export * from "./deployment-metadata"; export * from "./diagram"; +export * from "./table"; export * from "./diagram-palette"; export * from "./diagram-flowchart-style"; export * from "./diagram-architecture-style"; diff --git a/packages/shared/src/table.test.mjs b/packages/shared/src/table.test.mjs new file mode 100644 index 00000000..4622db79 --- /dev/null +++ b/packages/shared/src/table.test.mjs @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { markdownToDoc } from "./content.ts"; +import { + addTableField, + addTableRecord, + applyTableView, + createDefaultTableDocument, + hasTableDocumentMarker, + parseTableDocument, + removeTableField, + replaceTableView, + serializeTableDocument, + stripTableDocumentMarker, + tableDocumentToCsv, + tableFallbackMarkdown, + updateTableCell, + updateTableField, + getTableSummary, +} from "./table.ts"; + +describe("structured table documents", () => { + test("round-trips fields, records, filters, and sort through the markdown envelope", () => { + const seeded = createDefaultTableDocument(); + const withView = replaceTableView(updateTableCell(seeded, "rec_sample", "fld_name", "阅读清单"), { + filters: [{ fieldId: "fld_status", operator: "eq", value: "未开始" }], + sort: { fieldId: "fld_name", direction: "asc" }, + }); + const markdown = serializeTableDocument(withView); + expect(hasTableDocumentMarker(markdown)).toBe(true); + expect(stripTableDocumentMarker(markdown)).toBe(tableFallbackMarkdown(withView)); + expect(parseTableDocument(markdown)).toEqual(withView); + expect(markdownToDoc(tableFallbackMarkdown(withView)).content?.some((node) => node.type === "table")).toBe(true); + expect(JSON.stringify(markdownToDoc(stripTableDocumentMarker(markdown)))).not.toContain("edgeever-table-v1"); + }); + + test("rejects a broken envelope without dropping the readable table", () => { + const markdown = `${tableFallbackMarkdown(createDefaultTableDocument())}\n\n`; + expect(parseTableDocument(markdown)).toBeNull(); + expect(hasTableDocumentMarker(markdown)).toBe(true); + expect(stripTableDocumentMarker(markdown)).toContain("| 名称 |"); + expect(getTableSummary(markdown)).toEqual({ structuredTable: false }); + }); + + test("filters, sorts, and keeps an ordinary markdown table untouched", () => { + let document = createDefaultTableDocument(); + document = addTableRecord(document, "rec_next"); + document = updateTableCell(document, "rec_next", "fld_name", "已完成的书"); + document = updateTableCell(document, "rec_next", "fld_status", "完成"); + document = updateTableCell(document, "rec_next", "fld_date", "2026-09-01"); + document = replaceTableView(document, { + filters: [{ fieldId: "fld_name", operator: "contains", value: "书" }], + sort: { fieldId: "fld_date", direction: "desc" }, + }); + expect(applyTableView(document).map((record) => record.id)).toEqual(["rec_next"]); + expect(parseTableDocument("| 名称 |\n| --- |\n| 普通表格 |")).toBeNull(); + }); + + test("coerces cells when a field type changes and refuses to remove the last field", () => { + const document = updateTableField(createDefaultTableDocument(), "fld_name", { type: "number" }); + expect(document.records[0]?.cells.fld_name).toBeNull(); + const onlyName = removeTableField(removeTableField(document, "fld_status"), "fld_date"); + expect(removeTableField(onlyName, "fld_name").fields.map((field) => field.id)).toEqual(["fld_name"]); + }); + + test("exports csv for the rows currently in view and escapes commas", () => { + const document = updateTableCell(createDefaultTableDocument(), "rec_sample", "fld_name", "甲,乙"); + expect(tableDocumentToCsv(document)).toContain('"甲,乙"'); + expect(tableDocumentToCsv(document).charCodeAt(0)).toBe(0xfeff); + }); + + test("summarizes a table note for the memo list", () => { + expect(getTableSummary(serializeTableDocument(createDefaultTableDocument()))).toEqual({ + structuredTable: true, + tablePreview: { fieldCount: 3, recordCount: 1, fieldNames: ["名称", "状态", "日期"] }, + }); + }); + + test("adds a field onto every existing record", () => { + const document = addTableField(createDefaultTableDocument(), { id: "fld_url", name: "链接", type: "url" }); + expect(document.records[0]?.cells.fld_url).toBe(""); + expect(document.fields.at(-1)?.name).toBe("链接"); + }); +}); diff --git a/packages/shared/src/table.ts b/packages/shared/src/table.ts new file mode 100644 index 00000000..b80793bb --- /dev/null +++ b/packages/shared/src/table.ts @@ -0,0 +1,368 @@ +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_FILTER_OPERATORS = ["contains", "eq", "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 TableField = { + id: string; + name: string; + type: TableFieldType; + options?: string[]; +}; + +export type TableRecord = { + id: string; + cells: Record; +}; + +export type TableFilter = { + fieldId: string; + operator: TableFilterOperator; + value?: string; +}; + +export type TableSort = { + fieldId: string; + direction: "asc" | "desc"; +}; + +export type TableView = { + filters: TableFilter[]; + sort: TableSort | null; +}; + +export type TableDocument = { + schemaVersion: typeof TABLE_SCHEMA_VERSION; + fields: TableField[]; + records: TableRecord[]; + view: TableView; +}; + +export type TableSeedLabels = { + name?: string; + status?: string; + date?: string; + sample?: string; + notStarted?: string; + inProgress?: string; + done?: string; +}; + +export type TableSummaryPreview = { + fieldCount: number; + recordCount: number; + fieldNames: string[]; +}; + +const TABLE_MARKER = "edgeever-table-v1"; +const TABLE_COMMENT = new RegExp(``); +const FIELD_NAME_LIMIT = 80; +const OPTION_LIMIT = 40; +const CELL_TEXT_LIMIT = 2000; + +const encodeBase64Url = (value: string) => Base64.encodeURI(value); +const decodeBase64Url = (value: string) => Base64.decode(value); + +export const createTableId = (prefix: "fld" | "rec") => { + const uuid = globalThis.crypto?.randomUUID?.(); + const suffix = uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; + return `${prefix}_${suffix}`; +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const cleanName = (value: unknown, fallback: string) => { + const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : ""; + return (name || fallback).slice(0, FIELD_NAME_LIMIT); +}; + +const cleanOptions = (value: unknown) => { + if (!Array.isArray(value)) return []; + const options: string[] = []; + for (const item of value) { + if (typeof item !== "string") continue; + const option = item.replace(/\s+/g, " ").trim().slice(0, FIELD_NAME_LIMIT); + if (!option || options.includes(option)) continue; + options.push(option); + if (options.length >= OPTION_LIMIT) break; + } + return options; +}; + +export const coerceTableCell = (field: TableField, value: unknown): TableCellValue => { + if (field.type === "checkbox") { + return value === true || value === "true" || value === 1 || value === "1"; + } + if (field.type === "number") { + if (value === null || value === undefined || value === "") return null; + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; + } + if (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); + +const parseField = (value: unknown): TableField | null => { + if (!isRecord(value) || typeof value.id !== "string" || !value.id || value.id.length > 80) return null; + if (!TABLE_FIELD_TYPES.includes(value.type as TableFieldType)) return null; + const type = value.type as TableFieldType; + const field: TableField = { id: value.id, name: cleanName(value.name, "字段"), type }; + if (type === "select") field.options = cleanOptions(value.options); + return field; +}; + +const parseView = (value: unknown, fieldIds: Set): TableView => { + if (!isRecord(value)) return { filters: [], sort: null }; + const filters: TableFilter[] = []; + if (Array.isArray(value.filters)) { + for (const item of value.filters) { + if (!isRecord(item) || typeof item.fieldId !== "string" || !fieldIds.has(item.fieldId)) continue; + if (!TABLE_FILTER_OPERATORS.includes(item.operator as TableFilterOperator)) continue; + const filter: TableFilter = { fieldId: item.fieldId, operator: item.operator as TableFilterOperator }; + if (typeof item.value === "string") filter.value = item.value.slice(0, CELL_TEXT_LIMIT); + filters.push(filter); + if (filters.length >= 8) break; + } + } + const sort = isRecord(value.sort) + && typeof value.sort.fieldId === "string" + && fieldIds.has(value.sort.fieldId) + && (value.sort.direction === "asc" || value.sort.direction === "desc") + ? { fieldId: value.sort.fieldId, direction: value.sort.direction as "asc" | "desc" } + : null; + return { filters, sort }; +}; + +export const parseTableDocument = (markdown: string | null | undefined): TableDocument | null => { + const encoded = markdown?.match(TABLE_COMMENT)?.[1]?.replace(/\s+/g, ""); + if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null; + try { + const value = JSON.parse(decodeBase64Url(encoded)) as Record; + if (value.schemaVersion !== TABLE_SCHEMA_VERSION || !Array.isArray(value.fields) || value.fields.length < 1) return null; + const fields: TableField[] = []; + for (const item of value.fields) { + const field = parseField(item); + if (!field || fields.some((current) => current.id === field.id)) return null; + fields.push(field); + } + if (!Array.isArray(value.records)) return null; + const fieldIds = new Set(fields.map((field) => field.id)); + const records: TableRecord[] = []; + const recordIds = new Set(); + for (const item of value.records) { + if (!isRecord(item) || typeof item.id !== "string" || !item.id || item.id.length > 80 || recordIds.has(item.id)) return null; + recordIds.add(item.id); + const rawCells = isRecord(item.cells) ? item.cells : {}; + const cells: Record = {}; + for (const field of fields) cells[field.id] = coerceTableCell(field, rawCells[field.id]); + records.push({ id: item.id, cells }); + } + return { schemaVersion: TABLE_SCHEMA_VERSION, fields, records, view: parseView(value.view, fieldIds) }; + } catch { + return null; + } +}; + +export const hasTableDocumentMarker = (markdown: string | null | undefined) => + Boolean(markdown?.match(TABLE_COMMENT)); + +export const stripTableDocumentMarker = (markdown: string | null | undefined) => + (markdown ?? "").replace(TABLE_COMMENT, "").trimEnd(); + +const markdownCell = (field: TableField, value: TableCellValue) => { + if (value === null || value === undefined || value === "") return ""; + const text = field.type === "checkbox" ? (value === true ? "true" : "false") : String(value); + return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|"); +}; + +export const tableFallbackMarkdown = (document: TableDocument) => { + const header = document.fields.map((field) => markdownCell({ ...field, type: "text" }, field.name) || "字段"); + const divider = header.map(() => "---"); + const rows = document.records.map((record) => document.fields.map((field) => markdownCell(field, record.cells[field.id] ?? null))); + return [header, divider, ...rows].map((row) => `| ${row.join(" | ")} |`).join("\n"); +}; + +export const serializeTableDocument = (document: TableDocument) => + `${tableFallbackMarkdown(document)}\n\n`; + +export const createDefaultTableDocument = (labels: TableSeedLabels = {}): TableDocument => { + const nameId = "fld_name"; + const statusId = "fld_status"; + const dateId = "fld_date"; + const notStarted = labels.notStarted ?? "未开始"; + const inProgress = labels.inProgress ?? "进行中"; + const done = labels.done ?? "完成"; + return { + schemaVersion: TABLE_SCHEMA_VERSION, + fields: [ + { id: nameId, name: labels.name ?? "名称", type: "text" }, + { id: statusId, name: labels.status ?? "状态", type: "select", options: [notStarted, inProgress, done] }, + { id: dateId, name: labels.date ?? "日期", type: "date" }, + ], + records: [{ + id: "rec_sample", + cells: { + [nameId]: labels.sample ?? "示例记录", + [statusId]: notStarted, + [dateId]: "", + }, + }], + view: { filters: [], sort: null }, + }; +}; + +const csvCell = (value: TableCellValue) => { + const text = value === null || value === undefined ? "" : value === true ? "true" : value === false ? "false" : String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text; +}; + +export const tableDocumentToCsv = (document: TableDocument, records: TableRecord[] = document.records) => { + const lines = [ + document.fields.map((field) => csvCell(field.name)), + ...records.map((record) => document.fields.map((field) => csvCell(record.cells[field.id] ?? null))), + ]; + return `\uFEFF${lines.map((line) => line.join(",")).join("\n")}`; +}; + +const cellSortText = (field: TableField, value: TableCellValue) => { + if (value === null || value === undefined || value === "") return null; + if (field.type === "number") return typeof value === "number" ? value : null; + if (field.type === "checkbox") return value === true ? 1 : 0; + return String(value); +}; + +const matchesFilter = (field: TableField, value: TableCellValue, filter: TableFilter) => { + const empty = field.type === "checkbox" ? value !== true : value === null || value === ""; + if (filter.operator === "empty") return empty; + if (filter.operator === "notEmpty") return !empty; + if (filter.operator === "eq") { + if (field.type === "number") { + const expected = Number(filter.value); + return Number.isFinite(expected) && value === expected; + } + if (field.type === "checkbox") return (filter.value === "true") === (value === true); + return String(value ?? "") === (filter.value ?? ""); + } + return String(value ?? "").toLocaleLowerCase().includes((filter.value ?? "").trim().toLocaleLowerCase()); +}; + +export const applyTableView = (document: TableDocument, records = document.records) => { + const fields = new Map(document.fields.map((field) => [field.id, field])); + const filtered = records.filter((record) => document.view.filters.every((filter) => { + const field = fields.get(filter.fieldId); + return field ? matchesFilter(field, record.cells[field.id] ?? null, filter) : true; + })); + const sort = document.view.sort ? fields.get(document.view.sort.fieldId) : undefined; + if (!sort || !document.view.sort) return filtered; + const direction = document.view.sort.direction === "desc" ? -1 : 1; + return [...filtered].sort((left, right) => { + const a = cellSortText(sort, left.cells[sort.id] ?? null); + const b = cellSortText(sort, right.cells[sort.id] ?? null); + if (a === null && b === null) return 0; + if (a === null) return 1; + if (b === null) return -1; + if (typeof a === "number" && typeof b === "number") return (a - b) * direction; + return String(a).localeCompare(String(b)) * direction; + }); +}; + +export const addTableField = (document: TableDocument, field: TableField): TableDocument => { + if (document.fields.length >= TABLE_FIELD_LIMIT || document.fields.some((item) => item.id === field.id)) return document; + const nextField = field.type === "select" ? { ...field, options: field.options ?? [] } : { ...field, options: undefined }; + return { + ...document, + fields: [...document.fields, nextField], + records: document.records.map((record) => ({ ...record, cells: { ...record.cells, [nextField.id]: emptyCell(nextField) } })), + }; +}; + +export const updateTableField = (document: TableDocument, fieldId: string, patch: Partial>): TableDocument => { + const current = document.fields.find((field) => field.id === fieldId); + if (!current) return document; + const next: TableField = { + ...current, + name: patch.name === undefined ? current.name : cleanName(patch.name, current.name), + type: patch.type ?? current.type, + }; + if (next.type === "select") next.options = cleanOptions(patch.options ?? current.options ?? []); + return { + ...document, + fields: document.fields.map((field) => field.id === fieldId ? next : field), + records: document.records.map((record) => ({ + ...record, + cells: { ...record.cells, [fieldId]: coerceTableCell(next, record.cells[fieldId]) }, + })), + }; +}; + +export const removeTableField = (document: TableDocument, fieldId: string): TableDocument => { + if (document.fields.length <= 1 || !document.fields.some((field) => field.id === fieldId)) return document; + const fields = document.fields.filter((field) => field.id !== fieldId); + const fieldIds = new Set(fields.map((field) => field.id)); + return { + ...document, + fields, + records: document.records.map((record) => ({ + ...record, + cells: Object.fromEntries(Object.entries(record.cells).filter(([id]) => fieldIds.has(id))), + })), + view: { + filters: document.view.filters.filter((filter) => filter.fieldId !== fieldId), + sort: document.view.sort?.fieldId === fieldId ? null : document.view.sort, + }, + }; +}; + +export const addTableRecord = (document: TableDocument, id = createTableId("rec")): TableDocument => { + if (document.records.length >= TABLE_RECORD_LIMIT || document.records.some((record) => record.id === id)) return document; + const cells: Record = {}; + for (const field of document.fields) cells[field.id] = emptyCell(field); + return { ...document, records: [...document.records, { id, cells }] }; +}; + +export const updateTableCell = (document: TableDocument, recordId: string, fieldId: string, value: unknown): TableDocument => { + const field = document.fields.find((item) => item.id === fieldId); + if (!field) return document; + return { + ...document, + records: document.records.map((record) => record.id === recordId + ? { ...record, cells: { ...record.cells, [fieldId]: coerceTableCell(field, value) } } + : record), + }; +}; + +export const removeTableRecord = (document: TableDocument, recordId: string): TableDocument => ({ + ...document, + records: document.records.filter((record) => record.id !== recordId), +}); + +export const replaceTableView = (document: TableDocument, view: TableView): TableDocument => ({ + ...document, + view: parseView(view, new Set(document.fields.map((field) => field.id))), +}); + +export const getTableSummary = (markdown: string | null | undefined): { + structuredTable: boolean; + tablePreview?: TableSummaryPreview; +} => { + const document = parseTableDocument(markdown); + if (!document) return { structuredTable: false }; + return { + structuredTable: true, + tablePreview: { + fieldCount: document.fields.length, + recordCount: document.records.length, + fieldNames: document.fields.map((field) => field.name).filter(Boolean).slice(0, 4), + }, + }; +}; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 03d81892..215abe3e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -1,6 +1,9 @@ import type { TiptapDoc } from "./content"; import type { AiAction, AiPromptParameterKind, AiPromptResultMode } from "./ai-assistant"; import type { DiagramKind, DiagramSummaryPreview } from "./diagram"; +import type { TableSummaryPreview } from "./table"; + +export type NoteCreateKind = DiagramKind | "table"; export type Notebook = { id: string; @@ -23,6 +26,8 @@ export type MemoSummary = { excerpt: string; diagramKind?: DiagramKind | null; diagramPreview?: DiagramSummaryPreview; + structuredTable?: boolean; + tablePreview?: TableSummaryPreview; tags: string[]; isPinned: boolean; isArchived: boolean;