ai-chatbot/lib/flow-board/topic-details.ts
134 c3f5903a86 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 18:06:29 +08:00

120 lines
3.5 KiB
TypeScript

import type { PlaitElement } from "@plait/core";
import { getNoteDocument, noteDocumentText, serializeNoteDocument, type NoteDocument } from "./topic-notes";
export type TopicAttachment = { name: string; url: string; mimeType: string };
export type TopicDetails = {
notes: string;
notesDocument?: NoteDocument;
labels: string[];
href: string;
topicLinkId: string;
attachment: TopicAttachment | null;
};
export type TopicEntry = {
element: PlaitElement;
title: string;
depth: number;
path: number[];
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function getTopicTitle(element: PlaitElement): string {
const visit = (value: unknown): string => {
const item = record(value);
if (typeof item.text === "string") return item.text;
return Array.isArray(item.children)
? item.children.map(visit).join("")
: "";
};
return visit(record(element.data).topic) || "未命名主题";
}
export function getTopicEntries(
elements: PlaitElement[],
depth = 0,
parentPath: number[] = []
): TopicEntry[] {
return elements.flatMap((element, index) => {
if (!["mind", "mindmap", "mind_child"].includes(String(element.type)))
return [];
return [
{
element,
title: getTopicTitle(element),
depth,
path: [...parentPath, index],
},
...getTopicEntries(element.children || [], depth + 1, [
...parentPath,
index,
]),
];
});
}
export function getTopicDetails(element: PlaitElement | null): TopicDetails {
const data = record(element?.xmind);
const notes =
typeof data.notes === "string"
? data.notes
: record(record(data.notes).plain).content;
const labels = Array.isArray(data.labels)
? data.labels
: typeof data.labels === "string"
? [data.labels]
: [];
const htmlText = typeof notes === "string" ? "" : noteDocumentText(getNoteDocument(element));
const attachment = record(data.attachment);
const safeAttachment =
typeof attachment.url === "string" &&
/^data:[a-z0-9.+/-]+;base64,[a-z0-9+/=]*$/i.test(attachment.url) &&
typeof attachment.name === "string"
? (attachment as TopicAttachment)
: null;
return {
attachment: safeAttachment,
notes: typeof notes === "string" ? notes : htmlText,
labels: [
...new Set(
labels
.filter(
(label): label is string =>
typeof label === "string" && Boolean(label.trim())
)
.map((label) => label.trim())
),
],
href: typeof data.href === "string" ? data.href : "",
topicLinkId: typeof data.topicLinkId === "string" ? data.topicLinkId : "",
};
}
export function patchTopicDetails(
element: PlaitElement,
patch: Partial<TopicDetails>
) {
const data = { ...record(element.xmind) };
for (const [key, value] of Object.entries(patch)) {
if (key === "notesDocument") data.notes = serializeNoteDocument(value as NoteDocument);
else if (key === "notes")
data.notes = value ? { plain: { content: value } } : undefined;
else data[key] = value;
}
return data;
}
export function getSafeTopicHref(href: string): string | null {
if (!href.trim()) return null;
try {
const url = new URL(href.trim());
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
}