1378 lines
40 KiB
TypeScript
1378 lines
40 KiB
TypeScript
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
|
import { HttpClient } from '@angular/common/http';
|
|
import { Note, VaultNode, GraphData, TagInfo, VaultFolder, FileMetadata } from '../types';
|
|
import { VaultEventsService, VaultEventPayload } from './vault-events.service';
|
|
import { Subscription, firstValueFrom } from 'rxjs';
|
|
import { rewriteTagsFrontmatter, rewriteBooleanFrontmatter } from '../app/shared/markdown/markdown-frontmatter.util';
|
|
import { DEFAULT_HELP_CONTENT } from '../app/constants/help-note.content';
|
|
|
|
// ============================================================================
|
|
// INTERFACES
|
|
// ============================================================================
|
|
|
|
interface VaultApiNote {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
tags: string[];
|
|
mtime: number;
|
|
fileName?: string;
|
|
filePath?: string;
|
|
originalPath?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
interface VaultApiResponse {
|
|
notes: VaultApiNote[];
|
|
}
|
|
|
|
interface QuickLinkCounts {
|
|
all: number;
|
|
favorites: number;
|
|
publish: number;
|
|
templates: number;
|
|
tasks: number;
|
|
drafts: number;
|
|
private: number;
|
|
archive: number;
|
|
trash: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CONSTANTS
|
|
// ============================================================================
|
|
|
|
const TRASH_FOLDER = '.trash';
|
|
const REFRESH_DEBOUNCE_MS = 300;
|
|
const VAULT_API_ENDPOINT = '/api/vault';
|
|
const FILES_METADATA_ENDPOINT = '/api/files/metadata';
|
|
|
|
// Tag validation patterns
|
|
const TAG_VALIDATION = {
|
|
INVALID_CHARS: /[{}]/,
|
|
NUMERIC_ONLY: /^\d+$/,
|
|
HEX_LIKE: /^[0-9a-fA-F]{3,}$/,
|
|
INLINE_TAG: /(^|\s)#([A-Za-z0-9_\-\/]+)\b/g,
|
|
CODE_FENCE: /^```/
|
|
};
|
|
|
|
// ============================================================================
|
|
// SERVICE
|
|
// ============================================================================
|
|
|
|
@Injectable({
|
|
providedIn: 'root'
|
|
})
|
|
export class VaultService implements OnDestroy {
|
|
// ========================================
|
|
// STATE SIGNALS
|
|
// ========================================
|
|
|
|
private notesMap = signal<Map<string, Note>>(new Map());
|
|
private fastTreeSignal = signal<VaultNode[]>([]);
|
|
private openFolderPaths = signal(new Set<string>());
|
|
private vaultNameSignal = signal<string>(this.resolveInitialVaultName());
|
|
|
|
// Fast lookup indices for metadata
|
|
private idToPathIndex = new Map<string, string>();
|
|
private slugIdToPathIndex = new Map<string, string>();
|
|
private metaByPathIndex = new Map<string, FileMetadata>();
|
|
|
|
// Subscription management
|
|
private vaultEventsSubscription: Subscription | null = null;
|
|
private refreshTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// ========================================
|
|
// COMPUTED SIGNALS
|
|
// ========================================
|
|
|
|
readonly allNotes = computed(() => Array.from(this.notesMap().values()));
|
|
|
|
readonly trashNotes = computed<Note[]>(() =>
|
|
this.allNotes().filter(note => this.isInTrash(note.filePath || note.originalPath || ''))
|
|
);
|
|
|
|
readonly counts = computed<QuickLinkCounts>(() =>
|
|
this.calculateQuickLinkCounts()
|
|
);
|
|
|
|
readonly folderCounts = computed<Record<string, number>>(() =>
|
|
this.calculateFolderCounts()
|
|
);
|
|
|
|
readonly trashFolderCounts = computed<Record<string, number>>(() =>
|
|
this.calculateTrashFolderCounts()
|
|
);
|
|
|
|
readonly vaultName = computed(() => this.vaultNameSignal());
|
|
|
|
readonly fastFileTree = computed<VaultNode[]>(() => this.fastTreeSignal());
|
|
|
|
readonly fileTree = computed<VaultNode[]>(() =>
|
|
this.buildFileTree()
|
|
);
|
|
|
|
readonly trashTree = computed<VaultNode[]>(() =>
|
|
this.buildTrashTree()
|
|
);
|
|
|
|
readonly graphData = computed<GraphData>(() =>
|
|
this.buildGraphData()
|
|
);
|
|
|
|
readonly tags = computed<TagInfo[]>(() =>
|
|
this.extractTags()
|
|
);
|
|
|
|
// ========================================
|
|
// CONSTRUCTOR & LIFECYCLE
|
|
// ========================================
|
|
|
|
constructor(
|
|
private http: HttpClient,
|
|
private vaultEvents: VaultEventsService
|
|
) {
|
|
this.initialize();
|
|
}
|
|
|
|
async updateNoteStates(
|
|
noteId: string,
|
|
key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private' | 'template' | 'task',
|
|
nextValue: boolean
|
|
): Promise<boolean> {
|
|
const note = this.getNoteById(noteId);
|
|
if (!note?.filePath) return false;
|
|
|
|
const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
|
|
const updatedRaw = rewriteBooleanFrontmatter(currentRaw, { [key]: nextValue });
|
|
|
|
if (!await this.saveMarkdown(note.filePath, updatedRaw)) return false;
|
|
|
|
const updatedFrontmatter = { ...(note.frontmatter || {}) } as any;
|
|
if (nextValue === undefined as any) {
|
|
delete updatedFrontmatter[key];
|
|
} else {
|
|
updatedFrontmatter[key] = nextValue;
|
|
}
|
|
|
|
this.updateNoteInMap(note, { rawContent: updatedRaw, frontmatter: updatedFrontmatter });
|
|
return true;
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.cleanup();
|
|
}
|
|
|
|
// ========================================
|
|
// INITIALIZATION
|
|
// ========================================
|
|
|
|
private initialize(): void {
|
|
this.loadFastFileTree();
|
|
this.refreshNotes();
|
|
this.observeVaultEvents();
|
|
}
|
|
|
|
private cleanup(): void {
|
|
this.vaultEventsSubscription?.unsubscribe();
|
|
this.vaultEventsSubscription = null;
|
|
|
|
if (this.refreshTimeoutId !== null) {
|
|
clearTimeout(this.refreshTimeoutId);
|
|
this.refreshTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// PUBLIC API
|
|
// ========================================
|
|
|
|
getNoteById(id: string): Note | undefined {
|
|
return this.notesMap().get(id);
|
|
}
|
|
|
|
/**
|
|
* Load the built-in help note content into the in-memory notes map.
|
|
* This loads the help content from the embedded file.
|
|
* Returns a promise that resolves with the id (slug) of the registered note.
|
|
*/
|
|
async loadBuiltinHelpNote(): Promise<string> {
|
|
const slugId = this.buildSlugIdFromPath('help.md'); // "help"
|
|
if (this.getNoteById(slugId)) return slugId;
|
|
|
|
try {
|
|
// Load help content from embedded constant
|
|
const rawContent = DEFAULT_HELP_CONTENT;
|
|
|
|
const normalizedContent = this.normalizeLineEndings(rawContent);
|
|
const { frontmatter, body } = this.parseFrontmatter(normalizedContent);
|
|
|
|
const title = (frontmatter.title as string) || (frontmatter.titre as string) || 'Guide utilisateur';
|
|
const nowIso = new Date().toISOString();
|
|
const createdAt = typeof frontmatter.creation_date === 'string' ? frontmatter.creation_date : nowIso;
|
|
const updatedAt = typeof frontmatter.modification_date === 'string' ? frontmatter.modification_date : nowIso;
|
|
const tags = Array.isArray(frontmatter.tags) ? this.normalizeTags(frontmatter.tags as string[]) : [];
|
|
|
|
const note: Note = {
|
|
id: slugId,
|
|
title,
|
|
content: body,
|
|
rawContent: normalizedContent,
|
|
tags,
|
|
frontmatter,
|
|
backlinks: [],
|
|
mtime: Date.now(),
|
|
fileName: 'help.md',
|
|
filePath: 'assets/help/help.md',
|
|
originalPath: 'help',
|
|
createdAt,
|
|
updatedAt,
|
|
};
|
|
|
|
this.registerBuiltinNote(note);
|
|
return slugId;
|
|
} catch (error) {
|
|
console.error('Failed to load built-in help note:', error);
|
|
throw new Error('Impossible de charger le guide d\'aide');
|
|
}
|
|
}
|
|
|
|
async ensureNoteLoadedById(id: string): Promise<boolean> {
|
|
if (!id || this.getNoteById(id)) return !!id;
|
|
|
|
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
|
return path ? this.ensureNoteLoadedByPath(path) : false;
|
|
}
|
|
|
|
async ensureNoteLoadedByPath(path: string): Promise<boolean> {
|
|
if (!path) return false;
|
|
|
|
const slugId = this.buildSlugIdFromPath(path);
|
|
if (this.getNoteById(slugId)) return true;
|
|
|
|
try {
|
|
const rawContent = await this.fetchNoteContent(path);
|
|
const note = this.parseNoteFromContent(rawContent, slugId, path);
|
|
|
|
this.addNoteToMap(note);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
toggleFolder(path: string): void {
|
|
this.openFolderPaths.update(paths => {
|
|
const updated = new Set(paths);
|
|
updated.has(path) ? updated.delete(path) : updated.add(path);
|
|
return updated;
|
|
});
|
|
this.applyOpenStateToFastTree();
|
|
}
|
|
|
|
ensureFolderOpen(originalPath: string): void {
|
|
if (!originalPath) return;
|
|
|
|
const parts = originalPath.split('/').filter(Boolean);
|
|
if (parts.length <= 1) return;
|
|
|
|
const updatedPaths = new Set(this.openFolderPaths());
|
|
let currentPath = '';
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
|
updatedPaths.add(currentPath);
|
|
}
|
|
|
|
this.openFolderPaths.set(updatedPaths);
|
|
this.applyOpenStateToFastTree();
|
|
}
|
|
|
|
refresh(): void {
|
|
this.refreshNotes();
|
|
}
|
|
|
|
getFastMetaById(id: string): FileMetadata | undefined {
|
|
const path = this.idToPathIndex.get(id) || this.slugIdToPathIndex.get(id);
|
|
return path ? this.metaByPathIndex.get(path) : undefined;
|
|
}
|
|
|
|
async updateNoteTags(noteId: string, tags: string[]): Promise<boolean> {
|
|
const note = this.getNoteById(noteId);
|
|
if (!note?.filePath) return false;
|
|
|
|
const currentRaw = note.rawContent ?? this.recomposeMarkdownFromNote(note);
|
|
const updatedRaw = rewriteTagsFrontmatter(currentRaw, tags);
|
|
|
|
if (!await this.saveMarkdown(note.filePath, updatedRaw)) return false;
|
|
|
|
this.updateNoteInMap(note, { rawContent: updatedRaw, tags: this.normalizeTags(tags) });
|
|
return true;
|
|
}
|
|
|
|
// ========================================
|
|
// FAST FILE TREE
|
|
// ========================================
|
|
|
|
private loadFastFileTree(): void {
|
|
this.http.get<FileMetadata[]>(FILES_METADATA_ENDPOINT).subscribe({
|
|
next: (items) => {
|
|
try {
|
|
this.buildFastTree(items || []);
|
|
} catch (e) {
|
|
console.warn('[VaultService] Failed to build fast tree:', e);
|
|
}
|
|
},
|
|
error: () => {
|
|
// Silent fallback to regular tree
|
|
}
|
|
});
|
|
}
|
|
|
|
private buildFastTree(items: FileMetadata[]): void {
|
|
this.clearFastIndices();
|
|
|
|
const root = this.createRootFolder();
|
|
const folders = new Map<string, VaultFolder>([['', root]]);
|
|
|
|
for (const item of items) {
|
|
if (!item?.path) continue;
|
|
|
|
const path = this.normalizePath(item.path);
|
|
if (this.isBuiltinPath(path)) continue;
|
|
this.indexMetadata(item, path);
|
|
|
|
const parentFolder = this.buildFolderStructure(path, folders, root);
|
|
if (parentFolder) {
|
|
this.addFileNode(parentFolder, path, this.buildSlugIdFromPath(path));
|
|
}
|
|
}
|
|
|
|
this.sortFolderChildren(root);
|
|
this.fastTreeSignal.set(root.children);
|
|
}
|
|
|
|
private clearFastIndices(): void {
|
|
this.idToPathIndex.clear();
|
|
this.slugIdToPathIndex.clear();
|
|
this.metaByPathIndex.clear();
|
|
}
|
|
|
|
private indexMetadata(item: FileMetadata, path: string): void {
|
|
const slugId = this.buildSlugIdFromPath(path);
|
|
|
|
if (item.id) this.idToPathIndex.set(String(item.id), path);
|
|
if (slugId) this.slugIdToPathIndex.set(slugId, path);
|
|
this.metaByPathIndex.set(path, item);
|
|
}
|
|
|
|
private buildFolderStructure(
|
|
path: string,
|
|
folders: Map<string, VaultFolder>,
|
|
root: VaultFolder
|
|
): VaultFolder | null {
|
|
const parts = path.split('/').filter(Boolean);
|
|
const folderSegments = parts.slice(0, -1);
|
|
|
|
let currentPath = '';
|
|
let parentFolder: VaultFolder | null = root;
|
|
|
|
for (const segment of folderSegments) {
|
|
if (segment === TRASH_FOLDER) return null;
|
|
|
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
|
|
let folder = folders.get(currentPath);
|
|
if (!folder) {
|
|
folder = this.createFolder(segment, currentPath);
|
|
folders.set(currentPath, folder);
|
|
parentFolder.children.push(folder);
|
|
} else {
|
|
folder.isOpen = this.openFolderPaths().has(currentPath);
|
|
}
|
|
|
|
parentFolder = folder;
|
|
}
|
|
|
|
return parentFolder;
|
|
}
|
|
|
|
private applyOpenStateToFastTree(): void {
|
|
const applyToNodes = (nodes: VaultNode[]) => {
|
|
for (const node of nodes) {
|
|
if (node.type === 'folder') {
|
|
node.isOpen = this.openFolderPaths().has(node.path);
|
|
applyToNodes(node.children);
|
|
}
|
|
}
|
|
};
|
|
|
|
const current = this.fastTreeSignal();
|
|
if (current.length === 0) return;
|
|
|
|
applyToNodes(current);
|
|
this.fastTreeSignal.set([...current]); // Trigger change detection
|
|
}
|
|
|
|
// ========================================
|
|
// FILE TREE BUILDERS
|
|
// ========================================
|
|
|
|
private buildFileTree(): VaultNode[] {
|
|
const root = this.createRootFolder();
|
|
const folders = new Map<string, VaultFolder>([['', root]]);
|
|
const openFolders = this.openFolderPaths();
|
|
|
|
const sortedNotes = this.getSortedNotes();
|
|
|
|
for (const note of sortedNotes) {
|
|
if (this.isBuiltinPath(note.originalPath) || this.isBuiltinPath(note.filePath)) {
|
|
continue;
|
|
}
|
|
const segments = note.originalPath.split('/').filter(Boolean);
|
|
const folderSegments = segments.slice(0, -1);
|
|
|
|
const parentFolder = this.ensureFolderPath(folderSegments, folders, root, openFolders);
|
|
this.addFileNode(parentFolder, note.filePath, note.id, note.fileName);
|
|
}
|
|
|
|
this.sortAndCleanFolderChildren(root);
|
|
return root.children;
|
|
}
|
|
|
|
private buildTrashTree(): VaultNode[] {
|
|
const root = this.createFolder(TRASH_FOLDER, TRASH_FOLDER, true);
|
|
const folders = new Map<string, VaultFolder>([[TRASH_FOLDER, root]]);
|
|
const openFolders = this.openFolderPaths();
|
|
|
|
for (const note of this.allNotes()) {
|
|
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
|
if (!this.isInTrash(filePath)) continue;
|
|
|
|
const segments = this.parseTrashFolderSegments(filePath);
|
|
const parentFolder = segments
|
|
? this.ensureTrashFolderPath(segments, folders, root, openFolders)
|
|
: root;
|
|
|
|
this.addFileNode(parentFolder, note.filePath, note.id, note.fileName);
|
|
}
|
|
|
|
this.sortAndCleanFolderChildren(root);
|
|
return root.children;
|
|
}
|
|
|
|
private ensureFolderPath(
|
|
segments: string[],
|
|
folders: Map<string, VaultFolder>,
|
|
root: VaultFolder,
|
|
openFolders: Set<string>
|
|
): VaultFolder {
|
|
let currentPath = '';
|
|
let parentFolder = root;
|
|
|
|
for (const segment of segments) {
|
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
|
|
let folder = folders.get(currentPath);
|
|
if (!folder) {
|
|
folder = this.createFolder(segment, currentPath, openFolders.has(currentPath));
|
|
folders.set(currentPath, folder);
|
|
parentFolder.children.push(folder);
|
|
} else {
|
|
folder.isOpen = openFolders.has(currentPath);
|
|
}
|
|
|
|
parentFolder = folder;
|
|
}
|
|
|
|
return parentFolder;
|
|
}
|
|
|
|
private ensureTrashFolderPath(
|
|
segments: string[],
|
|
folders: Map<string, VaultFolder>,
|
|
root: VaultFolder,
|
|
openFolders: Set<string>
|
|
): VaultFolder {
|
|
let currentPath = TRASH_FOLDER;
|
|
let parentFolder = root;
|
|
|
|
for (const segment of segments) {
|
|
if (!segment) continue;
|
|
|
|
currentPath = `${currentPath}/${segment}`;
|
|
|
|
let folder = folders.get(currentPath);
|
|
if (!folder) {
|
|
folder = this.createFolder(segment, currentPath, openFolders.has(currentPath));
|
|
folders.set(currentPath, folder);
|
|
parentFolder.children.push(folder);
|
|
} else {
|
|
folder.isOpen = openFolders.has(currentPath);
|
|
}
|
|
|
|
parentFolder = folder;
|
|
}
|
|
|
|
return parentFolder;
|
|
}
|
|
|
|
// ========================================
|
|
// COUNTS CALCULATION
|
|
// ========================================
|
|
|
|
private calculateQuickLinkCounts(): QuickLinkCounts {
|
|
const counts: QuickLinkCounts = {
|
|
all: 0,
|
|
favorites: 0,
|
|
publish: 0,
|
|
templates: 0,
|
|
tasks: 0,
|
|
drafts: 0,
|
|
private: 0,
|
|
archive: 0,
|
|
trash: 0
|
|
};
|
|
|
|
for (const note of this.allNotes()) {
|
|
const path = note.filePath || note.originalPath || '';
|
|
|
|
if (this.isInTrash(path)) {
|
|
counts.trash++;
|
|
continue;
|
|
}
|
|
|
|
counts.all++;
|
|
const fm = note.frontmatter || {};
|
|
|
|
if (fm.favoris === true) counts.favorites++;
|
|
if (fm.publish === true) counts.publish++;
|
|
if (fm.template === true) counts.templates++;
|
|
if (fm.task === true) counts.tasks++;
|
|
if (fm.draft === true) counts.drafts++;
|
|
if (fm.private === true) counts.private++;
|
|
if (fm.archive === true) counts.archive++;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
private calculateFolderCounts(): Record<string, number> {
|
|
const counts: Record<string, number> = {};
|
|
|
|
for (const note of this.allNotes()) {
|
|
const path = this.normalizePath(note.originalPath || note.filePath || '');
|
|
if (!path || this.isInTrash(path) || this.isBuiltinPath(path)) continue;
|
|
|
|
const parts = path.split('/');
|
|
parts.pop(); // Remove filename
|
|
|
|
let acc = '';
|
|
for (const segment of parts) {
|
|
if (!segment) continue;
|
|
acc = acc ? `${acc}/${segment}` : segment;
|
|
counts[acc] = (counts[acc] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
private calculateTrashFolderCounts(): Record<string, number> {
|
|
const counts: Record<string, number> = {};
|
|
|
|
const increment = (path: string) => {
|
|
const raw = this.normalizePath(path);
|
|
const norm = raw.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
counts[norm] = (counts[norm] ?? 0) + 1;
|
|
};
|
|
|
|
for (const note of this.allNotes()) {
|
|
const filePath = this.normalizePath(note.filePath || note.originalPath || '');
|
|
if (!this.isInTrash(filePath)) continue;
|
|
|
|
const segments = this.parseTrashFolderSegments(filePath);
|
|
|
|
// Always count the root .trash bucket
|
|
increment(TRASH_FOLDER);
|
|
|
|
if (!segments || segments.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
let current = TRASH_FOLDER;
|
|
for (const segment of segments) {
|
|
current = `${current}/${segment}`;
|
|
increment(current);
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
// ========================================
|
|
// GRAPH DATA
|
|
// ========================================
|
|
|
|
private buildGraphData(): GraphData {
|
|
const startTime = performance.now();
|
|
const notes = this.allNotes();
|
|
|
|
const nodes = notes.map(note => ({ id: note.id, label: note.title }));
|
|
const edges: { source: string; target: string }[] = [];
|
|
|
|
// Build fast lookup indices
|
|
const noteById = new Map(notes.map(n => [n.id, n]));
|
|
const noteByTitle = new Map(notes.map(n => [n.title, n]));
|
|
const notesByAlias = this.buildAliasIndex(notes);
|
|
|
|
// Extract links efficiently
|
|
for (const note of notes) {
|
|
const matches = note.content.matchAll(/\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g);
|
|
|
|
for (const match of matches) {
|
|
const rawLink = match[1];
|
|
const linkPath = rawLink.toLowerCase().replace(/\s+/g, '-');
|
|
|
|
const targetNote = noteById.get(linkPath)
|
|
|| noteByTitle.get(rawLink)
|
|
|| notesByAlias.get(rawLink);
|
|
|
|
if (targetNote && targetNote.id !== note.id) {
|
|
edges.push({ source: note.id, target: targetNote.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
const duration = performance.now() - startTime;
|
|
console.log(`[GraphData] Computed in ${duration.toFixed(2)}ms: ${nodes.length} nodes, ${edges.length} edges`);
|
|
|
|
return { nodes, edges };
|
|
}
|
|
|
|
private buildAliasIndex(notes: Note[]): Map<string, Note> {
|
|
const index = new Map<string, Note>();
|
|
|
|
for (const note of notes) {
|
|
const aliases = note.frontmatter?.aliases;
|
|
if (Array.isArray(aliases)) {
|
|
for (const alias of aliases as string[]) {
|
|
index.set(alias, note);
|
|
}
|
|
}
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
// ========================================
|
|
// TAGS EXTRACTION
|
|
// ========================================
|
|
|
|
private extractTags(): TagInfo[] {
|
|
const tagCounts = new Map<string, number>();
|
|
|
|
for (const note of this.allNotes()) {
|
|
for (const tag of note.tags) {
|
|
if (this.isValidTag(tag)) {
|
|
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(tagCounts.entries())
|
|
.map(([name, count]) => ({ name, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
}
|
|
|
|
private isValidTag(raw: string): boolean {
|
|
if (!raw) return false;
|
|
|
|
const tag = String(raw).trim();
|
|
if (!tag) return false;
|
|
if (TAG_VALIDATION.INVALID_CHARS.test(tag)) return false;
|
|
if (TAG_VALIDATION.NUMERIC_ONLY.test(tag)) return false;
|
|
if (TAG_VALIDATION.HEX_LIKE.test(tag)) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// ========================================
|
|
// VAULT EVENTS
|
|
// ========================================
|
|
|
|
private observeVaultEvents(): void {
|
|
this.vaultEventsSubscription = this.vaultEvents.events$().subscribe({
|
|
next: (event) => this.handleVaultEvent(event),
|
|
error: (error) => console.error('Vault events stream error:', error)
|
|
});
|
|
}
|
|
|
|
private handleVaultEvent(event: VaultEventPayload): void {
|
|
if (!event?.event) return;
|
|
|
|
const refreshEvents = ['add', 'change', 'unlink', 'addDir', 'unlinkDir', 'ready', 'connected'];
|
|
|
|
if (refreshEvents.includes(event.event)) {
|
|
this.scheduleRefresh();
|
|
} else if (event.event === 'error') {
|
|
console.error('Vault watcher error:', event.message ?? 'Unknown error');
|
|
}
|
|
}
|
|
|
|
private scheduleRefresh(): void {
|
|
if (this.refreshTimeoutId !== null) {
|
|
clearTimeout(this.refreshTimeoutId);
|
|
}
|
|
|
|
this.refreshTimeoutId = setTimeout(() => {
|
|
this.refreshTimeoutId = null;
|
|
// Refresh notes contents and also reload the fast file tree to reflect new/removed folders
|
|
this.refreshNotes();
|
|
this.loadFastFileTree();
|
|
}, REFRESH_DEBOUNCE_MS);
|
|
}
|
|
|
|
// ========================================
|
|
// NOTES REFRESH
|
|
// ========================================
|
|
|
|
private refreshNotes(): void {
|
|
this.http.get<VaultApiResponse>(VAULT_API_ENDPOINT).subscribe({
|
|
next: ({ notes }) => this.processApiNotes(notes),
|
|
error: (error) => {
|
|
console.error('Failed to load vault notes from API', error);
|
|
this.notesMap.set(new Map());
|
|
}
|
|
});
|
|
}
|
|
|
|
private processApiNotes(apiNotes: VaultApiNote[]): void {
|
|
const newNotesMap = new Map<string, Note>();
|
|
let detectedVaultName: string | null = null;
|
|
|
|
for (const apiNote of apiNotes) {
|
|
const note = this.convertApiNoteToNote(apiNote);
|
|
newNotesMap.set(note.id, note);
|
|
|
|
const vaultName = this.extractVaultName(note);
|
|
if (vaultName) detectedVaultName = vaultName;
|
|
}
|
|
|
|
this.computeBacklinks(newNotesMap);
|
|
this.notesMap.set(newNotesMap);
|
|
|
|
if (detectedVaultName) {
|
|
this.vaultNameSignal.set(this.formatVaultName(detectedVaultName));
|
|
}
|
|
}
|
|
|
|
private convertApiNoteToNote(apiNote: VaultApiNote): Note {
|
|
const normalizedContent = this.normalizeLineEndings(apiNote.content);
|
|
const { frontmatter, body } = this.parseFrontmatter(normalizedContent);
|
|
|
|
const derivedTitle = this.extractTitle(body, apiNote.id);
|
|
const title = frontmatter.title || apiNote.title || derivedTitle;
|
|
|
|
const originalPath = this.normalizePath(
|
|
apiNote.originalPath ?? apiNote.filePath?.replace(/\.md$/i, '') ?? apiNote.id
|
|
);
|
|
|
|
const fileName = apiNote.fileName ?? this.deriveFileName(originalPath, apiNote);
|
|
const filePath = apiNote.filePath ?? `${originalPath}.md`;
|
|
|
|
const tags = this.extractAllTags(apiNote, frontmatter, body);
|
|
const updatedAt = apiNote.updatedAt || new Date(frontmatter.mtime || apiNote.mtime || Date.now()).toISOString();
|
|
|
|
return {
|
|
id: apiNote.id,
|
|
title,
|
|
content: body,
|
|
rawContent: normalizedContent,
|
|
tags: Array.from(tags),
|
|
frontmatter,
|
|
backlinks: [],
|
|
mtime: frontmatter.mtime || apiNote.mtime || Date.now(),
|
|
fileName,
|
|
filePath,
|
|
originalPath,
|
|
createdAt: apiNote.createdAt,
|
|
updatedAt
|
|
};
|
|
}
|
|
|
|
private extractAllTags(apiNote: VaultApiNote, frontmatter: any, body: string): Set<string> {
|
|
const tags = new Set<string>();
|
|
|
|
// API tags
|
|
if (Array.isArray(apiNote.tags)) {
|
|
apiNote.tags.forEach(tag => tags.add(tag));
|
|
}
|
|
|
|
// Frontmatter tags
|
|
if (Array.isArray(frontmatter.tags)) {
|
|
(frontmatter.tags as string[]).forEach(tag => tags.add(tag));
|
|
}
|
|
|
|
// Inline hashtags
|
|
this.extractInlineTags(body).forEach(tag => tags.add(tag));
|
|
|
|
return tags;
|
|
}
|
|
|
|
private extractInlineTags(body: string): Set<string> {
|
|
const tags = new Set<string>();
|
|
const lines = (body || '').split('\n');
|
|
let inCodeBlock = false;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
|
|
if (TAG_VALIDATION.CODE_FENCE.test(trimmed)) {
|
|
inCodeBlock = !inCodeBlock;
|
|
continue;
|
|
}
|
|
|
|
if (inCodeBlock) continue;
|
|
|
|
const matches = line.matchAll(TAG_VALIDATION.INLINE_TAG);
|
|
for (const match of matches) {
|
|
tags.add(match[2]);
|
|
}
|
|
}
|
|
|
|
return tags;
|
|
}
|
|
|
|
private deriveFileName(originalPath: string, apiNote: VaultApiNote): string {
|
|
const parts = originalPath.split('/').filter(Boolean);
|
|
const lastSegment = parts.pop();
|
|
|
|
if (apiNote.filePath) {
|
|
return apiNote.filePath.split('/').pop() ?? `${lastSegment ?? apiNote.id}.md`;
|
|
}
|
|
|
|
return `${lastSegment ?? apiNote.id}.md`;
|
|
}
|
|
|
|
// ========================================
|
|
// BACKLINKS
|
|
// ========================================
|
|
|
|
private computeBacklinks(notesMap: Map<string, Note>): void {
|
|
const allNotes = Array.from(notesMap.values());
|
|
|
|
// Reset backlinks
|
|
allNotes.forEach(note => note.backlinks = []);
|
|
|
|
// Build lookup indices
|
|
const noteById = new Map(allNotes.map(n => [n.id, n]));
|
|
const noteByTitle = new Map(allNotes.map(n => [n.title, n]));
|
|
const notesByAlias = this.buildAliasIndex(allNotes);
|
|
|
|
for (const note of allNotes) {
|
|
const matches = note.content.matchAll(/\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g);
|
|
|
|
for (const match of matches) {
|
|
const rawLink = match[1];
|
|
const linkPath = rawLink.toLowerCase().replace(/\s+/g, '-');
|
|
|
|
const targetNote = noteById.get(linkPath)
|
|
|| noteByTitle.get(rawLink)
|
|
|| notesByAlias.get(rawLink);
|
|
|
|
if (targetNote && targetNote.id !== note.id) {
|
|
const target = notesMap.get(targetNote.id);
|
|
if (target && !target.backlinks.includes(note.id)) {
|
|
target.backlinks.push(note.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// FRONTMATTER PARSING
|
|
// ========================================
|
|
|
|
private parseFrontmatter(content: string): { frontmatter: Record<string, any>; body: string } {
|
|
const sanitized = content.replace(/^\uFEFF/, '');
|
|
const normalized = this.normalizeLineEndings(sanitized);
|
|
|
|
const match = normalized.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
|
|
if (!match) {
|
|
return { frontmatter: {}, body: content.trim() };
|
|
}
|
|
|
|
const [, frontmatterText, body] = match;
|
|
const frontmatter = this.parseFrontmatterLines(frontmatterText.split('\n'));
|
|
|
|
return { frontmatter, body: body.trim() };
|
|
}
|
|
|
|
private parseFrontmatterLines(lines: string[]): Record<string, any> {
|
|
const frontmatter: Record<string, any> = {};
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
if (!line.trim() || line.trim().startsWith('#')) continue;
|
|
|
|
const colonIndex = line.indexOf(':');
|
|
if (colonIndex === -1) continue;
|
|
|
|
const key = line.slice(0, colonIndex).trim();
|
|
let valuePart = line.slice(colonIndex + 1).trim();
|
|
|
|
if (!key) continue;
|
|
|
|
// Handle list values
|
|
if (!valuePart) {
|
|
const { values, nextIndex } = this.parseListValues(lines, i + 1);
|
|
if (values.length) {
|
|
frontmatter[key] = values;
|
|
i = nextIndex - 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Handle array syntax [a, b, c]
|
|
if (valuePart.startsWith('[') && valuePart.endsWith(']')) {
|
|
const content = valuePart.slice(1, -1);
|
|
frontmatter[key] = content.split(',').map(v => this.parseFrontmatterValue(v.trim()));
|
|
continue;
|
|
}
|
|
|
|
frontmatter[key] = this.parseFrontmatterValue(valuePart);
|
|
}
|
|
|
|
return frontmatter;
|
|
}
|
|
|
|
private parseListValues(lines: string[], startIndex: number): { values: any[]; nextIndex: number } {
|
|
const values: any[] = [];
|
|
let j = startIndex;
|
|
|
|
while (j < lines.length) {
|
|
const line = lines[j];
|
|
|
|
if (!line.trim()) break;
|
|
|
|
if (/^\s*-\s+/.test(line)) {
|
|
const item = line.replace(/^\s*-\s+/, '').trim();
|
|
values.push(this.parseFrontmatterValue(item));
|
|
j++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return { values, nextIndex: j };
|
|
}
|
|
|
|
private parseFrontmatterValue(rawValue: string): any {
|
|
const trimmed = rawValue.trim();
|
|
if (!trimmed) return '';
|
|
|
|
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
|
|
|
// Boolean
|
|
if (/^(true|false)$/i.test(unquoted)) {
|
|
return unquoted.toLowerCase() === 'true';
|
|
}
|
|
|
|
// Number
|
|
const numeric = Number(unquoted);
|
|
if (!isNaN(numeric) && unquoted !== '') {
|
|
return numeric;
|
|
}
|
|
|
|
return unquoted;
|
|
}
|
|
|
|
// ========================================
|
|
// HELPER FUNCTIONS
|
|
// ========================================
|
|
|
|
private extractTitle(content: string, fallback: string): string {
|
|
const match = content.match(/^#\s+(.*)/);
|
|
if (match) return match[1];
|
|
|
|
return fallback
|
|
.split('/')
|
|
.pop()!
|
|
.replace(/-/g, ' ')
|
|
.replace(/\b\w/g, l => l.toUpperCase());
|
|
}
|
|
|
|
private parseTrashFolderSegments(path: string): string[] | null {
|
|
if (!path) return null;
|
|
|
|
const parts = path.split('/').filter(Boolean);
|
|
let trashIndex = parts.indexOf(TRASH_FOLDER);
|
|
|
|
if (trashIndex === -1) {
|
|
const vaultIndex = parts.indexOf('vault');
|
|
if (vaultIndex !== -1 && parts[vaultIndex + 1] === TRASH_FOLDER) {
|
|
trashIndex = vaultIndex + 1;
|
|
}
|
|
}
|
|
|
|
if (trashIndex === -1) return null;
|
|
|
|
const afterTrash = parts.slice(trashIndex + 1);
|
|
if (afterTrash.length === 0) return null;
|
|
|
|
const result = afterTrash
|
|
.slice(0, -1)
|
|
.map(segment => {
|
|
try {
|
|
return decodeURIComponent(segment);
|
|
} catch {
|
|
return segment;
|
|
}
|
|
})
|
|
.map(s => s.trim())
|
|
.filter(Boolean);
|
|
|
|
console.log('🔍 [parseTrashFolderSegments]', {
|
|
input: path,
|
|
parts,
|
|
trashIndex,
|
|
afterTrash,
|
|
result
|
|
});
|
|
|
|
return result.length > 0 ? result : null;
|
|
}
|
|
|
|
buildSlugIdFromPath(filePath: string): string {
|
|
const noExt = filePath
|
|
.replace(/\\/g, '/')
|
|
.replace(/\.(md|excalidraw(?:\.md)?)$/i, '');
|
|
|
|
const segments = noExt.split('/').filter(Boolean);
|
|
const slugSegments = segments.map(seg => this.slugifySegment(seg));
|
|
|
|
return slugSegments.join('/');
|
|
}
|
|
|
|
private slugifySegment(segment: string): string {
|
|
const normalized = segment
|
|
.normalize('NFKD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.trim();
|
|
|
|
const slug = normalized
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
return slug || normalized.toLowerCase() || segment.toLowerCase();
|
|
}
|
|
|
|
private normalizePath(path: string): string {
|
|
return (path || '').replace(/\\/g, '/');
|
|
}
|
|
|
|
private isBuiltinPath(path: string): boolean {
|
|
const normalized = this.normalizePath(path).replace(/^\/+/g, '');
|
|
if (!normalized) return false;
|
|
const [firstSegment] = normalized.split('/');
|
|
return firstSegment === '__builtin__';
|
|
}
|
|
|
|
private normalizeLineEndings(content: string): string {
|
|
return content.replace(/\r\n/g, '\n');
|
|
}
|
|
|
|
private isInTrash(path: string): boolean {
|
|
const normalized = this.normalizePath(path).replace(/^\/+/, '');
|
|
return (
|
|
normalized.startsWith(`${TRASH_FOLDER}/`) ||
|
|
normalized.includes(`/${TRASH_FOLDER}/`)
|
|
);
|
|
}
|
|
|
|
private normalizeTags(tags: string[]): string[] {
|
|
return Array.from(new Set(
|
|
(tags || [])
|
|
.map(t => String(t).trim())
|
|
.filter(Boolean)
|
|
));
|
|
}
|
|
|
|
// ========================================
|
|
// VAULT NAME RESOLUTION
|
|
// ========================================
|
|
|
|
private resolveInitialVaultName(): string {
|
|
const homeFile = (window as any)?.APP_CONFIG?.vault?.home;
|
|
|
|
if (homeFile && typeof homeFile === 'string') {
|
|
const segments = homeFile.split(/[\/]/).filter(Boolean);
|
|
if (segments.length) {
|
|
return this.formatVaultName(segments[segments.length - 1]);
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const appConfig = (window as any).APP_CONFIG;
|
|
const explicitName = appConfig?.vault?.name;
|
|
|
|
if (explicitName && typeof explicitName === 'string') {
|
|
return explicitName;
|
|
}
|
|
|
|
const configuredPath = appConfig?.vault?.path;
|
|
if (configuredPath && typeof configuredPath === 'string') {
|
|
const segments = configuredPath.split(/[\\/]/).filter(Boolean);
|
|
if (segments.length) {
|
|
return this.formatVaultName(segments[segments.length - 1]);
|
|
}
|
|
}
|
|
}
|
|
|
|
return 'Vault';
|
|
}
|
|
|
|
private extractVaultName(note: Note): string | null {
|
|
const { frontmatter, filePath, originalPath } = note;
|
|
|
|
if (!this.shouldExtractVaultName(frontmatter, filePath, originalPath)) {
|
|
return null;
|
|
}
|
|
|
|
const candidateName = frontmatter.NomDeVoute
|
|
|| frontmatter.nomdevoute
|
|
|| frontmatter.vaultName;
|
|
|
|
return typeof candidateName === 'string' && candidateName.trim()
|
|
? candidateName.trim()
|
|
: null;
|
|
}
|
|
|
|
private shouldExtractVaultName(
|
|
frontmatter: Record<string, any>,
|
|
filePath?: string,
|
|
originalPath?: string
|
|
): boolean {
|
|
const hasVaultNameField = typeof frontmatter?.NomDeVoute === 'string'
|
|
|| typeof frontmatter?.nomdevoute === 'string'
|
|
|| typeof frontmatter?.vaultName === 'string';
|
|
|
|
if (!hasVaultNameField) return false;
|
|
|
|
const normalizedPath = this.normalizePath(filePath ?? originalPath ?? '');
|
|
const pathSegments = normalizedPath
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map(segment => segment.toLowerCase());
|
|
|
|
if (pathSegments.length === 0) return false;
|
|
|
|
const lastSegment = pathSegments[pathSegments.length - 1];
|
|
return pathSegments.length === 1 && (lastSegment === 'home.md' || lastSegment === 'home');
|
|
}
|
|
|
|
private formatVaultName(value: string): string {
|
|
return value
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\b\w/g, char => char.toUpperCase());
|
|
}
|
|
|
|
// ========================================
|
|
// FOLDER & NODE CREATION
|
|
// ========================================
|
|
|
|
private createRootFolder(): VaultFolder {
|
|
return {
|
|
type: 'folder',
|
|
name: 'root',
|
|
path: '',
|
|
children: [],
|
|
isOpen: true
|
|
};
|
|
}
|
|
|
|
private createFolder(name: string, path: string, isOpen: boolean = false): VaultFolder {
|
|
return {
|
|
type: 'folder',
|
|
name: name || '(unnamed)',
|
|
path,
|
|
children: [],
|
|
isOpen: isOpen || this.openFolderPaths().has(path)
|
|
};
|
|
}
|
|
|
|
private addFileNode(
|
|
folder: VaultFolder,
|
|
filePath: string,
|
|
id: string,
|
|
fileName?: string
|
|
): void {
|
|
const parts = filePath.split('/').filter(Boolean);
|
|
const name = fileName || parts[parts.length - 1] || filePath;
|
|
const normalizedPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
|
|
|
folder.children.push({
|
|
type: 'file',
|
|
name,
|
|
path: normalizedPath,
|
|
id
|
|
});
|
|
}
|
|
|
|
// ========================================
|
|
// SORTING
|
|
// ========================================
|
|
|
|
private sortFolderChildren(folder: VaultFolder): void {
|
|
folder.children.sort((a, b) => {
|
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
});
|
|
|
|
for (const child of folder.children) {
|
|
if (child.type === 'folder') {
|
|
this.sortFolderChildren(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sortAndCleanFolderChildren(folder: VaultFolder): void {
|
|
const cleanedChildren: VaultNode[] = [];
|
|
|
|
for (const child of folder.children) {
|
|
if (child.type === 'folder') {
|
|
// Skip trash folder
|
|
if (child.name === TRASH_FOLDER) continue;
|
|
|
|
this.sortAndCleanFolderChildren(child);
|
|
cleanedChildren.push(child);
|
|
} else {
|
|
cleanedChildren.push(child);
|
|
}
|
|
}
|
|
|
|
folder.children = cleanedChildren;
|
|
|
|
folder.children.sort((a, b) => {
|
|
if (a.type === 'folder' && b.type === 'file') return -1;
|
|
if (a.type === 'file' && b.type === 'folder') return 1;
|
|
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
|
});
|
|
}
|
|
|
|
private getSortedNotes(): Note[] {
|
|
return this.allNotes()
|
|
.slice()
|
|
.sort((a, b) =>
|
|
a.originalPath.localeCompare(b.originalPath, undefined, { sensitivity: 'base' })
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// NOTE OPERATIONS
|
|
// ========================================
|
|
|
|
private async fetchNoteContent(path: string): Promise<string> {
|
|
const url = `/vault/${encodeURI(path)}`;
|
|
const raw = await firstValueFrom(
|
|
this.http.get(url, { responseType: 'text' as any })
|
|
);
|
|
return this.normalizeLineEndings(String(raw));
|
|
}
|
|
|
|
private parseNoteFromContent(rawContent: string, slugId: string, path: string): Note {
|
|
const { frontmatter, body } = this.parseFrontmatter(rawContent);
|
|
const derivedTitle = this.extractTitle(body, slugId);
|
|
const title = (frontmatter.title as string) || derivedTitle;
|
|
const meta = this.metaByPathIndex.get(path);
|
|
const fileName = path.split('/').pop() ?? `${slugId}.md`;
|
|
const updatedAt = meta?.updatedAt ?? new Date().toISOString();
|
|
|
|
return {
|
|
id: slugId,
|
|
title,
|
|
content: body,
|
|
rawContent,
|
|
tags: Array.isArray(frontmatter.tags) ? (frontmatter.tags as string[]) : [],
|
|
frontmatter,
|
|
backlinks: [],
|
|
mtime: Date.now(),
|
|
fileName,
|
|
filePath: path,
|
|
originalPath: path.replace(/\.md$/i, ''),
|
|
createdAt: meta?.createdAt,
|
|
updatedAt
|
|
};
|
|
}
|
|
|
|
private addNoteToMap(note: Note): void {
|
|
const current = new Map(this.notesMap());
|
|
current.set(note.id, note);
|
|
this.notesMap.set(current);
|
|
}
|
|
|
|
/** Register a builtin (virtual) note without touching file indices. */
|
|
private registerBuiltinNote(note: Note): void {
|
|
// Do not update fast indices that rely on physical paths
|
|
const current = new Map(this.notesMap());
|
|
current.set(note.id, note);
|
|
this.notesMap.set(current);
|
|
}
|
|
|
|
private updateNoteInMap(note: Note, updates: Partial<Note>): void {
|
|
const updated: Note = { ...note, ...updates };
|
|
const mapCopy = new Map(this.notesMap());
|
|
mapCopy.set(updated.id, updated);
|
|
this.notesMap.set(mapCopy);
|
|
}
|
|
|
|
// ========================================
|
|
// FILE OPERATIONS
|
|
// ========================================
|
|
|
|
private async saveMarkdown(filePath: string, content: string): Promise<boolean> {
|
|
try {
|
|
const url = `/api/files?path=${encodeURIComponent(filePath)}`;
|
|
await firstValueFrom(
|
|
this.http.put(url, content, {
|
|
headers: { 'Content-Type': 'text/markdown' }
|
|
})
|
|
);
|
|
return true;
|
|
} catch (e) {
|
|
console.error('[VaultService] saveMarkdown failed', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private recomposeMarkdownFromNote(note: Note): string {
|
|
try {
|
|
const frontmatter = note.frontmatter || {};
|
|
const lines: string[] = ['---'];
|
|
|
|
for (const [key, value] of Object.entries(frontmatter)) {
|
|
if (key === 'tags') continue; // Handled by rewriteTagsFrontmatter
|
|
|
|
if (Array.isArray(value)) {
|
|
lines.push(`${key}:`);
|
|
for (const item of value) {
|
|
lines.push(` - ${item}`);
|
|
}
|
|
} else {
|
|
lines.push(`${key}: ${value}`);
|
|
}
|
|
}
|
|
|
|
lines.push('---', this.normalizeLineEndings(note.content ?? ''));
|
|
return lines.join('\n');
|
|
} catch {
|
|
return note.content ?? '';
|
|
}
|
|
}
|
|
} |