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 } from '../app/shared/markdown/markdown-frontmatter.util'; // ============================================================================ // 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; templates: number; tasks: number; drafts: 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>(new Map()); private fastTreeSignal = signal([]); private openFolderPaths = signal(new Set()); private vaultNameSignal = signal(this.resolveInitialVaultName()); // Fast lookup indices for metadata private idToPathIndex = new Map(); private slugIdToPathIndex = new Map(); private metaByPathIndex = new Map(); // Subscription management private vaultEventsSubscription: Subscription | null = null; private refreshTimeoutId: ReturnType | null = null; // ======================================== // COMPUTED SIGNALS // ======================================== readonly allNotes = computed(() => Array.from(this.notesMap().values())); readonly trashNotes = computed(() => this.allNotes().filter(note => this.isInTrash(note.filePath || note.originalPath || '')) ); readonly counts = computed(() => this.calculateQuickLinkCounts() ); readonly folderCounts = computed>(() => this.calculateFolderCounts() ); readonly trashFolderCounts = computed>(() => this.calculateTrashFolderCounts() ); readonly vaultName = computed(() => this.vaultNameSignal()); readonly fastFileTree = computed(() => this.fastTreeSignal()); readonly fileTree = computed(() => this.buildFileTree() ); readonly trashTree = computed(() => this.buildTrashTree() ); readonly graphData = computed(() => this.buildGraphData() ); readonly tags = computed(() => this.extractTags() ); // ======================================== // CONSTRUCTOR & LIFECYCLE // ======================================== constructor( private http: HttpClient, private vaultEvents: VaultEventsService ) { this.initialize(); } 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); } async ensureNoteLoadedById(id: string): Promise { 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 { 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 { 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(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([['', root]]); for (const item of items) { if (!item?.path) continue; const path = this.normalizePath(item.path); 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, 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([['', root]]); const openFolders = this.openFolderPaths(); const sortedNotes = this.getSortedNotes(); for (const note of sortedNotes) { 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([[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, root: VaultFolder, openFolders: Set ): 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, root: VaultFolder, openFolders: Set ): 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, templates: 0, tasks: 0, drafts: 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.template === true) counts.templates++; if (fm.task === true) counts.tasks++; if (fm.draft === true) counts.drafts++; if (fm.archive === true) counts.archive++; } return counts; } private calculateFolderCounts(): Record { const counts: Record = {}; for (const note of this.allNotes()) { const path = this.normalizePath(note.originalPath || note.filePath || ''); if (!path || this.isInTrash(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 { const counts: Record = {}; 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 { const index = new Map(); 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(); 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; this.refreshNotes(); }, REFRESH_DEBOUNCE_MS); } // ======================================== // NOTES REFRESH // ======================================== private refreshNotes(): void { this.http.get(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(); 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 { const tags = new Set(); // 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 { const tags = new Set(); 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): 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; 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 { const frontmatter: Record = {}; 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 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, 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 { 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); } private updateNoteInMap(note: Note, updates: Partial): 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 { 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 ?? ''; } } }