Files
NetMesh/domain/notes.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

967 lines
29 KiB
TypeScript

import type { Host, VaultNote } from "./models.ts";
import { getNextVaultOrder, normalizeVaultOrder, sortByVaultOrder } from "./vaultOrder.ts";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough";
import { toString as markdownNodeToString } from "mdast-util-to-string";
import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough";
export type { Host, VaultNote };
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,
isPinned: note.isPinned ? true : 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 firstDotIndex = safe.indexOf(".");
const windowsStem = (firstDotIndex >= 0 ? safe.slice(0, firstDotIndex) : safe).trimEnd();
const extension = firstDotIndex >= 0 ? safe.slice(firstDotIndex) : "";
const withoutReservedName = NOTE_EXPORT_RESERVED_WINDOWS_NAMES.test(windowsStem)
? `${windowsStem}_${extension}`
: 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 notesForExport = getVaultNotesForExportScope(notes, scope);
const groupPaths = Array.from(new Set(
notesForExport.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : []),
)).sort((left, right) => {
return left.split("/").length - right.split("/").length;
});
const exportedGroupSegments = new Map<string, string[]>();
const usedDirectoryNamesByParent = new Map<string, Set<string>>();
for (const groupPath of groupPaths) {
const parentPath = getNoteGroupParentPath(groupPath);
const parentSegments = parentPath ? exportedGroupSegments.get(parentPath) ?? [] : [];
const parentKey = parentSegments.join("/").toLowerCase();
const usedDirectoryNames = usedDirectoryNamesByParent.get(parentKey) ?? new Set<string>();
const baseName = sanitizeNoteExportFileNamePart(getNoteGroupLeafName(groupPath), "folder");
let candidate = baseName;
let suffix = 2;
while (usedDirectoryNames.has(candidate.toLowerCase())) {
candidate = `${baseName}-${suffix}`;
suffix += 1;
}
usedDirectoryNames.add(candidate.toLowerCase());
usedDirectoryNamesByParent.set(parentKey, usedDirectoryNames);
exportedGroupSegments.set(groupPath, [...parentSegments, candidate]);
}
const exportEntries = notesForExport.map((note, index) => ({
note,
groupSegments: note.group
? exportedGroupSegments.get(cleanNoteGroupPath(note.group)) ?? []
: [],
baseName: sanitizeNoteExportFileNamePart(note.title, `note-${index + 1}`),
}));
const directoryNames = new Set<string>();
for (const { groupSegments } of exportEntries) {
for (let depth = 1; depth <= groupSegments.length; depth += 1) {
directoryNames.add(groupSegments.slice(0, depth).join("/").toLowerCase());
}
}
const usedNames = new Set<string>();
return exportEntries.map(({ note, groupSegments, baseName }) => {
const basePath = [...groupSegments, baseName].join("/");
let candidate = `${basePath}.md`;
let suffix = 2;
while (usedNames.has(candidate.toLowerCase()) || directoryNames.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));
};
/** Build once per note-list snapshot, so typing does not lowercase full bodies. */
export const createVaultNoteSearchIndex = (notes: readonly VaultNote[]) => {
const entries = notes.map((note) => ({
note,
fields: [note.title, note.content, note.group ?? "", ...(note.tags ?? [])]
.map((value) => value.toLowerCase()),
}));
return (query: string): VaultNote[] => {
const needle = query.trim().toLowerCase();
return entries
.filter((entry) => !needle || entry.fields.some((field) => field.includes(needle)))
.map((entry) => entry.note);
};
};
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;
export const deriveNoteImportTitle = (fileName: string, content: string): string => {
const heading = extractNoteHeadings(content).find((item) => item.level === 1);
if (heading) return heading.text;
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,
};
};
export interface NoteHeadingItem {
id: string;
level: number;
text: string;
line: number;
}
export const normalizeNoteHeadingText = (value: string): string => value.replace(/\s+/g, " ").trim();
export const extractNoteHeadings = (content: string): NoteHeadingItem[] => {
if (!content) return [];
const headings: NoteHeadingItem[] = [];
type AstNode = {
type: string;
depth?: number;
children?: AstNode[];
position?: { start?: { line?: number } };
};
const visit = (node: AstNode) => {
if (node.type === "heading" && node.depth) {
const text = normalizeNoteHeadingText(
markdownNodeToString(node as Parameters<typeof markdownNodeToString>[0]),
);
const line = node.position?.start?.line ?? 1;
if (text) {
headings.push({
id: `heading-${line - 1}-${text.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")}`,
level: node.depth,
text,
line,
});
}
}
node.children?.forEach(visit);
};
visit(
fromMarkdown(content, {
extensions: [gfmStrikethrough()],
mdastExtensions: [gfmStrikethroughFromMarkdown()],
}) as AstNode,
);
return headings;
};
export const extractNoteSnippet = (content: string, maxLength = 100): string => {
if (!content) return "";
let text = content.replace(/```[\s\S]*?```/g, " ");
text = text.replace(/`([^`]+)`/g, "$1");
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
text = text.replace(/^#{1,6}\s+/gm, "");
text = text.replace(/^>\s+/gm, "");
text = text.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/gm, "");
text = text.replace(/^\d+\.\s+/gm, "");
text = text.replace(/\s+/g, " ").trim();
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trim()}...`;
};
export const extractAllNoteTags = (notes: VaultNote[]): { tag: string; count: number }[] => {
const map = new Map<string, number>();
for (const note of notes) {
if (note.tags) {
for (const t of note.tags) {
const clean = t.trim();
if (clean) {
map.set(clean, (map.get(clean) || 0) + 1);
}
}
}
}
return Array.from(map.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
};
export type NoteSortOption =
| "updatedDesc"
| "updatedAsc"
| "createdDesc"
| "createdAsc"
| "titleAsc"
| "titleDesc"
| "custom";
export type NoteFilterMode = "all" | "pinned" | "recent" | "uncategorized";
export const filterAndSortVaultNotes = (
notes: VaultNote[],
options: {
search?: string;
group?: string | null;
tag?: string | null;
filterMode?: NoteFilterMode;
sort?: NoteSortOption;
hosts?: Host[];
} = {},
): VaultNote[] => {
const {
search = "",
group = null,
tag = null,
filterMode = "all",
sort = "updatedDesc",
hosts = [],
} = options;
const filtered = notes.filter((note) => {
if (filterMode === "pinned" && !note.isPinned) return false;
if (filterMode === "uncategorized" && note.group) return false;
if (filterMode === "recent") {
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
if (note.updatedAt < sevenDaysAgo) return false;
}
if (group) {
if (!note.group || !isNoteGroupInside(note.group, group)) return false;
}
if (tag) {
if (!note.tags || !note.tags.includes(tag)) return false;
}
if (search.trim() && !matchesVaultNoteSearch(note, search, hosts)) {
return false;
}
return true;
});
return [...filtered].sort((a, b) => {
if (sort !== "custom") {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1;
}
}
switch (sort) {
case "updatedDesc":
return b.updatedAt - a.updatedAt;
case "updatedAsc":
return a.updatedAt - b.updatedAt;
case "createdDesc":
return b.createdAt - a.createdAt;
case "createdAsc":
return a.createdAt - b.createdAt;
case "titleAsc":
return (a.title || "").localeCompare(b.title || "");
case "titleDesc":
return (b.title || "").localeCompare(a.title || "");
case "custom":
default:
return (a.order ?? 0) - (b.order ?? 0);
}
});
};
export interface NoteStats {
words: number;
chars: number;
lines: number;
}
export const calculateNoteStats = (content: string): NoteStats => {
if (!content) return { words: 0, chars: 0, lines: 0 };
const lines = content.split("\n").length;
const chars = content.length;
const cjkMatches = content.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g);
const cjkChars = cjkMatches ? cjkMatches.length : 0;
const nonCjkWords = content
.replace(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean).length;
const words = cjkChars + nonCjkWords;
return { words, chars, lines };
};
export type MarkdownActionType =
| "undo"
| "redo"
| "bold"
| "italic"
| "strikethrough"
| "underline"
| "code"
| "codeblock"
| "h1"
| "h2"
| "h3"
| "h4"
| "quote"
| "bullet"
| "number"
| "task"
| "table"
| "divider"
| "link"
| "image"
| "math";
export interface WrapMarkdownResult {
text: string;
selectionStart: number;
selectionEnd: number;
}
const formatSelectedMarkdownListLines = (
content: string,
marker: (index: number) => string,
): { formatted: string; selectionStartOffset: number; selectionEndOffset: number } => {
let itemIndex = 0;
let cursor = 0;
let selectionStartOffset: number | null = null;
let selectionEndOffset: number | null = null;
const formattedLines = content.split("\n").map((line) => {
if (!line.trim()) {
cursor += line.length + 1;
return line;
}
const prefix = marker(itemIndex);
itemIndex += 1;
const formattedLine = `${prefix}${line}`;
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
selectionEndOffset = cursor + formattedLine.length;
cursor += formattedLine.length + 1;
return formattedLine;
});
return {
formatted: formattedLines.join("\n"),
selectionStartOffset: selectionStartOffset ?? 0,
selectionEndOffset: selectionEndOffset ?? 0,
};
};
export const formatMarkdownListSelection = (
content: string,
action: "bullet" | "number" | "task",
): string => formatSelectedMarkdownListLines(
content,
action === "number"
? (index) => `${index + 1}. `
: action === "task"
? () => "- [ ] "
: () => "- ",
).formatted;
const formatSelectedMarkdownQuoteLines = (content: string): {
formatted: string;
selectionStartOffset: number;
selectionEndOffset: number;
} => {
let cursor = 0;
let selectionStartOffset: number | null = null;
let selectionEndOffset: number | null = null;
const formattedLines = content.split("\n").map((line) => {
const prefix = line ? "> " : ">";
const formattedLine = `${prefix}${line}`;
if (line) {
if (selectionStartOffset === null) selectionStartOffset = cursor + prefix.length;
selectionEndOffset = cursor + formattedLine.length;
}
cursor += formattedLine.length + 1;
return formattedLine;
});
return {
formatted: formattedLines.join("\n"),
selectionStartOffset: selectionStartOffset ?? 0,
selectionEndOffset: selectionEndOffset ?? 0,
};
};
export const formatMarkdownQuoteSelection = (content: string): string =>
formatSelectedMarkdownQuoteLines(content).formatted;
export const wrapMarkdownSyntax = (
text: string,
start: number,
end: number,
action: MarkdownActionType,
): WrapMarkdownResult => {
const before = text.slice(0, start);
const selected = text.slice(start, end);
const after = text.slice(end);
const selectedBlockContent = (fallback: string): string => selected.trim() ? selected : fallback;
switch (action) {
case "bold": {
const content = selected || "bold text";
return {
text: `${before}**${content}**${after}`,
selectionStart: start + 2,
selectionEnd: start + 2 + content.length,
};
}
case "italic": {
const content = selected || "italic text";
return {
text: `${before}*${content}*${after}`,
selectionStart: start + 1,
selectionEnd: start + 1 + content.length,
};
}
case "strikethrough": {
const content = selected || "strikethrough text";
return {
text: `${before}~~${content}~~${after}`,
selectionStart: start + 2,
selectionEnd: start + 2 + content.length,
};
}
case "underline": {
const content = selected || "underlined text";
return {
text: `${before}<u>${content}</u>${after}`,
selectionStart: start + 3,
selectionEnd: start + 3 + content.length,
};
}
case "code": {
const content = selected || "code";
return {
text: `${before}\`${content}\`${after}`,
selectionStart: start + 1,
selectionEnd: start + 1 + content.length,
};
}
case "codeblock": {
const content = selected || "";
const opening = "\n```bash\n";
const insert = `${opening}${content}\n\`\`\`\n`;
return {
text: `${before}${insert}${after}`,
selectionStart: start + opening.length,
selectionEnd: start + opening.length + content.length,
};
}
case "h1": {
const content = selected || "Heading 1";
return {
text: `${before}\n# ${content}\n${after}`,
selectionStart: start + 3,
selectionEnd: start + 3 + content.length,
};
}
case "h2": {
const content = selected || "Heading 2";
return {
text: `${before}\n## ${content}\n${after}`,
selectionStart: start + 4,
selectionEnd: start + 4 + content.length,
};
}
case "h3": {
const content = selected || "Heading 3";
return {
text: `${before}\n### ${content}\n${after}`,
selectionStart: start + 5,
selectionEnd: start + 5 + content.length,
};
}
case "h4": {
const content = selected || "Heading 4";
return {
text: `${before}\n#### ${content}\n${after}`,
selectionStart: start + 6,
selectionEnd: start + 6 + content.length,
};
}
case "quote": {
const content = selectedBlockContent("Quote");
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownQuoteLines(content);
return {
text: `${before}\n${formatted}\n${after}`,
selectionStart: start + 1 + selectionStartOffset,
selectionEnd: start + 1 + selectionEndOffset,
};
}
case "bullet": {
const content = selectedBlockContent("List item");
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
content,
() => "- ",
);
return {
text: `${before}\n${formatted}\n${after}`,
selectionStart: start + 1 + selectionStartOffset,
selectionEnd: start + 1 + selectionEndOffset,
};
}
case "number": {
const content = selectedBlockContent("List item");
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
content,
(index) => `${index + 1}. `,
);
return {
text: `${before}\n${formatted}\n${after}`,
selectionStart: start + 1 + selectionStartOffset,
selectionEnd: start + 1 + selectionEndOffset,
};
}
case "task": {
const content = selectedBlockContent("Task");
const { formatted, selectionStartOffset, selectionEndOffset } = formatSelectedMarkdownListLines(
content,
() => "- [ ] ",
);
return {
text: `${before}\n${formatted}\n${after}`,
selectionStart: start + 1 + selectionStartOffset,
selectionEnd: start + 1 + selectionEndOffset,
};
}
case "divider": {
const insert = `\n---\n`;
return {
text: `${before}${insert}${after}`,
selectionStart: start + insert.length,
selectionEnd: start + insert.length,
};
}
case "table": {
const tableMarkdown = `\n| Column 1 | Column 2 | Column 3 |\n| :--- | :--- | :--- |\n| Cell 1 | Cell 2 | Cell 3 |\n`;
return {
text: `${before}${tableMarkdown}${after}`,
selectionStart: start + tableMarkdown.length,
selectionEnd: start + tableMarkdown.length,
};
}
case "link": {
const title = selected || "Link text";
const insert = `[${title}](https://)`;
return {
text: `${before}${insert}${after}`,
selectionStart: start + title.length + 3,
selectionEnd: start + title.length + 11,
};
}
case "image": {
const alt = selected || "Image description";
const insert = `![${alt}](https://)`;
return {
text: `${before}${insert}${after}`,
selectionStart: start + alt.length + 4,
selectionEnd: start + alt.length + 12,
};
}
case "math": {
const content = selected || "";
const opening = "\n```latex\n";
const insert = `${opening}${content}\n\`\`\`\n`;
return {
text: `${before}${insert}${after}`,
selectionStart: start + opening.length,
selectionEnd: start + opening.length + content.length,
};
}
default:
return { text, selectionStart: start, selectionEnd: end };
}
};