317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||
|
||
import type { TocItem, TreeNode } from "./wikiTypes";
|
||
|
||
export const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||
thread: "对话沉淀",
|
||
search: "检索沉淀",
|
||
tool: "工具沉淀",
|
||
file: "文件导入",
|
||
manual: "手动创建",
|
||
report: "研究报告",
|
||
};
|
||
|
||
export const STATUS_LABELS: Record<string, string> = {
|
||
draft: "草稿",
|
||
approved: "已批准",
|
||
archived: "已归档",
|
||
};
|
||
|
||
const FALLBACK_FOLDER = "未分类";
|
||
|
||
/**
|
||
* Display labels for known English directory segments (the wiki-capture
|
||
* categories that historically drove the vault path). Keyed by the lowercased
|
||
* segment. User-created folders are arbitrary (usually Chinese) and simply don't
|
||
* match, so they pass through unchanged.
|
||
*/
|
||
const FOLDER_LABELS: Record<string, string> = {
|
||
synthesis: "分析综合",
|
||
concepts: "概念框架",
|
||
concept: "概念框架",
|
||
references: "外部来源",
|
||
reference: "外部来源",
|
||
decision: "决策记录",
|
||
journal: "会话纪要",
|
||
session: "会话纪要",
|
||
};
|
||
|
||
/** Map a known English directory segment to its Chinese display label (else unchanged). */
|
||
export function localizeFolderName(name: string): string {
|
||
return FOLDER_LABELS[name.trim().toLowerCase()] ?? name;
|
||
}
|
||
|
||
/**
|
||
* Canonical English wiki-capture section headings → Chinese display labels.
|
||
* Keyed by the lowercased, symbol-stripped heading text.
|
||
*/
|
||
const HEADING_LABELS: Record<string, string> = {
|
||
context: "背景",
|
||
"finding / decision": "结论 / 决策",
|
||
"finding/decision": "结论 / 决策",
|
||
finding: "结论",
|
||
decision: "决策",
|
||
reasoning: "推理依据",
|
||
implications: "影响 / 后续",
|
||
sources: "来源",
|
||
related: "相关知识",
|
||
merged: "已合并",
|
||
};
|
||
|
||
/**
|
||
* Replace the canonical English wiki-capture section headings (`## Context`,
|
||
* `## Finding / Decision`, …) with Chinese labels at render time. Operates on
|
||
* heading lines only (fenced code blocks are skipped) and matches the heading
|
||
* text case-insensitively, so **all historical notes are localized with no data
|
||
* migration**. Heading levels are preserved; because `extractToc` and the
|
||
* markdown viewer both receive this same transformed text, the slug/anchor ids
|
||
* stay consistent.
|
||
*/
|
||
export function localizeHeadings(markdown: string): string {
|
||
let inFence = false;
|
||
return markdown
|
||
.split("\n")
|
||
.map((line) => {
|
||
if (/^\s*(```|~~~)/.test(line)) {
|
||
inFence = !inFence;
|
||
return line;
|
||
}
|
||
if (inFence) return line;
|
||
const match = /^(#{1,4})\s+(.+?)\s*$/.exec(line);
|
||
if (!match) return line;
|
||
const key = match[2].replace(/[#`*_~]/g, "").trim().toLowerCase();
|
||
const zh = HEADING_LABELS[key];
|
||
return zh ? `${match[1]} ${zh}` : line;
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
/** Stable slug for a heading: keeps CJK characters, collapses whitespace to dashes. */
|
||
export function slugify(text: string): string {
|
||
return text
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[#`*_~[\]()]/g, "")
|
||
.replace(/\s+/g, "-")
|
||
.replace(/[^\w一-龥-]/g, "");
|
||
}
|
||
|
||
/**
|
||
* Parse h1–h4 headings out of raw markdown (skipping fenced code blocks) and
|
||
* assign each a stable id; duplicate titles get a numeric suffix. The same id
|
||
* sequence is mounted onto the rendered headings by MarkdownViewer.
|
||
*/
|
||
export function extractToc(markdown: string): TocItem[] {
|
||
const usedIds = new Map<string, number>();
|
||
const items: TocItem[] = [];
|
||
let inFence = false;
|
||
|
||
for (const line of markdown.split("\n")) {
|
||
if (/^\s*(```|~~~)/.test(line)) {
|
||
inFence = !inFence;
|
||
continue;
|
||
}
|
||
if (inFence) continue;
|
||
const match = /^(#{1,4})\s+(.+)$/.exec(line);
|
||
if (!match) continue;
|
||
|
||
const level = match[1].length;
|
||
const text = match[2].replace(/[#`*_~]/g, "").trim();
|
||
const baseId = slugify(text) || "heading";
|
||
const count = usedIds.get(baseId) ?? 0;
|
||
usedIds.set(baseId, count + 1);
|
||
items.push({ id: count === 0 ? baseId : `${baseId}-${count + 1}`, text, level });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/** One entry parsed from the note's trailing `## Related` wikilink list. */
|
||
export interface RelatedLink {
|
||
target: string;
|
||
alias?: string;
|
||
}
|
||
|
||
/**
|
||
* Split the backend-injected `## Related` section (a plain list of
|
||
* `- [[Title]]` bullets, see knowledge/service.py `_inject_related_notes`)
|
||
* out of the markdown body so it can be rendered as a structured module
|
||
* instead of a loose bullet list. If the section contains anything other
|
||
* than wikilink bullets, the body is returned untouched (no data loss).
|
||
*/
|
||
export function splitRelatedSection(markdown: string): {
|
||
body: string;
|
||
related: RelatedLink[];
|
||
} {
|
||
const lines = markdown.split("\n");
|
||
let inFence = false;
|
||
let start = -1;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (/^\s*(```|~~~)/.test(lines[i])) {
|
||
inFence = !inFence;
|
||
continue;
|
||
}
|
||
if (inFence) continue;
|
||
if (/^##\s+(Related|相关文档|相关)\s*$/i.test(lines[i].trim())) {
|
||
start = i;
|
||
break;
|
||
}
|
||
}
|
||
if (start === -1) return { body: markdown, related: [] };
|
||
|
||
let end = lines.length;
|
||
for (let i = start + 1; i < lines.length; i++) {
|
||
if (/^#{1,2}\s+/.test(lines[i])) {
|
||
end = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const related: RelatedLink[] = [];
|
||
for (const raw of lines.slice(start + 1, end)) {
|
||
const line = raw.trim();
|
||
if (!line) continue;
|
||
const match = /^[-*]\s*\[\[([^\]\n|]+)(?:\|([^\]\n]+))?\]\]\s*$/.exec(line);
|
||
if (!match) return { body: markdown, related: [] };
|
||
const target = match[1].trim();
|
||
if (!related.some((r) => r.target === target)) {
|
||
related.push({ target, alias: match[2]?.trim() || undefined });
|
||
}
|
||
}
|
||
if (related.length === 0) return { body: markdown, related: [] };
|
||
|
||
const body = [...lines.slice(0, start), ...lines.slice(end)]
|
||
.join("\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trimEnd();
|
||
return { body, related };
|
||
}
|
||
|
||
/**
|
||
* Folder path segments for a note. The user-assigned ``folder`` (a slash-joined
|
||
* path) is authoritative; unfiled notes fall back to a source-type group so the
|
||
* tree is never empty. (The old behavior keyed off ``vault_path`` — the fixed
|
||
* ``projects/<project>/<category>`` scaffold — which the editable directory now
|
||
* replaces.)
|
||
*/
|
||
export function noteFolderSegments(note: KnowledgeNote): string[] {
|
||
const folder = (note.folder || "").replace(/^\/+|\/+$/g, "").trim();
|
||
if (folder) {
|
||
const segments = folder.split("/").map((s) => s.trim()).filter(Boolean);
|
||
if (segments.length > 0) return segments;
|
||
}
|
||
return [SOURCE_TYPE_LABELS[note.source_type] ?? FALLBACK_FOLDER];
|
||
}
|
||
|
||
/** Breadcrumb segments for a note (folders only; the title is appended by the caller). */
|
||
export function noteBreadcrumbSegments(note: KnowledgeNote): string[] {
|
||
return ["知识库管理", ...noteFolderSegments(note).map(localizeFolderName)];
|
||
}
|
||
|
||
function sortTree(nodes: TreeNode[]): void {
|
||
nodes.sort((a, b) => {
|
||
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
||
return a.name.localeCompare(b.name, "zh-CN");
|
||
});
|
||
for (const node of nodes) {
|
||
if (node.children) sortTree(node.children);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the directory tree from the flat note list. Notes with a vault_path
|
||
* become nested folders mirroring that path; others are grouped by source type.
|
||
*/
|
||
export function buildKnowledgeTree(
|
||
notes: KnowledgeNote[],
|
||
folderPaths: string[] = [],
|
||
): TreeNode[] {
|
||
const root: TreeNode[] = [];
|
||
const folderMap = new Map<string, TreeNode>();
|
||
|
||
const ensureFolder = (segments: string[]): TreeNode[] => {
|
||
let children = root;
|
||
let path = "";
|
||
for (const segment of segments) {
|
||
path = path ? `${path}/${segment}` : segment;
|
||
let node = folderMap.get(path);
|
||
if (!node) {
|
||
// Display name is localized (known English category → 中文); the id /
|
||
// folderPath keep the raw segment so filtering/matching is unaffected.
|
||
node = { id: `folder:${path}`, name: localizeFolderName(segment), type: "folder", children: [], folderPath: path };
|
||
folderMap.set(path, node);
|
||
children.push(node);
|
||
}
|
||
children = node.children!;
|
||
}
|
||
return children;
|
||
};
|
||
|
||
// Pre-create managed (possibly empty) directories so a freshly-made folder
|
||
// shows up in the tree before any note is filed into it.
|
||
for (const path of folderPaths) {
|
||
const segments = path.split("/").map((s) => s.trim()).filter(Boolean);
|
||
if (segments.length) ensureFolder(segments);
|
||
}
|
||
|
||
for (const note of notes) {
|
||
ensureFolder(noteFolderSegments(note)).push({
|
||
id: note.id,
|
||
name: note.title || "未命名文档",
|
||
type: "file",
|
||
item: note,
|
||
});
|
||
}
|
||
|
||
sortTree(root);
|
||
return root;
|
||
}
|
||
|
||
/** Ids of every folder in the tree (used by "expand all" while searching). */
|
||
export function collectFolderIds(nodes: TreeNode[]): string[] {
|
||
const ids: string[] = [];
|
||
const walk = (list: TreeNode[]) => {
|
||
for (const node of list) {
|
||
if (node.type === "folder") {
|
||
ids.push(node.id);
|
||
if (node.children) walk(node.children);
|
||
}
|
||
}
|
||
};
|
||
walk(nodes);
|
||
return ids;
|
||
}
|
||
|
||
/** Folder ids on the path from the root down to the given note, or null if absent. */
|
||
export function findAncestorFolderIds(
|
||
nodes: TreeNode[],
|
||
noteId: string,
|
||
): string[] | null {
|
||
for (const node of nodes) {
|
||
if (node.type === "file" && node.id === noteId) return [];
|
||
if (node.type === "folder" && node.children) {
|
||
const sub = findAncestorFolderIds(node.children, noteId);
|
||
if (sub) return [node.id, ...sub];
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function formatDateTime(value?: string | null): string {
|
||
return value ? value.slice(0, 19).replace("T", " ") : "—";
|
||
}
|
||
|
||
export function formatDate(value?: string | null): string {
|
||
return value ? value.slice(0, 10) : "—";
|
||
}
|
||
|
||
/** Trigger a client-side download of the note body as a .md file. */
|
||
export function downloadMarkdown(note: KnowledgeNote): void {
|
||
const blob = new Blob([note.content_md], { type: "text/markdown;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = `${(note.title || "knowledge").replace(/[\\/:*?"<>|]/g, "_")}.md`;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|