469 lines
15 KiB
TypeScript
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());
|
|
}
|
|
} |