feat(web): add a beta structured table note
Store typed records in the note body and edit them as a grid on web and desktop. Phones and iOS stay read-only so the record model is not overwritten.
This commit is contained in:
1 parent
41fc5825eb
commit
c2a26b23ac
27 files changed
+1530
-33
No files matched your search
@@ -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 (?, ?, ?)")
|
||||
|
||||
@@ -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<typeof diagramSemanticGraph>["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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -26,6 +26,12 @@ enum TipTapContentSource: Sendable {
|
||||
if markdown.contains("<!-- edgeever-diagram-v1:") {
|
||||
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
|
||||
}
|
||||
// Structured tables keep their JSON record model in the same comment.
|
||||
// The viewer shows the readable Markdown table and never the marker.
|
||||
if markdown.contains("<!-- edgeever-table-v1:") {
|
||||
let stripped = stripStructuredTableMarker(markdown)
|
||||
return Decision(useJSON: false, payload: stripped, fingerprint: "table:\(stripped)")
|
||||
}
|
||||
if jsonUsable {
|
||||
if jsonHasImageWidth(json) {
|
||||
return Decision(useJSON: true, payload: documentJSON, fingerprint: "json:\(json)")
|
||||
@@ -53,6 +59,14 @@ enum TipTapContentSource: Sendable {
|
||||
return Decision(useJSON: false, payload: markdown, fingerprint: "md:\(markdown)")
|
||||
}
|
||||
|
||||
static func stripStructuredTableMarker(_ markdown: String) -> String {
|
||||
markdown.replacingOccurrences(
|
||||
of: #"<!--\s*edgeever-table-v1:[\s\S]*?-->"#,
|
||||
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.
|
||||
|
||||
@@ -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("<!-- edgeever-diagram-v1:")
|
||||
}
|
||||
|
||||
private func isStructuredTable(_ memo: MemoDetail) -> Bool {
|
||||
memo.contentMarkdown.contains("<!-- edgeever-table-v1:")
|
||||
}
|
||||
|
||||
private func blocksRichTextEdit(_ memo: MemoDetail) -> Bool {
|
||||
isVisualDiagram(memo) || isStructuredTable(memo)
|
||||
}
|
||||
|
||||
private func refreshSyncStatus() {
|
||||
guard let scope = env.session.dataScope else {
|
||||
outboxStatus = nil
|
||||
|
||||
@@ -13,6 +13,18 @@ final class TipTapContentSourceTests: XCTestCase {
|
||||
XCTAssertTrue(decision.payload.contains("edgeever-diagram-v1"))
|
||||
}
|
||||
|
||||
func testViewerStripsStructuredTableMarker() {
|
||||
let markdown = "| 名称 |\n| --- |\n| 示例 |\n\n<!-- edgeever-table-v1:abc -->"
|
||||
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
|
||||
|
||||
@@ -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<!-- edgeever-table-v1:not-json -->`;
|
||||
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; }");
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 = ({
|
||||
</View>
|
||||
) : (
|
||||
<View style={detailLayoutStyles.meta}>
|
||||
{!memo.isDeleted && !isVisualDiagram ? (
|
||||
{!memo.isDeleted && !locksRichTextEdit ? (
|
||||
<Pressable
|
||||
accessibilityHint="进入编辑并聚焦标题"
|
||||
accessibilityLabel="编辑笔记标题"
|
||||
@@ -1232,7 +1237,7 @@ export const MemoDetailModal = ({
|
||||
onAiRequest={isEditing ? editor.requestSelectionAi : undefined}
|
||||
onChange={isEditing ? editor.persistDraft : undefined}
|
||||
onImagePreview={isEditing ? undefined : onImagePreview}
|
||||
onDoublePress={isEditing || isVisualDiagram ? undefined : async () => {
|
||||
onDoublePress={isEditing || locksRichTextEdit ? undefined : async () => {
|
||||
beginEditorStartup();
|
||||
onRichEdit(memo, "body");
|
||||
}}
|
||||
@@ -1269,7 +1274,7 @@ export const MemoDetailModal = ({
|
||||
<Text style={styles.errorText}>笔记加载失败</Text>
|
||||
</View>
|
||||
)}
|
||||
{memo && !memo.isDeleted && !isVisualDiagram && !isEditing ? (
|
||||
{memo && !memo.isDeleted && !locksRichTextEdit && !isEditing ? (
|
||||
<Pressable
|
||||
accessibilityLabel="编辑笔记"
|
||||
accessibilityRole="button"
|
||||
@@ -1289,7 +1294,7 @@ export const MemoDetailModal = ({
|
||||
<Pressable style={styles.actionSheet}>
|
||||
<View style={styles.actionSheetHandle} />
|
||||
<Text style={styles.actionSheetTitle}>{resolvedLocale !== "zh-CN" ? "Note actions" : "笔记操作"}</Text>
|
||||
{!memo.isDeleted && !isVisualDiagram ? (
|
||||
{!memo.isDeleted && !locksRichTextEdit ? (
|
||||
<DetailActionSheetItem
|
||||
icon={<Sparkles color="#16A06E" size={18} />}
|
||||
label={resolvedLocale !== "zh-CN" ? "AI note assistant" : "AI 笔记助手"}
|
||||
|
||||
@@ -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 = ({
|
||||
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600 ">{memo.diagramPreview.labels.join(" · ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : tableLabel ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 rounded border border-slate-200 px-1.5 py-0.5">
|
||||
<Table className="h-3 w-3" aria-hidden="true" />{tableLabel}
|
||||
</span>
|
||||
{memo.tablePreview ? <span>{t("structuredTable.listCounts", { records: memo.tablePreview.recordCount, fields: memo.tablePreview.fieldCount })}</span> : null}
|
||||
</div>
|
||||
{listDensity !== "compact" && memo.tablePreview?.fieldNames.length ? (
|
||||
<div className="line-clamp-2 text-[13px] leading-relaxed text-slate-600">{memo.tablePreview.fieldNames.join(" · ")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
RotateCcw,
|
||||
FileText,
|
||||
Network,
|
||||
Table,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,7 +48,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotebookTreeItem } from "./NotebookTreeItem";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Notebook, AuthUser, DiagramKind } from "@edgeever/shared";
|
||||
import type { Notebook, AuthUser, NoteCreateKind } from "@edgeever/shared";
|
||||
import type { NotebookNode, NotebookDropPosition, NotebookSortMode } from "@/lib/app-helpers";
|
||||
import type { SyncQueueSummary } from "@/lib/sync-queue";
|
||||
import {
|
||||
@@ -300,7 +301,7 @@ const SidebarRailButton = ({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
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
|
||||
<Boxes className="h-4 w-4" />
|
||||
{t("diagram.architecture")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
|
||||
<Table className="h-4 w-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-wide text-emerald-700">
|
||||
Beta
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-36 items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs font-medium text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70"
|
||||
aria-label={t("structuredTable.editField", { name: field.name })}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<span className="truncate">{field.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-400">{t(`structuredTable.types.${field.type}`)}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 space-y-2 p-2">
|
||||
<label className="block space-y-1 text-xs text-slate-500">
|
||||
<span>{t("structuredTable.fieldName")}</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} onBlur={commitName} aria-label={t("structuredTable.fieldName")} />
|
||||
</label>
|
||||
<label className="block space-y-1 text-xs text-slate-500">
|
||||
<span>{t("structuredTable.fieldType")}</span>
|
||||
<select
|
||||
className={`${controlClassName} w-full`}
|
||||
value={field.type}
|
||||
aria-label={t("structuredTable.fieldType")}
|
||||
onChange={(event) => onChange(updateTableField(document, field.id, { type: event.target.value as TableFieldType }))}
|
||||
>
|
||||
{TABLE_FIELD_TYPES.map((type) => <option key={type} value={type}>{t(`structuredTable.types.${type}`)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{field.type === "select" ? (
|
||||
<label className="block space-y-1 text-xs text-slate-500">
|
||||
<span>{t("structuredTable.selectOptions")}</span>
|
||||
<Input
|
||||
value={options}
|
||||
placeholder={t("structuredTable.selectOptionsHint")}
|
||||
aria-label={t("structuredTable.selectOptions")}
|
||||
onChange={(event) => setOptions(event.target.value)}
|
||||
onBlur={commitOptions}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={document.fields.length <= 1}
|
||||
onSelect={() => onChange(removeTableField(document, field.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("structuredTable.deleteField")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Checkbox
|
||||
checked={value === true}
|
||||
disabled={readOnly}
|
||||
aria-label={field.name}
|
||||
onCheckedChange={(checked) => onChange(updateTableCell(document, record.id, field.id, checked === true))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!active) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="block h-8 w-full min-w-36 truncate rounded-md px-2 text-left text-sm text-slate-800 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/70 disabled:hover:bg-transparent"
|
||||
disabled={readOnly}
|
||||
onClick={() => onEdit({ recordId: record.id, fieldId: field.id })}
|
||||
>
|
||||
{text || <span className="text-slate-300">—</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const options = field.options ?? [];
|
||||
const choices = draft && !options.includes(draft) ? [draft, ...options] : options;
|
||||
return (
|
||||
<select
|
||||
autoFocus
|
||||
className={`${controlClassName} w-full min-w-36`}
|
||||
value={draft}
|
||||
aria-label={field.name}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
onDraft(event.target.value);
|
||||
onCommit(event.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="" />
|
||||
{choices.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
className={`${controlClassName} w-full min-w-36`}
|
||||
type={field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "url" ? "url" : "text"}
|
||||
value={draft}
|
||||
aria-label={field.name}
|
||||
onChange={(event) => {
|
||||
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<TableDocument | null>(parsed);
|
||||
const [editing, setEditing] = useState<EditingCell | null>(null);
|
||||
const draftRef = useRef("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(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<MemoEditSession | null>(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<ColumnDef<TableRecord>[]>(() => {
|
||||
if (!document) return [];
|
||||
return [
|
||||
...document.fields.map((field): ColumnDef<TableRecord> => ({
|
||||
id: field.id,
|
||||
header: () => <FieldHeader document={document} field={field} readOnly={readOnly} onChange={changeDocument} />,
|
||||
cell: ({ row }) => (
|
||||
<RecordCell
|
||||
document={document}
|
||||
field={field}
|
||||
record={row.original}
|
||||
active={editing?.recordId === row.original.id && editing.fieldId === field.id}
|
||||
readOnly={readOnly}
|
||||
onEdit={(cell) => {
|
||||
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 }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("structuredTable.deleteRecord")}
|
||||
disabled={readOnly}
|
||||
onClick={() => changeDocument(removeTableRecord(document, row.original.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("structuredTable.deleteRecord")}</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [document, editing, readOnly, t]);
|
||||
|
||||
const table = useReactTable({ data: visibleRecords, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id });
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-card">
|
||||
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onBackToList}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("structuredTable.back")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="p-6 text-sm text-slate-500">{t("structuredTable.unreadable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col bg-card">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 px-3 py-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={t("structuredTable.back")} onClick={onBackToList}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("structuredTable.back")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Table className="h-4 w-4 text-emerald-600" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MemoTitleInput
|
||||
ariaLabel={t("structuredTable.title")}
|
||||
placeholder={t("structuredTable.title")}
|
||||
readOnly={readOnly}
|
||||
value={title}
|
||||
onValueChange={(value) => {
|
||||
setTitle(value);
|
||||
titleRef.current = value;
|
||||
if (documentRef.current) setDirty(snapshotOf(value, documentRef.current) !== savedSnapshotRef.current);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{countLabel}</span>
|
||||
{saveLabel ? <span className={saveError ? "text-xs text-rose-600" : "text-xs text-slate-400"}>{saveLabel}</span> : null}
|
||||
<Button type="button" variant="outline" size="sm" onClick={exportCsv}>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("structuredTable.exportCsv")}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={readOnly || fieldLimitReached}
|
||||
aria-label={fieldLimitReached ? t("structuredTable.fieldLimit") : t("structuredTable.addField")}
|
||||
onClick={() => changeDocument(addTableField(document, {
|
||||
id: createTableId("fld"),
|
||||
name: t("structuredTable.newField", { index: document.fields.length + 1 }),
|
||||
type: "text",
|
||||
}))}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("structuredTable.addField")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{fieldLimitReached ? t("structuredTable.fieldLimit") : t("structuredTable.addField")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
disabled={readOnly || recordLimitReached}
|
||||
aria-label={recordLimitReached ? t("structuredTable.recordLimit") : t("structuredTable.addRecord")}
|
||||
onClick={() => changeDocument(addTableRecord(document))}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("structuredTable.addRecord")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{recordLimitReached ? t("structuredTable.recordLimit") : t("structuredTable.addRecord")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-3 py-2">
|
||||
<span className="text-xs text-slate-500">{t("structuredTable.filter")}</span>
|
||||
{document.view.filters.map((filter, index) => {
|
||||
const operatorNeedsValue = filter.operator === "contains" || filter.operator === "eq";
|
||||
return (
|
||||
<div key={`${filter.fieldId}-${index}`} className="flex items-center gap-1">
|
||||
<select
|
||||
className={controlClassName}
|
||||
value={filter.fieldId}
|
||||
aria-label={t("structuredTable.filter")}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => setFilter(index, { ...filter, fieldId: event.target.value })}
|
||||
>
|
||||
{document.fields.map((field) => <option key={field.id} value={field.id}>{field.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className={controlClassName}
|
||||
value={filter.operator}
|
||||
aria-label={t("structuredTable.filter")}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => setFilter(index, { ...filter, operator: event.target.value as TableFilterOperator })}
|
||||
>
|
||||
{TABLE_FILTER_OPERATORS.map((operator) => <option key={operator} value={operator}>{t(`structuredTable.operators.${operator}`)}</option>)}
|
||||
</select>
|
||||
{operatorNeedsValue ? (
|
||||
<Input
|
||||
className="h-8 w-36 text-xs"
|
||||
value={filter.value ?? ""}
|
||||
placeholder={t("structuredTable.filterValue")}
|
||||
aria-label={t("structuredTable.filterValue")}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => setFilter(index, { ...filter, value: event.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("structuredTable.clearFilters")}
|
||||
disabled={readOnly}
|
||||
onClick={() => changeDocument(replaceTableView(document, {
|
||||
...document.view,
|
||||
filters: document.view.filters.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={readOnly || document.view.filters.length >= 8}
|
||||
onClick={() => changeDocument(replaceTableView(document, {
|
||||
...document.view,
|
||||
filters: [...document.view.filters, { fieldId: document.fields[0]?.id ?? "", operator: "contains", value: "" }],
|
||||
}))}
|
||||
>
|
||||
{t("structuredTable.addFilter")}
|
||||
</Button>
|
||||
<label className="ml-auto flex items-center gap-1 text-xs text-slate-500">
|
||||
<span>{t("structuredTable.sort")}</span>
|
||||
<select
|
||||
className={controlClassName}
|
||||
value={document.view.sort?.fieldId ?? ""}
|
||||
aria-label={t("structuredTable.sort")}
|
||||
disabled={readOnly}
|
||||
onChange={(event) => changeDocument(replaceTableView(document, {
|
||||
...document.view,
|
||||
sort: event.target.value ? { fieldId: event.target.value, direction: document.view.sort?.direction ?? "asc" } : null,
|
||||
}))}
|
||||
>
|
||||
<option value="">{t("structuredTable.noSort")}</option>
|
||||
{document.fields.map((field) => <option key={field.id} value={field.id}>{field.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className={controlClassName}
|
||||
value={document.view.sort?.direction ?? "asc"}
|
||||
aria-label={t("structuredTable.sort")}
|
||||
disabled={readOnly || !document.view.sort}
|
||||
onChange={(event) => {
|
||||
if (!document.view.sort) return;
|
||||
changeDocument(replaceTableView(document, {
|
||||
...document.view,
|
||||
sort: { ...document.view.sort, direction: event.target.value === "desc" ? "desc" : "asc" },
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="asc">{t("structuredTable.ascending")}</option>
|
||||
<option value="desc">{t("structuredTable.descending")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-max min-w-full border-collapse">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-slate-200">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-2 py-1 text-left font-medium">
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-2 py-1 align-middle">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{visibleRecords.length === 0 ? (
|
||||
<p className="px-4 py-8 text-sm text-slate-500">{filtered ? t("structuredTable.noMatches") : t("structuredTable.empty")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableEditorPane;
|
||||
@@ -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 ? (
|
||||
<TableEditorPane
|
||||
key={selectedMemo.id}
|
||||
memo={selectedMemo}
|
||||
repository={repository}
|
||||
readOnly={memoView === "trash" || selectedMemo.isDeleted}
|
||||
onBackToList={() => {
|
||||
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" }),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EditorPane
|
||||
onOpenExecutionCenter={handleOpenExecutionCenter}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, UserRound, Workflow, X } from "lucide-react";
|
||||
import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, Table, UserRound, Workflow, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DiagramKind, Notebook } from "@edgeever/shared";
|
||||
import type { NoteCreateKind, Notebook } from "@edgeever/shared";
|
||||
import type { MobileBottomNavItem, NotebookNode } from "@/lib/app-helpers";
|
||||
import {
|
||||
buildNotebookTree,
|
||||
@@ -58,7 +58,7 @@ export const MobileBottomNav = ({
|
||||
activeItem: MobileBottomNavItem;
|
||||
canCreateMemo: boolean;
|
||||
isCreating: boolean;
|
||||
onCreateMemo: (kind?: DiagramKind) => void;
|
||||
onCreateMemo: (kind?: NoteCreateKind) => void;
|
||||
onHome: () => void;
|
||||
onOpenSettings: () => void;
|
||||
}) => {
|
||||
@@ -89,6 +89,13 @@ export const MobileBottomNav = ({
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("mind-map")}><Network className="h-4 w-4" />{t("diagram.mindMap")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("flowchart")}><Workflow className="h-4 w-4" />{t("diagram.flowchart")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onCreateMemo("architecture")}><Boxes className="h-4 w-4" />{t("diagram.architecture")}</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => onCreateMemo("table")}>
|
||||
<Table className="h-4 w-4" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("structuredTable.name")}</span>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full border border-emerald-200/80 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold leading-none tracking-wide text-emerald-700">
|
||||
Beta
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MobileBottomNavButton active={activeItem === "settings"} icon={<UserRound className="h-5 w-5" />} label={t("nav.mine")} onClick={onOpenSettings} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<!-- edgeever-table-v1:not-json -->`;
|
||||
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<!-- edgeever-diagram-v1:not-json -->`;
|
||||
const body = resolvePublicShareBody(shareOf(markdown), "share-token");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "マインドマップ",
|
||||
|
||||
@@ -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: "思维导图",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<!-- edgeever-table-v1:not-json -->`;
|
||||
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("链接");
|
||||
});
|
||||
});
|
||||
@@ -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<string, TableCellValue>;
|
||||
};
|
||||
|
||||
export type TableFilter = {
|
||||
fieldId: string;
|
||||
operator: TableFilterOperator;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type TableSort = {
|
||||
fieldId: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type TableView = {
|
||||
filters: TableFilter[];
|
||||
sort: TableSort | null;
|
||||
};
|
||||
|
||||
export type TableDocument = {
|
||||
schemaVersion: typeof TABLE_SCHEMA_VERSION;
|
||||
fields: TableField[];
|
||||
records: TableRecord[];
|
||||
view: TableView;
|
||||
};
|
||||
|
||||
export type TableSeedLabels = {
|
||||
name?: string;
|
||||
status?: string;
|
||||
date?: string;
|
||||
sample?: string;
|
||||
notStarted?: string;
|
||||
inProgress?: string;
|
||||
done?: string;
|
||||
};
|
||||
|
||||
export type TableSummaryPreview = {
|
||||
fieldCount: number;
|
||||
recordCount: number;
|
||||
fieldNames: string[];
|
||||
};
|
||||
|
||||
const TABLE_MARKER = "edgeever-table-v1";
|
||||
const TABLE_COMMENT = new RegExp(`<!--\\s*${TABLE_MARKER}:([\\s\\S]*?)\\s*-->`);
|
||||
const FIELD_NAME_LIMIT = 80;
|
||||
const OPTION_LIMIT = 40;
|
||||
const CELL_TEXT_LIMIT = 2000;
|
||||
|
||||
const encodeBase64Url = (value: string) => Base64.encodeURI(value);
|
||||
const decodeBase64Url = (value: string) => Base64.decode(value);
|
||||
|
||||
export const createTableId = (prefix: "fld" | "rec") => {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
const suffix = uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
return `${prefix}_${suffix}`;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const cleanName = (value: unknown, fallback: string) => {
|
||||
const name = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
||||
return (name || fallback).slice(0, FIELD_NAME_LIMIT);
|
||||
};
|
||||
|
||||
const cleanOptions = (value: unknown) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const options: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== "string") continue;
|
||||
const option = item.replace(/\s+/g, " ").trim().slice(0, FIELD_NAME_LIMIT);
|
||||
if (!option || options.includes(option)) continue;
|
||||
options.push(option);
|
||||
if (options.length >= OPTION_LIMIT) break;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
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<string>): TableView => {
|
||||
if (!isRecord(value)) return { filters: [], sort: null };
|
||||
const filters: TableFilter[] = [];
|
||||
if (Array.isArray(value.filters)) {
|
||||
for (const item of value.filters) {
|
||||
if (!isRecord(item) || typeof item.fieldId !== "string" || !fieldIds.has(item.fieldId)) continue;
|
||||
if (!TABLE_FILTER_OPERATORS.includes(item.operator as TableFilterOperator)) continue;
|
||||
const filter: TableFilter = { fieldId: item.fieldId, operator: item.operator as TableFilterOperator };
|
||||
if (typeof item.value === "string") filter.value = item.value.slice(0, CELL_TEXT_LIMIT);
|
||||
filters.push(filter);
|
||||
if (filters.length >= 8) break;
|
||||
}
|
||||
}
|
||||
const sort = isRecord(value.sort)
|
||||
&& typeof value.sort.fieldId === "string"
|
||||
&& fieldIds.has(value.sort.fieldId)
|
||||
&& (value.sort.direction === "asc" || value.sort.direction === "desc")
|
||||
? { fieldId: value.sort.fieldId, direction: value.sort.direction as "asc" | "desc" }
|
||||
: null;
|
||||
return { filters, sort };
|
||||
};
|
||||
|
||||
export const parseTableDocument = (markdown: string | null | undefined): TableDocument | null => {
|
||||
const encoded = markdown?.match(TABLE_COMMENT)?.[1]?.replace(/\s+/g, "");
|
||||
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
|
||||
try {
|
||||
const value = JSON.parse(decodeBase64Url(encoded)) as Record<string, unknown>;
|
||||
if (value.schemaVersion !== TABLE_SCHEMA_VERSION || !Array.isArray(value.fields) || value.fields.length < 1) return null;
|
||||
const fields: TableField[] = [];
|
||||
for (const item of value.fields) {
|
||||
const field = parseField(item);
|
||||
if (!field || fields.some((current) => current.id === field.id)) return null;
|
||||
fields.push(field);
|
||||
}
|
||||
if (!Array.isArray(value.records)) return null;
|
||||
const fieldIds = new Set(fields.map((field) => field.id));
|
||||
const records: TableRecord[] = [];
|
||||
const recordIds = new Set<string>();
|
||||
for (const item of value.records) {
|
||||
if (!isRecord(item) || typeof item.id !== "string" || !item.id || item.id.length > 80 || recordIds.has(item.id)) return null;
|
||||
recordIds.add(item.id);
|
||||
const rawCells = isRecord(item.cells) ? item.cells : {};
|
||||
const cells: Record<string, TableCellValue> = {};
|
||||
for (const field of fields) cells[field.id] = coerceTableCell(field, rawCells[field.id]);
|
||||
records.push({ id: item.id, cells });
|
||||
}
|
||||
return { schemaVersion: TABLE_SCHEMA_VERSION, fields, records, view: parseView(value.view, fieldIds) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const hasTableDocumentMarker = (markdown: string | null | undefined) =>
|
||||
Boolean(markdown?.match(TABLE_COMMENT));
|
||||
|
||||
export const stripTableDocumentMarker = (markdown: string | null | undefined) =>
|
||||
(markdown ?? "").replace(TABLE_COMMENT, "").trimEnd();
|
||||
|
||||
const 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<!-- ${TABLE_MARKER}:${encodeBase64Url(JSON.stringify(document))} -->`;
|
||||
|
||||
export const createDefaultTableDocument = (labels: TableSeedLabels = {}): TableDocument => {
|
||||
const nameId = "fld_name";
|
||||
const statusId = "fld_status";
|
||||
const dateId = "fld_date";
|
||||
const notStarted = labels.notStarted ?? "未开始";
|
||||
const inProgress = labels.inProgress ?? "进行中";
|
||||
const done = labels.done ?? "完成";
|
||||
return {
|
||||
schemaVersion: TABLE_SCHEMA_VERSION,
|
||||
fields: [
|
||||
{ id: nameId, name: labels.name ?? "名称", type: "text" },
|
||||
{ id: statusId, name: labels.status ?? "状态", type: "select", options: [notStarted, inProgress, done] },
|
||||
{ id: dateId, name: labels.date ?? "日期", type: "date" },
|
||||
],
|
||||
records: [{
|
||||
id: "rec_sample",
|
||||
cells: {
|
||||
[nameId]: labels.sample ?? "示例记录",
|
||||
[statusId]: notStarted,
|
||||
[dateId]: "",
|
||||
},
|
||||
}],
|
||||
view: { filters: [], sort: null },
|
||||
};
|
||||
};
|
||||
|
||||
const csvCell = (value: TableCellValue) => {
|
||||
const text = 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<Pick<TableField, "name" | "type" | "options">>): TableDocument => {
|
||||
const current = document.fields.find((field) => field.id === fieldId);
|
||||
if (!current) return document;
|
||||
const next: TableField = {
|
||||
...current,
|
||||
name: patch.name === undefined ? current.name : cleanName(patch.name, current.name),
|
||||
type: patch.type ?? current.type,
|
||||
};
|
||||
if (next.type === "select") next.options = cleanOptions(patch.options ?? current.options ?? []);
|
||||
return {
|
||||
...document,
|
||||
fields: document.fields.map((field) => field.id === fieldId ? next : field),
|
||||
records: document.records.map((record) => ({
|
||||
...record,
|
||||
cells: { ...record.cells, [fieldId]: coerceTableCell(next, record.cells[fieldId]) },
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeTableField = (document: TableDocument, fieldId: string): TableDocument => {
|
||||
if (document.fields.length <= 1 || !document.fields.some((field) => field.id === fieldId)) return document;
|
||||
const fields = document.fields.filter((field) => field.id !== fieldId);
|
||||
const fieldIds = new Set(fields.map((field) => field.id));
|
||||
return {
|
||||
...document,
|
||||
fields,
|
||||
records: document.records.map((record) => ({
|
||||
...record,
|
||||
cells: Object.fromEntries(Object.entries(record.cells).filter(([id]) => fieldIds.has(id))),
|
||||
})),
|
||||
view: {
|
||||
filters: document.view.filters.filter((filter) => filter.fieldId !== fieldId),
|
||||
sort: document.view.sort?.fieldId === fieldId ? null : document.view.sort,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const addTableRecord = (document: TableDocument, id = createTableId("rec")): TableDocument => {
|
||||
if (document.records.length >= TABLE_RECORD_LIMIT || document.records.some((record) => record.id === id)) return document;
|
||||
const cells: Record<string, TableCellValue> = {};
|
||||
for (const field of document.fields) cells[field.id] = emptyCell(field);
|
||||
return { ...document, records: [...document.records, { id, cells }] };
|
||||
};
|
||||
|
||||
export const updateTableCell = (document: TableDocument, recordId: string, fieldId: string, value: unknown): TableDocument => {
|
||||
const field = document.fields.find((item) => item.id === fieldId);
|
||||
if (!field) return document;
|
||||
return {
|
||||
...document,
|
||||
records: document.records.map((record) => record.id === recordId
|
||||
? { ...record, cells: { ...record.cells, [fieldId]: coerceTableCell(field, value) } }
|
||||
: record),
|
||||
};
|
||||
};
|
||||
|
||||
export const removeTableRecord = (document: TableDocument, recordId: string): TableDocument => ({
|
||||
...document,
|
||||
records: document.records.filter((record) => record.id !== recordId),
|
||||
});
|
||||
|
||||
export const replaceTableView = (document: TableDocument, view: TableView): TableDocument => ({
|
||||
...document,
|
||||
view: parseView(view, new Set(document.fields.map((field) => field.id))),
|
||||
});
|
||||
|
||||
export const getTableSummary = (markdown: string | null | undefined): {
|
||||
structuredTable: boolean;
|
||||
tablePreview?: TableSummaryPreview;
|
||||
} => {
|
||||
const document = parseTableDocument(markdown);
|
||||
if (!document) return { structuredTable: false };
|
||||
return {
|
||||
structuredTable: true,
|
||||
tablePreview: {
|
||||
fieldCount: document.fields.length,
|
||||
recordCount: document.records.length,
|
||||
fieldNames: document.fields.map((field) => field.name).filter(Boolean).slice(0, 4),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user