Files
Netcatty/domain/notes.ts
T
2026-06-27 02:33:56 +08:00

465 lines
14 KiB
TypeScript

import type { Host, VaultNote } from "./models";
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder";
const cleanStringArray = (values: unknown): string[] | undefined => {
if (!Array.isArray(values)) return undefined;
const cleaned = Array.from(
new Set(
values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean),
),
);
return cleaned.length ? cleaned : undefined;
};
export const sanitizeNoteTitle = (title: unknown): string =>
typeof title === "string" ? title.trim() : "";
export const sanitizeVaultNote = (note: Partial<VaultNote>): VaultNote => {
const now = Date.now();
const createdAt =
typeof note.createdAt === "number" && Number.isFinite(note.createdAt)
? note.createdAt
: now;
const updatedAt =
typeof note.updatedAt === "number" && Number.isFinite(note.updatedAt)
? note.updatedAt
: createdAt;
return {
id: typeof note.id === "string" && note.id.trim() ? note.id : crypto.randomUUID(),
title: sanitizeNoteTitle(note.title),
content: typeof note.content === "string" ? note.content : "",
group: typeof note.group === "string" && note.group.trim() ? note.group.trim() : undefined,
tags: cleanStringArray(note.tags),
linkedHostIds: cleanStringArray(note.linkedHostIds),
createdAt,
updatedAt,
order: typeof note.order === "number" && Number.isFinite(note.order) ? note.order : undefined,
};
};
export const normalizeVaultNotes = (notes: Partial<VaultNote>[]): VaultNote[] =>
normalizeVaultOrder(notes.map(sanitizeVaultNote));
export const normalizeNoteGroups = (groups: unknown): string[] =>
Array.isArray(groups)
? Array.from(
new Set(
groups
.filter((value): value is string => typeof value === "string")
.map((value) => cleanNoteGroupPath(value))
.filter(Boolean),
),
)
: [];
export const cleanNoteGroupPath = (value: string): string =>
value
.split("/")
.map((part) => part.trim())
.filter(Boolean)
.join("/");
export const ancestorNoteGroupPaths = (path: string): string[] => {
const parts = cleanNoteGroupPath(path).split("/").filter(Boolean);
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
};
export const getNoteGroupLeafName = (path: string): string =>
cleanNoteGroupPath(path).split("/").pop() || cleanNoteGroupPath(path);
export const getNoteGroupParentPath = (path: string): string | null => {
const parts = cleanNoteGroupPath(path).split("/").filter(Boolean);
if (parts.length <= 1) return null;
return parts.slice(0, -1).join("/");
};
export const joinNoteGroupPath = (parent: string | null, name: string): string => {
const cleanName = cleanNoteGroupPath(name);
if (!cleanName) return "";
const cleanParent = parent ? cleanNoteGroupPath(parent) : "";
return cleanParent ? `${cleanParent}/${cleanName}` : cleanName;
};
export const isNoteGroupInside = (path: string | undefined, group: string): boolean => {
const cleanPath = path ? cleanNoteGroupPath(path) : "";
const cleanGroup = cleanNoteGroupPath(group);
return cleanPath === cleanGroup || Boolean(cleanPath.startsWith(`${cleanGroup}/`));
};
export const replaceNoteGroupPrefix = (path: string | undefined, from: string, to: string): string | undefined => {
if (!path) return path;
const cleanPath = cleanNoteGroupPath(path);
const cleanFrom = cleanNoteGroupPath(from);
const cleanTo = cleanNoteGroupPath(to);
if (cleanPath === cleanFrom) return cleanTo || undefined;
if (cleanPath.startsWith(`${cleanFrom}/`)) return cleanTo ? `${cleanTo}/${cleanPath.slice(cleanFrom.length + 1)}` : undefined;
return cleanPath;
};
export const resolveMovedNoteGroupPath = (
group: string,
parent: string | null,
groups: string[],
): string | null => {
const source = cleanNoteGroupPath(group);
const targetParent = parent ? cleanNoteGroupPath(parent) : null;
if (!source) return null;
if (targetParent && (targetParent === source || targetParent.startsWith(`${source}/`))) return null;
const leafName = getNoteGroupLeafName(source);
const basePath = joinNoteGroupPath(targetParent, leafName);
if (!basePath || basePath === source) return null;
const existingGroups = normalizeNoteGroups(groups)
.filter((item) => !isNoteGroupInside(item, source));
const hasConflict = (candidate: string) =>
existingGroups.some((item) => item === candidate || item.startsWith(`${candidate}/`));
if (!hasConflict(basePath)) return basePath;
for (let index = 2; index < 1000; index += 1) {
const candidate = joinNoteGroupPath(targetParent, `${leafName} ${index}`);
if (!hasConflict(candidate)) return candidate;
}
return null;
};
export const remapExpandedNoteGroupPaths = (
expandedPaths: Set<string>,
from: string,
to: string,
): Set<string> => {
const next = new Set<string>();
expandedPaths.forEach((item) => {
const replaced = replaceNoteGroupPrefix(item, from, to);
if (replaced) next.add(replaced);
});
ancestorNoteGroupPaths(to).forEach((path) => next.add(path));
return next;
};
export const sortVaultNotes = (notes: VaultNote[]): VaultNote[] => sortByVaultOrder(notes);
export type VaultNotesExportScope =
| { type: "all" }
| { type: "group"; group: string };
export interface VaultNoteMarkdownExportFile {
name: string;
content: string;
}
const NOTE_EXPORT_UNSAFE_FILENAME_CHARS = /[<>:"/\\|?*]/g;
const NOTE_EXPORT_RESERVED_WINDOWS_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
const replaceControlFilenameChars = (value: string): string => {
let output = "";
for (const char of value) {
output += char.charCodeAt(0) < 32 ? "-" : char;
}
return output;
};
export const sanitizeNoteExportFileNamePart = (value: string | undefined, fallback: string): string => {
const cleaned = replaceControlFilenameChars((value ?? "").trim())
.replace(NOTE_EXPORT_UNSAFE_FILENAME_CHARS, "-")
.replace(/\s+/g, " ")
.replace(/\.+$/g, "")
.trim();
const safe = cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : fallback;
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(safe) ? `${safe}_` : safe;
return withoutReservedName.slice(0, 120) || fallback;
};
export const getVaultNotesForExportScope = (
notes: VaultNote[],
scope: VaultNotesExportScope = { type: "all" },
): VaultNote[] => {
const normalized = sortVaultNotes(normalizeVaultNotes(notes));
if (scope.type === "all") return normalized;
const group = cleanNoteGroupPath(scope.group);
if (!group) return [];
return normalized.filter((note) => isNoteGroupInside(note.group, group));
};
export const buildVaultNoteMarkdownExportFiles = (
notes: VaultNote[],
scope: VaultNotesExportScope = { type: "all" },
): VaultNoteMarkdownExportFile[] => {
const usedNames = new Set<string>();
return getVaultNotesForExportScope(notes, scope).map((note, index) => {
const groupSegments = note.group
? cleanNoteGroupPath(note.group)
.split("/")
.filter(Boolean)
.map((part) => sanitizeNoteExportFileNamePart(part, "folder"))
: [];
const baseName = sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`);
const basePath = [...groupSegments, baseName].join("/");
let candidate = `${basePath}.md`;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase())) {
candidate = `${basePath}-${suffix}.md`;
suffix += 1;
}
usedNames.add(candidate.toLowerCase());
return {
name: candidate,
content: note.content,
};
});
};
export const matchesVaultNoteSearch = (
note: VaultNote,
query: string,
hosts: Host[] = [],
): boolean => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
const linkedHosts = hosts
.filter((host) => note.linkedHostIds?.includes(host.id))
.map((host) => `${host.label} ${host.hostname}`)
.join(" ");
return [
note.title,
note.content,
note.group ?? "",
...(note.tags ?? []),
linkedHosts,
].some((value) => value.toLowerCase().includes(needle));
};
const isSanitizedRenderedLink = (value: string): boolean => {
try {
const url = new URL(value);
return url.protocol === "about:" && url.pathname === "blank";
} catch {
return value.trim() === "about:blank";
}
};
const unescapeMarkdownText = (value: string): string =>
value.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1").trim();
const findInlineMarkdownLinkMatches = (markdown: string, label: string): string[] => {
const matches: string[] = [];
const targetLabel = label.trim();
if (!targetLabel) return matches;
let index = 0;
while (index < markdown.length) {
const labelStart = markdown.indexOf("[", index);
if (labelStart === -1) break;
if (labelStart > 0 && markdown[labelStart - 1] === "!") {
index = labelStart + 1;
continue;
}
let cursor = labelStart + 1;
let escaped = false;
let labelEnd = -1;
for (; cursor < markdown.length; cursor += 1) {
const char = markdown[cursor];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "]") {
labelEnd = cursor;
break;
}
}
if (labelEnd === -1 || markdown[labelEnd + 1] !== "(") {
index = labelStart + 1;
continue;
}
const rawLabel = markdown.slice(labelStart + 1, labelEnd);
if (unescapeMarkdownText(rawLabel) !== targetLabel) {
index = labelEnd + 1;
continue;
}
cursor = labelEnd + 2;
while (cursor < markdown.length && /\s/.test(markdown[cursor])) cursor += 1;
let href = "";
if (markdown[cursor] === "<") {
cursor += 1;
const hrefStart = cursor;
while (cursor < markdown.length && markdown[cursor] !== ">") cursor += 1;
href = markdown.slice(hrefStart, cursor).trim();
} else {
const hrefStart = cursor;
let depth = 0;
escaped = false;
for (; cursor < markdown.length; cursor += 1) {
const char = markdown[cursor];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "(") {
depth += 1;
continue;
}
if (char === ")") {
if (depth === 0) break;
depth -= 1;
continue;
}
if (/\s/.test(char) && depth === 0) break;
}
href = markdown.slice(hrefStart, cursor).trim();
}
if (href) matches.push(href);
index = cursor + 1;
}
return matches;
};
export const resolveRenderedMarkdownLinkHref = (
markdown: string,
label: string,
renderedHref: string,
): string => {
if (!isSanitizedRenderedLink(renderedHref)) return renderedHref;
const matches = findInlineMarkdownLinkMatches(markdown, label);
const uniqueMatches = Array.from(new Set(matches));
return uniqueMatches.length === 1 ? uniqueMatches[0] : renderedHref;
};
const NOTE_IMPORT_TITLE_EXTENSIONS = /\.(md|markdown|txt)$/i;
const stripFencedCodeBlocksForImportTitle = (content: string): string => {
const withoutClosedBlocks = content.replace(/```[\s\S]*?```/g, "");
const lines = withoutClosedBlocks.split("\n");
const output: string[] = [];
let inUnclosedFence = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
inUnclosedFence = true;
continue;
}
if (inUnclosedFence) continue;
output.push(line);
}
return output.join("\n");
};
export const deriveNoteImportTitle = (fileName: string, content: string): string => {
const contentWithoutCodeBlocks = stripFencedCodeBlocksForImportTitle(content);
const headingMatch = /^#\s+(.+?)\s*$/m.exec(contentWithoutCodeBlocks);
if (headingMatch?.[1]) return headingMatch[1].trim();
const baseName = fileName.replace(NOTE_IMPORT_TITLE_EXTENSIONS, "").trim();
return baseName || "Untitled note";
};
export const buildVaultNoteFromMarkdownImport = ({
fileName,
content,
group,
order,
}: {
fileName: string;
content: string;
group: string | null;
order: number;
}): VaultNote => {
const now = Date.now();
return sanitizeVaultNote({
title: deriveNoteImportTitle(fileName, content),
content,
group: group || undefined,
createdAt: now,
updatedAt: now,
order,
});
};
export const importMarkdownPayloadsToVaultNotes = (
payloads: Array<{ fileName: string; content: string }>,
existingNotes: VaultNote[],
targetGroup: string | null,
): { notes: VaultNote[]; importedCount: number } => {
const imported: VaultNote[] = [];
let orderBase = existingNotes;
for (const { fileName, content } of payloads) {
const note = buildVaultNoteFromMarkdownImport({
fileName,
content,
group: targetGroup,
order: getNextVaultOrder([...orderBase, ...imported]),
});
imported.push(note);
orderBase = [...orderBase, note];
}
return {
notes: normalizeVaultNotes([...existingNotes, ...imported]),
importedCount: imported.length,
};
};
export const importMarkdownFilesToVaultNotes = async (
files: File[],
existingNotes: VaultNote[],
targetGroup: string | null,
readFile: (file: File) => Promise<string>,
): Promise<{ notes: VaultNote[]; importedCount: number; skippedCount: number }> => {
const payloads: Array<{ fileName: string; content: string }> = [];
let skippedCount = 0;
for (const file of files) {
if (!/\.(md|markdown|txt)$/i.test(file.name)) {
skippedCount += 1;
continue;
}
payloads.push({
fileName: file.name,
content: await readFile(file),
});
}
const { notes, importedCount } = importMarkdownPayloadsToVaultNotes(
payloads,
existingNotes,
targetGroup,
);
return {
notes,
importedCount,
skippedCount,
};
};