ObsiViewer/src/services/vault.service.ts

1280 lines
36 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 } 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<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();
}
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<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);
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) {
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,
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<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)) 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;
this.refreshNotes();
}, 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 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);
}
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 ?? '';
}
}
}