import { Injectable, signal, computed, OnDestroy } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types'; import { VaultEventsService, VaultEventPayload } from './vault-events.service'; import { Subscription } from 'rxjs'; 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[]; } @Injectable({ providedIn: 'root' }) export class VaultService implements OnDestroy { private notesMap = signal>(new Map()); private openFolderPaths = signal(new Set()); private initialVaultName = this.resolveVaultName(); allNotes = computed(() => Array.from(this.notesMap().values())); vaultName = signal(this.initialVaultName); fileTree = computed(() => { const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true }; const folders = new Map([['', root]]); const openFolders = this.openFolderPaths(); const sortedNotes = this.allNotes().slice().sort((a, b) => { return a.originalPath.localeCompare(b.originalPath, undefined, { sensitivity: 'base' }); }); for (const note of sortedNotes) { const originalSegments = note.originalPath.split('/').filter(Boolean); const folderSegments = originalSegments.slice(0, -1); let currentPath = ''; let parentFolder = root; for (const segment of folderSegments) { currentPath = currentPath ? `${currentPath}/${segment}` : segment; let folder = folders.get(currentPath); if (!folder) { folder = { type: 'folder', name: segment, path: currentPath, children: [], isOpen: openFolders.has(currentPath) }; folders.set(currentPath, folder); parentFolder.children.push(folder); } else { folder.isOpen = openFolders.has(currentPath); } parentFolder = folder; } parentFolder.children.push({ type: 'file', name: note.fileName, path: note.filePath.startsWith('/') ? note.filePath : `/${note.filePath}`, id: note.id }); } const sortChildren = (node: VaultFolder) => { node.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' }); }); node.children.forEach(child => { if (child.type === 'folder') { sortChildren(child); } }); }; sortChildren(root); return root.children; }); graphData = computed(() => { const notes = this.allNotes(); const nodes = notes.map(note => ({ id: note.id, label: note.title })); const edges: { source: string, target: string }[] = []; for (const note of notes) { const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g; let match; while ((match = linkRegex.exec(note.content)) !== null) { const linkPath = match[1].toLowerCase().replace(/\s+/g, '-'); const targetNote = notes.find(n => n.id === linkPath || n.title === match[1] || (n.frontmatter?.aliases as string[])?.includes(match[1])); if (targetNote && targetNote.id !== note.id) { edges.push({ source: note.id, target: targetNote.id }); } } } return { nodes, edges }; }); tags = computed(() => { const tagCounts = new Map(); for (const note of this.allNotes()) { for (const tag of note.tags) { 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 vaultEventsSubscription: Subscription | null = null; private refreshTimeoutId: ReturnType | null = null; constructor(private http: HttpClient, private vaultEvents: VaultEventsService) { this.refreshNotes(); this.observeVaultEvents(); } ngOnDestroy(): void { this.vaultEventsSubscription?.unsubscribe(); this.vaultEventsSubscription = null; if (this.refreshTimeoutId !== null) { clearTimeout(this.refreshTimeoutId); this.refreshTimeoutId = null; } } getNoteById(id: string): Note | undefined { return this.notesMap().get(id); } toggleFolder(path: string): void { this.openFolderPaths.update(paths => { const newPaths = new Set(paths); if (newPaths.has(path)) { newPaths.delete(path); } else { newPaths.add(path); } return newPaths; }); } 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); } refresh(): void { this.refreshNotes(); } 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 || typeof event.event !== 'string') { return; } switch (event.event) { case 'add': case 'change': case 'unlink': case 'addDir': case 'unlinkDir': this.scheduleRefresh(); break; case 'ready': case 'connected': // Initial ready/connected events can trigger a refresh to ensure state is up-to-date. this.scheduleRefresh(); break; case 'error': console.error('Vault watcher reported error:', event.message ?? 'Unknown watcher error'); break; default: break; } } private scheduleRefresh(): void { if (this.refreshTimeoutId !== null) { return; } this.refreshTimeoutId = setTimeout(() => { this.refreshTimeoutId = null; this.refreshNotes(); }, 300); } private refreshNotes() { this.http.get('/api/vault').subscribe({ next: ({ notes }) => { const newNotesMap = new Map(); let detectedHomeVaultName: string | null = null; for (const apiNote of notes) { const { frontmatter, body } = this.parseFrontmatter(apiNote.content); const derivedTitle = this.extractTitle(body, apiNote.id); const noteTitle = frontmatter.title || apiNote.title || derivedTitle; const originalPath = (apiNote.originalPath ?? apiNote.filePath?.replace(/\.md$/i, '') ?? apiNote.id).replace(/\\/g, '/'); const fileName = apiNote.fileName ?? (() => { 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`; })(); const filePath = apiNote.filePath ?? `${originalPath}.md`; const tagSet = new Set(); if (Array.isArray(apiNote.tags)) { apiNote.tags.forEach(tag => tagSet.add(tag)); } if (Array.isArray(frontmatter.tags)) { (frontmatter.tags as string[]).forEach(tag => tagSet.add(tag)); } const fallbackUpdatedAt = new Date((frontmatter.mtime ?? apiNote.mtime) || Date.now()).toISOString(); const note: Note = { id: apiNote.id, title: noteTitle, content: body, tags: Array.from(tagSet), frontmatter, backlinks: [], mtime: frontmatter.mtime || apiNote.mtime || Date.now(), fileName, filePath, originalPath, createdAt: typeof apiNote.createdAt === 'string' ? apiNote.createdAt : undefined, updatedAt: typeof apiNote.updatedAt === 'string' ? apiNote.updatedAt : fallbackUpdatedAt }; if (this.shouldUseVaultName(frontmatter, apiNote.filePath, apiNote.originalPath)) { const candidateName = frontmatter.NomDeVoute || frontmatter.nomdevoute || frontmatter.vaultName; if (typeof candidateName === 'string' && candidateName.trim()) { detectedHomeVaultName = candidateName.trim(); } } newNotesMap.set(apiNote.id, note); } this.computeBacklinks(newNotesMap); this.notesMap.set(newNotesMap); if (detectedHomeVaultName) { this.vaultName.set(this.formatVaultName(detectedHomeVaultName)); } }, error: (error) => { console.error('Failed to load vault notes from API', error); this.notesMap.set(new Map()); }, }); } private computeBacklinks(notesMap: Map) { const allNotes = Array.from(notesMap.values()); allNotes.forEach(note => { note.backlinks = []; }); for (const note of allNotes) { const linkRegex = /\[\[([^|\]\n]+)(?:\|([^\]\n]+))?\]\]/g; let match; while ((match = linkRegex.exec(note.content)) !== null) { const linkTarget = match[1].toLowerCase().replace(/\s+/g, '-'); const targetNote = allNotes.find(n => n.id === linkTarget || n.title === match[1] || (Array.isArray(n.frontmatter?.aliases) && (n.frontmatter.aliases as string[]).includes(match[1])) ); if (targetNote && targetNote.id !== note.id) { const targetInMap = notesMap.get(targetNote.id); if (targetInMap && !targetInMap.backlinks.includes(note.id)) { targetInMap.backlinks.push(note.id); } } } } } private parseFrontmatter(content: string): { frontmatter: { [key: string]: any }, body: string } { const sanitizedContent = content.replace(/^\uFEFF/, ''); const normalizedContent = sanitizedContent.replace(/\r\n?/g, '\n'); const match = normalizedContent.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/); if (match) { const frontmatterText = match[1]; const body = match[2].trim(); const frontmatter: { [key: string]: any } = {}; const lines = frontmatterText.split('\n'); for (let i = 0; i < lines.length; i++) { const rawLine = lines[i]; if (!rawLine.trim() || rawLine.trim().startsWith('#')) { continue; } const colonIndex = rawLine.indexOf(':'); if (colonIndex === -1) { continue; } const key = rawLine.slice(0, colonIndex).trim(); let valuePart = rawLine.slice(colonIndex + 1).trim(); if (!key) { continue; } if (!valuePart) { const listValues: any[] = []; let j = i + 1; while (j < lines.length) { const listLine = lines[j]; if (!listLine.trim()) { break; } if (/^\s*-\s+/.test(listLine)) { const listItem = listLine.replace(/^\s*-\s+/, ''); listValues.push(this.parseFrontmatterValue(listItem.trim())); j++; } else { break; } } if (listValues.length) { frontmatter[key] = listValues; i = j - 1; continue; } } if (valuePart.startsWith('[') && valuePart.endsWith(']')) { const arrayContent = valuePart.substring(1, valuePart.length - 1); frontmatter[key] = arrayContent.split(',').map(v => this.parseFrontmatterValue(v.trim())); continue; } frontmatter[key] = this.parseFrontmatterValue(valuePart); } return { frontmatter, body }; } return { frontmatter: {}, body: content.trim() }; } private parseFrontmatterValue(rawValue: string): any { const trimmed = rawValue.trim(); if (!trimmed) { return ''; } const unquoted = trimmed.replace(/^['"]|['"]$/g, ''); if (/^(true|false)$/i.test(unquoted)) { return unquoted.toLowerCase() === 'true'; } const numeric = Number(unquoted); if (!isNaN(numeric) && unquoted !== '') { return numeric; } return unquoted; } private extractTitle(content: string, fallback: string): string { const match = content.match(/^#\s+(.*)/); return match ? match[1] : fallback.split('/').pop()!.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } private resolveVaultName(): 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 shouldUseVaultName(frontmatter: { [key: string]: any }, filePath?: string, originalPath?: string): boolean { const hasMetaName = typeof frontmatter?.NomDeVoute === 'string' || typeof frontmatter?.nomdevoute === 'string' || typeof frontmatter?.vaultName === 'string'; if (!hasMetaName) { return false; } const normalizedPath = (filePath ?? originalPath ?? '').replace(/\\/g, '/'); const pathSegments = normalizedPath.split('/').filter(Boolean).map(segment => segment.toLowerCase()); if (!pathSegments.length) { 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()); } }