ObsiViewer/src/services/vault.service.ts

469 lines
15 KiB
TypeScript

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<Map<string, Note>>(new Map());
private openFolderPaths = signal(new Set<string>());
private initialVaultName = this.resolveVaultName();
allNotes = computed(() => Array.from(this.notesMap().values()));
vaultName = signal<string>(this.initialVaultName);
fileTree = computed<VaultNode[]>(() => {
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
const folders = new Map<string, VaultFolder>([['', 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<GraphData>(() => {
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<TagInfo[]>(() => {
const tagCounts = new Map<string, number>();
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<typeof setTimeout> | 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<VaultApiResponse>('/api/vault').subscribe({
next: ({ notes }) => {
const newNotesMap = new Map<string, Note>();
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<string>();
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<string, Note>) {
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());
}
}