- WordCount component (toggle words/chars vs read time, persisted mode) - TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task) - Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip - GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate - ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d) - NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar - WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount - WorkspaceTaskPanel: task body rendered in slide-over + Edit link - Settings: data export (Markdown ZIP / JSON) via GET /api/export - Accessibility: skip-to-content link, aria-labels on all icon-only buttons - Fix: export route used non-existent fabledassistant.database — corrected to async_session() - Fix: ToolCallRecord.status type now includes "running" (was causing TS build error) - Dockerfile: upgrade npm to latest before install to suppress major-version notice - npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
204 lines
5.2 KiB
TypeScript
204 lines
5.2 KiB
TypeScript
import type { JSONContent } from "@tiptap/vue-3";
|
|
|
|
export function serializeToMarkdown(doc: JSONContent): string {
|
|
if (doc.type !== "doc") return "";
|
|
return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
}
|
|
|
|
function serializeNodes(nodes: JSONContent[], listIndent = 0): string {
|
|
let out = "";
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
out += serializeNode(nodes[i], listIndent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeNode(node: JSONContent, listIndent = 0): string {
|
|
switch (node.type) {
|
|
case "paragraph":
|
|
return serializeInline(node.content || []) + "\n\n";
|
|
|
|
case "heading": {
|
|
const level = node.attrs?.level ?? 1;
|
|
const prefix = "#".repeat(level) + " ";
|
|
return prefix + serializeInline(node.content || []) + "\n\n";
|
|
}
|
|
|
|
case "bulletList":
|
|
return (
|
|
serializeBulletList(node.content || [], listIndent) +
|
|
(listIndent === 0 ? "\n" : "")
|
|
);
|
|
|
|
case "orderedList":
|
|
return (
|
|
serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) +
|
|
(listIndent === 0 ? "\n" : "")
|
|
);
|
|
|
|
case "taskList":
|
|
return (
|
|
serializeTaskList(node.content || [], listIndent) +
|
|
(listIndent === 0 ? "\n" : "")
|
|
);
|
|
|
|
case "listItem":
|
|
case "taskItem":
|
|
// Handled by their respective list serializers
|
|
return "";
|
|
|
|
case "codeBlock": {
|
|
const lang = node.attrs?.language || "";
|
|
const code = getTextContent(node);
|
|
return "```" + lang + "\n" + code + "\n```\n\n";
|
|
}
|
|
|
|
case "blockquote": {
|
|
const inner = serializeNodes(node.content || []);
|
|
return (
|
|
inner
|
|
.trimEnd()
|
|
.split("\n")
|
|
.map((line) => "> " + line)
|
|
.join("\n") + "\n\n"
|
|
);
|
|
}
|
|
|
|
case "horizontalRule":
|
|
return "---\n\n";
|
|
|
|
case "hardBreak":
|
|
return "\n";
|
|
|
|
case "text":
|
|
return applyMarks(node.text || "", node.marks || []);
|
|
|
|
default:
|
|
// Unknown node — try to serialize children
|
|
if (node.content) {
|
|
return serializeNodes(node.content);
|
|
}
|
|
return node.text || "";
|
|
}
|
|
}
|
|
|
|
function serializeBulletList(
|
|
items: JSONContent[],
|
|
indent: number
|
|
): string {
|
|
let out = "";
|
|
for (const item of items) {
|
|
if (item.type !== "listItem") continue;
|
|
const children = item.content || [];
|
|
const prefix = " ".repeat(indent) + "- ";
|
|
out += serializeListItem(children, prefix, indent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeOrderedList(
|
|
items: JSONContent[],
|
|
indent: number,
|
|
start: number
|
|
): string {
|
|
let out = "";
|
|
let num = start;
|
|
for (const item of items) {
|
|
if (item.type !== "listItem") continue;
|
|
const children = item.content || [];
|
|
const prefix = " ".repeat(indent) + num + ". ";
|
|
out += serializeListItem(children, prefix, indent);
|
|
num++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeTaskList(items: JSONContent[], indent: number): string {
|
|
let out = "";
|
|
for (const item of items) {
|
|
if (item.type !== "taskItem") continue;
|
|
const checked = item.attrs?.checked ? "x" : " ";
|
|
const prefix = " ".repeat(indent) + `- [${checked}] `;
|
|
out += serializeListItem(item.content || [], prefix, indent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeListItem(
|
|
children: JSONContent[],
|
|
prefix: string,
|
|
indent: number
|
|
): string {
|
|
let out = "";
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i];
|
|
if (child.type === "paragraph") {
|
|
if (i === 0) {
|
|
out += prefix + serializeInline(child.content || []) + "\n";
|
|
} else {
|
|
out +=
|
|
" ".repeat(indent + 1) +
|
|
serializeInline(child.content || []) +
|
|
"\n";
|
|
}
|
|
} else if (child.type === "bulletList") {
|
|
out += serializeBulletList(child.content || [], indent + 1);
|
|
} else if (child.type === "orderedList") {
|
|
out += serializeOrderedList(
|
|
child.content || [],
|
|
indent + 1,
|
|
child.attrs?.start ?? 1
|
|
);
|
|
} else if (child.type === "taskList") {
|
|
out += serializeTaskList(child.content || [], indent + 1);
|
|
} else {
|
|
out += serializeNode(child, indent + 1);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeInline(nodes: JSONContent[]): string {
|
|
let out = "";
|
|
for (const node of nodes) {
|
|
if (node.type === "text") {
|
|
out += applyMarks(node.text || "", node.marks || []);
|
|
} else if (node.type === "hardBreak") {
|
|
out += "\n";
|
|
} else if (node.content) {
|
|
out += serializeInline(node.content);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function applyMarks(text: string, marks: NonNullable<JSONContent["marks"]>): string {
|
|
let result = text;
|
|
for (const mark of marks) {
|
|
switch (mark.type) {
|
|
case "bold":
|
|
result = `**${result}**`;
|
|
break;
|
|
case "italic":
|
|
result = `_${result}_`;
|
|
break;
|
|
case "strike":
|
|
result = `~~${result}~~`;
|
|
break;
|
|
case "code":
|
|
result = "`" + result + "`";
|
|
break;
|
|
case "link":
|
|
result = `[${result}](${mark.attrs?.href || ""})`;
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getTextContent(node: JSONContent): string {
|
|
if (node.text) return node.text;
|
|
if (!node.content) return "";
|
|
return node.content.map(getTextContent).join("");
|
|
}
|