#!/usr/bin/env node import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import zlib from 'zlib'; import chokidar from 'chokidar'; import { meiliClient, vaultIndexName, ensureIndexSettings } from './meilisearch.client.mjs'; import { fullReindex, upsertFile, deleteFile } from './meilisearch-indexer.mjs'; import { mapObsidianQueryToMeili, buildSearchParams } from './search.mapping.mjs'; import { PORT as CFG_PORT, VAULT_PATH as CFG_VAULT_PATH, debugPrintConfig } from './config.mjs'; import { z } from 'zod'; import { parseExcalidrawAny, toObsidianExcalidrawMd, extractFrontMatter, isValidExcalidrawScene } from './excalidraw-obsidian.mjs'; import { rewriteTagsFrontmatter, extractTagsFromFrontmatter } from './markdown-frontmatter.mjs'; import { enrichFrontmatterOnOpen } from './ensureFrontmatter.mjs'; import { loadVaultMetadataOnly } from './vault-metadata-loader.mjs'; import { MetadataCache as MetadataCacheOld, PerformanceLogger } from './performance-config.mjs'; import { MetadataCache } from './perf/metadata-cache.js'; import { PerformanceMonitor } from './perf/performance-monitor.js'; import { retryWithBackoff, CircuitBreaker } from './utils/retry.js'; import { setupMetadataEndpoint, setupPaginatedMetadataEndpoint, setupPerformanceEndpoint, setupDeferredIndexing, setupCreateNoteEndpoint, setupRenameFolderEndpoint, setupDeleteFolderEndpoint } from './index-phase3-patch.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = CFG_PORT; const rootDir = path.resolve(__dirname, '..'); const distDir = path.join(rootDir, 'dist'); // Centralized vault directory const vaultDir = path.isAbsolute(CFG_VAULT_PATH) ? CFG_VAULT_PATH : path.join(rootDir, CFG_VAULT_PATH); const vaultEventClients = new Set(); // Phase 3: Advanced caching and monitoring const metadataCache = new MetadataCache({ ttlMs: 5 * 60 * 1000, maxItems: 10_000 }); // List all folders under the vault (relative paths, forward slashes) app.get('/api/folders/list', (req, res) => { try { const out = []; const walk = (dir, relBase = '') => { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const de of entries) { if (!de.isDirectory()) continue; const name = de.name; // Skip hidden backup folders but keep .trash const rel = relBase ? `${relBase}/${name}` : name; const abs = path.join(dir, name); out.push(rel.replace(/\\/g, '/')); walk(abs, rel); } }; walk(vaultDir, ''); return res.json(out); } catch (error) { console.error('GET /api/folders/list error:', error); return res.status(500).json({ error: 'Unable to list folders' }); } }); // Duplicate a folder recursively app.post('/api/folders/duplicate', express.json(), (req, res) => { try { const { sourcePath, destinationPath } = req.body || {}; if (!sourcePath || !destinationPath) { return res.status(400).json({ error: 'Missing sourcePath or destinationPath' }); } const srcRel = String(sourcePath).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); const dstRel = String(destinationPath).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); const srcAbs = path.join(vaultDir, srcRel); const dstAbs = path.join(vaultDir, dstRel); if (!fs.existsSync(srcAbs) || !fs.statSync(srcAbs).isDirectory()) { return res.status(404).json({ error: 'Source folder not found' }); } if (fs.existsSync(dstAbs)) { return res.status(409).json({ error: 'Destination already exists' }); } const copyRecursive = (from, to) => { fs.mkdirSync(to, { recursive: true }); for (const entry of fs.readdirSync(from, { withFileTypes: true })) { const s = path.join(from, entry.name); const d = path.join(to, entry.name); if (entry.isDirectory()) copyRecursive(s, d); else fs.copyFileSync(s, d); } }; copyRecursive(srcAbs, dstAbs); if (metadataCache) metadataCache.clear(); if (broadcastVaultEvent) { broadcastVaultEvent({ event: 'folder-duplicate', sourcePath: srcRel, path: dstRel, timestamp: Date.now() }); } return res.json({ success: true, sourcePath: srcRel, path: dstRel }); } catch (error) { console.error('POST /api/folders/duplicate error:', error); return res.status(500).json({ error: 'Internal server error' }); } }); // Delete all pages (markdown/excalidraw) inside a folder recursively, keep folder structure app.delete('/api/folders/pages', express.json(), (req, res) => { try { const q = typeof req.query.path === 'string' ? req.query.path : ''; if (!q) return res.status(400).json({ error: 'Missing path' }); const rel = q.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); const abs = path.join(vaultDir, rel); if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { return res.status(404).json({ error: 'Folder not found' }); } const deleted = []; const walk = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p); else { const low = entry.name.toLowerCase(); if (low.endsWith('.md') || low.endsWith('.excalidraw') || low.endsWith('.excalidraw.md')) { try { fs.unlinkSync(p); deleted.push(path.relative(vaultDir, p).replace(/\\/g, '/')); } catch {} } } } }; walk(abs); if (metadataCache) metadataCache.clear(); if (broadcastVaultEvent) { broadcastVaultEvent({ event: 'folder-delete-pages', path: rel, count: deleted.length, timestamp: Date.now() }); } return res.json({ success: true, path: rel, deletedCount: deleted.length }); } catch (error) { console.error('DELETE /api/folders/pages error:', error); return res.status(500).json({ error: 'Internal server error' }); } }); const performanceMonitor = new PerformanceMonitor(); const meilisearchCircuitBreaker = new CircuitBreaker({ failureThreshold: 5, resetTimeoutMs: 30_000 }); const registerVaultEventClient = (res) => { const heartbeat = setInterval(() => { try { res.write(':keepalive\n\n'); } catch (error) { // Client disconnected, clean up console.log('[SSE] Client heartbeat failed, cleaning up'); unregisterVaultEventClient({ res, heartbeat }); } }, 20000); // Send heartbeat every 20 seconds const client = { res, heartbeat }; vaultEventClients.add(client); return client; }; const unregisterVaultEventClient = (client) => { clearInterval(client.heartbeat); vaultEventClients.delete(client); }; const broadcastVaultEvent = (payload) => { if (!vaultEventClients.size) { return; } const data = `data: ${JSON.stringify(payload)}\n\n`; for (const client of [...vaultEventClients]) { try { client.res.write(data); } catch (error) { console.error('Failed to notify vault event client:', error); unregisterVaultEventClient(client); } } }; const isMarkdownFile = (entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.md'); const normalizeString = (value) => { return value .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .trim(); }; const slugifySegment = (segment) => { const normalized = normalizeString(segment); const slug = normalized .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); return slug || normalized.toLowerCase() || segment.toLowerCase(); }; const slugifyPath = (relativePath) => { return relativePath .split('/') .map((segment) => slugifySegment(segment)) .filter(Boolean) .join('/'); }; const extractTitle = (content, fallback) => { const headingMatch = content.match(/^\s*#\s+(.+)$/m); if (headingMatch) { return headingMatch[1].trim(); } return fallback; }; const extractTags = (content) => { const tagRegex = /(^|\s)#([A-Za-z0-9_\/-]+)/g; const tags = new Set(); let match; while ((match = tagRegex.exec(content)) !== null) { tags.add(match[2]); } return Array.from(tags); }; const loadVaultNotes = async (vaultPath) => { const notes = []; const walk = async (currentDir) => { if (!fs.existsSync(currentDir)) { return; } const entries = fs.readdirSync(currentDir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { await walk(entryPath); continue; } if (!isMarkdownFile(entry)) { continue; } try { // Skip enrichment during initial load for performance (Phase 1) // Enrichment will happen on-demand when file is opened via /api/files const content = fs.readFileSync(entryPath, 'utf-8'); const stats = fs.statSync(entryPath); const relativePathWithExt = path.relative(vaultPath, entryPath).replace(/\\/g, '/'); const relativePath = relativePathWithExt.replace(/\.md$/i, ''); const id = slugifyPath(relativePath); const fileNameWithExt = entry.name; const fallbackTitle = path.basename(relativePath); const title = extractTitle(content, fallbackTitle); const finalId = id || slugifySegment(fallbackTitle) || fallbackTitle; const createdDate = stats.birthtimeMs ? new Date(stats.birthtimeMs) : new Date(stats.ctimeMs); const updatedDate = new Date(stats.mtimeMs); notes.push({ id: finalId, title, content, tags: extractTags(content), mtime: stats.mtimeMs, fileName: fileNameWithExt, filePath: relativePathWithExt, originalPath: relativePath, createdAt: createdDate.toISOString(), updatedAt: updatedDate.toISOString() }); } catch (err) { console.error(`Failed to read/enrich note at ${entryPath}:`, err); } } }; await walk(vaultPath); return notes; }; // Scan vault for .excalidraw.md files and return FileMetadata-like entries const scanVaultDrawings = (vaultPath) => { const items = []; const walk = (currentDir) => { let entries = []; try { entries = fs.readdirSync(currentDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const entryPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { walk(entryPath); continue; } if (!entry.isFile()) continue; const lower = entry.name.toLowerCase(); if (!lower.endsWith('.excalidraw.md')) continue; try { const stats = fs.statSync(entryPath); const relPath = path.relative(vaultPath, entryPath).replace(/\\/g, '/'); const id = slugifyPath(relPath.replace(/\.excalidraw(?:\.md)?$/i, '')); const title = path.basename(relPath).replace(/\.excalidraw(?:\.md)?$/i, ''); items.push({ id, title, path: relPath, createdAt: new Date(stats.birthtimeMs ? stats.birthtimeMs : stats.ctimeMs).toISOString(), updatedAt: new Date(stats.mtimeMs).toISOString(), }); } catch {} } }; walk(vaultPath); return items; }; const buildFileMetadata = (notes) => notes.map((note) => ({ id: note.id, title: note.title, path: note.filePath, createdAt: note.createdAt, updatedAt: note.updatedAt, })); const normalizeDateInput = (value) => { if (!value) { return null; } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; }; const isDateWithinRange = (target, start, end) => { const targetTime = target.getTime(); return targetTime >= start.getTime() && targetTime <= end.getTime(); }; const vaultWatcher = chokidar.watch(vaultDir, { persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 100, }, }); const watchedVaultEvents = ['add', 'change', 'unlink', 'addDir', 'unlinkDir']; watchedVaultEvents.forEach((eventName) => { vaultWatcher.on(eventName, (changedPath) => { const relativePath = path.relative(vaultDir, changedPath).replace(/\\/g, '/'); broadcastVaultEvent({ event: eventName, path: relativePath, timestamp: Date.now(), }); }); }); // Integrate Meilisearch with Chokidar for incremental updates vaultWatcher.on('add', async (filePath) => { if (filePath.toLowerCase().endsWith('.md')) { // Clear metadata cache (Phase 1) metadataCache.clear(); // Enrichir le frontmatter pour les nouveaux fichiers try { const enrichResult = await enrichFrontmatterOnOpen(filePath); if (enrichResult.modified) { console.log('[Watcher] Enriched frontmatter for new file:', path.basename(filePath)); } } catch (enrichError) { console.warn('[Watcher] Failed to enrich frontmatter for new file:', enrichError); } // Puis indexer dans Meilisearch upsertFile(filePath).catch(err => console.error('[Meili] Upsert on add failed:', err)); } }); vaultWatcher.on('change', (filePath) => { if (filePath.toLowerCase().endsWith('.md')) { // Clear metadata cache (Phase 1) metadataCache.clear(); upsertFile(filePath).catch(err => console.error('[Meili] Upsert on change failed:', err)); } }); vaultWatcher.on('unlink', (filePath) => { if (filePath.toLowerCase().endsWith('.md')) { // Clear metadata cache (Phase 1) metadataCache.clear(); const relativePath = path.relative(vaultDir, filePath).replace(/\\/g, '/'); deleteFile(relativePath).catch(err => console.error('[Meili] Delete failed:', err)); } }); vaultWatcher.on('ready', () => { broadcastVaultEvent({ event: 'ready', timestamp: Date.now(), }); }); vaultWatcher.on('error', (error) => { console.error('Vault watcher error:', error); broadcastVaultEvent({ event: 'error', message: typeof error?.message === 'string' ? error.message : 'Unknown watcher error', timestamp: Date.now(), }); }); // Vérifier si le répertoire dist existe if (!fs.existsSync(distDir)) { console.warn(`Warning: build directory not found at ${distDir}. Did you run \`npm run build\`?`); } // Servir les fichiers statiques de l'application Angular app.use(express.static(distDir)); // CORS configuration for development app.use(cors({ origin: "http://localhost:3000", credentials: true })); // Exposer les fichiers de la voûte pour un accès direct si nécessaire app.use('/vault', express.static(vaultDir)); // Résolution des attachements: recherche le fichier en remontant les dossiers depuis la note, puis dans la voûte app.get('/api/attachments/resolve', (req, res) => { try { const rawName = typeof req.query.name === 'string' ? req.query.name.trim() : ''; if (!rawName) { return res.status(400).type('text/plain').send('Missing required query parameter: name'); } const sanitize = (value) => value.replace(/\\/g, '/').replace(/^[/]+|[/]+$/g, ''); const name = sanitize(rawName); const noteRelPath = typeof req.query.note === 'string' ? sanitize(req.query.note) : ''; const baseRaw = typeof req.query.base === 'string' ? req.query.base : ''; const baseRel = sanitize(baseRaw); const candidateDirs = new Set(); const addCandidate = (dir, extra) => { const dirSegments = sanitize(dir); const extraSegments = sanitize(extra); if (dirSegments && extraSegments) { candidateDirs.add(`${dirSegments}/${extraSegments}`); } else if (dirSegments) { candidateDirs.add(dirSegments); } else if (extraSegments) { candidateDirs.add(extraSegments); } else { candidateDirs.add(''); } }; // Dossiers parents de la note if (noteRelPath) { const segments = noteRelPath.split('/'); segments.pop(); // retirer le nom de fichier while (segments.length >= 0) { const currentDir = segments.join('/'); addCandidate(currentDir, baseRel); addCandidate(currentDir, ''); if (!segments.length) break; segments.pop(); } } // Si base est défini, tenter aussi directement depuis la racine if (baseRel) { addCandidate('', baseRel); } // Toujours ajouter la racine seule en dernier recours addCandidate('', ''); for (const dir of candidateDirs) { const absoluteDir = dir ? path.join(vaultDir, dir) : vaultDir; const candidatePath = path.join(absoluteDir, name); try { if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) { return res.sendFile(candidatePath); } } catch { // Ignorer et poursuivre } } // Recherche exhaustive en dernier recours (coût plus élevé) const stack = [vaultDir]; const nameLower = name.toLowerCase(); while (stack.length) { const currentDir = stack.pop(); let entries = []; try { entries = fs.readdirSync(currentDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile() && entry.name.toLowerCase() === nameLower) { return res.sendFile(fullPath); } } } return res.status(404).type('text/plain').send(`‼️Attachement ${rawName} introuvable`); } catch (error) { console.error('Attachment resolve error:', error); return res.status(500).type('text/plain').send('Attachment resolver internal error'); } }); // API endpoint pour la santé app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.get('/api/vault/events', (req, res) => { res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.flushHeaders?.(); res.write( `data: ${JSON.stringify({ event: 'connected', timestamp: Date.now(), })}\n\n`, ); const client = registerVaultEventClient(res); req.on('close', () => { unregisterVaultEventClient(client); }); }); // API endpoint pour les données de la voûte (contenu réel) app.get('/api/vault', async (req, res) => { try { const notes = await loadVaultNotes(vaultDir); res.json({ notes }); } catch (error) { console.error('Failed to load vault notes:', error); res.status(500).json({ error: 'Unable to load vault notes.' }); } }); // Fast file list from Meilisearch (id, title, path, createdAt, updatedAt) app.get('/api/files/list', async (req, res) => { try { const client = meiliClient(); const indexUid = vaultIndexName(vaultDir); const index = await ensureIndexSettings(client, indexUid); const result = await index.search('', { limit: 10000, attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'] }); const items = Array.isArray(result.hits) ? result.hits.map(hit => ({ id: hit.id, title: hit.title, path: hit.path, createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt, updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt, })) : []; res.json(items); } catch (error) { console.error('Failed to list files via Meilisearch, falling back to FS:', error); try { const notes = await loadVaultNotes(vaultDir); res.json(buildFileMetadata(notes)); } catch (err2) { console.error('FS fallback failed:', err2); res.status(500).json({ error: 'Unable to list files.' }); } } }); // Phase 3: Fast metadata endpoint with cache read-through and monitoring // setupMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { // meiliClient, // vaultIndexName, // ensureIndexSettings, // loadVaultMetadataOnly // }); // Phase 3: Paginated metadata endpoint with cache read-through and monitoring // setupPaginatedMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { // meiliClient, // vaultIndexName, // ensureIndexSettings, // loadVaultMetadataOnly // }); app.get('/api/files/metadata', async (req, res) => { try { // If explicitly requested, bypass Meilisearch and read from filesystem for authoritative state const forceFs = String(req.query.source || '').toLowerCase() === 'fs'; if (!forceFs) { // Prefer Meilisearch for fast metadata const client = meiliClient(); const indexUid = vaultIndexName(vaultDir); const index = await ensureIndexSettings(client, indexUid); const result = await index.search('', { limit: 10000, attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'] }); const items = Array.isArray(result.hits) ? result.hits.map(hit => ({ id: hit.id, title: hit.title, path: hit.path, createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt, updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt, })) : []; // Merge .excalidraw files discovered via FS const drawings = scanVaultDrawings(vaultDir); const byPath = new Map(items.map(it => [String(it.path).toLowerCase(), it])); for (const d of drawings) { const key = String(d.path).toLowerCase(); if (!byPath.has(key)) { byPath.set(key, d); } } return res.json(Array.from(byPath.values())); } // Filesystem authoritative listing const notes = await loadVaultNotes(vaultDir); const base = buildFileMetadata(notes); const drawings = scanVaultDrawings(vaultDir); const byPath = new Map(base.map(it => [String(it.path).toLowerCase(), it])); for (const d of drawings) { const key = String(d.path).toLowerCase(); if (!byPath.has(key)) byPath.set(key, d); } return res.json(Array.from(byPath.values())); } catch (error) { console.error('Failed to load file metadata:', error); return res.status(500).json({ error: 'Unable to load file metadata.' }); } }); app.get('/api/files/by-date', async (req, res) => { const { date } = req.query; const targetDate = normalizeDateInput(date); if (!targetDate) { return res.status(400).json({ error: 'Invalid or missing date query parameter.' }); } try { const notes = await loadVaultNotes(vaultDir); const startOfDay = new Date(targetDate); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(targetDate); endOfDay.setHours(23, 59, 59, 999); const filtered = notes.filter((note) => { const createdAt = normalizeDateInput(note.createdAt); const updatedAt = normalizeDateInput(note.updatedAt); const matchesCreated = createdAt && isDateWithinRange(createdAt, startOfDay, endOfDay); const matchesUpdated = updatedAt && isDateWithinRange(updatedAt, startOfDay, endOfDay); return matchesCreated || matchesUpdated; }); res.json(buildFileMetadata(filtered)); } catch (error) { console.error('Failed to search files by date:', error); res.status(500).json({ error: 'Unable to search files by date.' }); } }); app.get('/api/files/by-date-range', async (req, res) => { const { start, end } = req.query; const startDate = normalizeDateInput(start); const endDate = normalizeDateInput(end ?? start); if (!startDate || !endDate || startDate > endDate) { return res.status(400).json({ error: 'Invalid start or end date parameters.' }); } const normalizedStart = new Date(startDate); normalizedStart.setHours(0, 0, 0, 0); const normalizedEnd = new Date(endDate); normalizedEnd.setHours(23, 59, 59, 999); try { const notes = await loadVaultNotes(vaultDir); const filtered = notes.filter((note) => { const createdAt = normalizeDateInput(note.createdAt); const updatedAt = normalizeDateInput(note.updatedAt); const matchesCreated = createdAt && isDateWithinRange(createdAt, normalizedStart, normalizedEnd); const matchesUpdated = updatedAt && isDateWithinRange(updatedAt, normalizedStart, normalizedEnd); return matchesCreated || matchesUpdated; }); res.json(buildFileMetadata(filtered)); } catch (error) { console.error('Failed to search files by date range:', error); res.status(500).json({ error: 'Unable to search files by date range.' }); } }); // Bookmarks API - reads/writes /.obsidian/bookmarks.json app.use(express.json()); app.post('/api/log', (req, res) => { try { const payload = req.body; if (!payload) { return res.status(400).json({ error: 'Missing log payload' }); } const records = Array.isArray(payload) ? payload : [payload]; // Validate and process records const validRecords = records.filter((record) => { if (!record || typeof record !== 'object') { console.warn('[FrontendLog] Ignored invalid record', record); return false; } return true; }); if (validRecords.length === 0) { return res.status(400).json({ error: 'No valid log records provided' }); } validRecords.forEach((record) => { const { event = 'UNKNOWN_EVENT', level = 'info', sessionId, userAgent, context = {}, data, } = record; const summary = { sessionId, route: context?.route ?? 'n/a', theme: context?.theme ?? 'n/a', version: context?.version ?? 'n/a', }; if (data !== undefined) { summary.data = data; } console.log(`[FrontendLog:${level}]`, event, summary, userAgent ?? ''); }); return res.status(202).json({ ok: true, processed: validRecords.length }); } catch (error) { console.error('Failed to process frontend logs:', error); return res.status(500).json({ error: 'Failed to process logs', message: error.message || 'Internal server error' }); } }); app.post('/api/logs', (req, res) => { try { const { source = 'frontend', level = 'info', message = '', data = null, timestamp = Date.now() } = req.body || {}; // Validate inputs if (!message || typeof message !== 'string') { return res.status(400).json({ error: 'Invalid or missing message' }); } if (!['error', 'warn', 'info', 'debug'].includes(level)) { return res.status(400).json({ error: 'Invalid log level' }); } const prefix = `[ClientLog:${source}]`; const payload = data !== undefined ? { data } : undefined; switch (level) { case 'error': console.error(prefix, message, payload ?? '', new Date(timestamp).toISOString()); break; case 'warn': console.warn(prefix, message, payload ?? '', new Date(timestamp).toISOString()); break; case 'debug': console.debug(prefix, message, payload ?? '', new Date(timestamp).toISOString()); break; default: console.log(prefix, message, payload ?? '', new Date(timestamp).toISOString()); break; } res.status(202).json({ status: 'queued' }); } catch (error) { console.error('Failed to process client logs:', error); res.status(500).json({ error: 'Failed to process logs', message: error.message || 'Internal server error' }); } }); // --- Files API (supports .excalidraw.md (Markdown-wrapped JSON), .excalidraw, .json and binary sidecars) --- // Helpers const sanitizeRelPath = (rel) => String(rel || '').replace(/\\/g, '/').replace(/^\/+/, ''); const resolveVaultPath = (rel) => { const clean = sanitizeRelPath(rel); const abs = path.resolve(vaultDir, clean); if (!abs.startsWith(path.resolve(vaultDir))) { throw Object.assign(new Error('Invalid path'), { status: 400 }); } return abs; }; const excalidrawSceneSchema = z.object({ elements: z.array(z.any()), appState: z.record(z.any()).optional(), files: z.record(z.any()).optional(), }).passthrough(); // Helper to determine content type function guessContentType(filePath) { const lower = filePath.toLowerCase(); if (lower.endsWith('.md')) return 'text/markdown; charset=utf-8'; if (lower.endsWith('.json')) return 'application/json; charset=utf-8'; return 'application/octet-stream'; } // GET file content (supports .excalidraw.md, .excalidraw, .json, .md) app.get('/api/files', async (req, res) => { try { const pathParam = req.query.path; if (!pathParam || typeof pathParam !== 'string') { return res.status(400).json({ error: 'Missing path query parameter' }); } const rel = decodeURIComponent(pathParam); const abs = resolveVaultPath(rel); if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { return res.status(404).json({ error: 'File not found' }); } const base = path.basename(abs).toLowerCase(); const ext = path.extname(abs).toLowerCase(); const isExcalidrawMd = base.endsWith('.excalidraw.md'); const isExcalidraw = ext === '.excalidraw' || ext === '.json' || isExcalidrawMd; if (!isExcalidraw && ext !== '.md') { return res.status(415).json({ error: 'Unsupported file type' }); } // For regular markdown files, enrich front-matter before reading if (!isExcalidraw && ext === '.md') { try { const enrichResult = await enrichFrontmatterOnOpen(abs); // If modified, trigger Meilisearch reindex if (enrichResult.modified) { upsertFile(abs).catch(err => console.warn('[GET /api/files] Failed to reindex after enrichment:', err) ); } const rev = calculateSimpleHash(enrichResult.content); res.setHeader('ETag', rev); res.setHeader('Content-Type', guessContentType(abs)); return res.send(enrichResult.content); } catch (enrichError) { console.error('[GET /api/files] Front-matter enrichment failed:', enrichError); // Fallback to reading without enrichment } } const content = fs.readFileSync(abs, 'utf-8'); // For Excalidraw files, parse and return JSON if (isExcalidraw) { const data = parseExcalidrawAny(content); if (!data || !isValidExcalidrawScene(data)) { return res.status(400).json({ error: 'Invalid Excalidraw format' }); } // Normalize scene structure const normalized = { elements: Array.isArray(data.elements) ? data.elements : [], appState: (data && typeof data.appState === 'object') ? data.appState : {}, files: (data && typeof data.files === 'object') ? data.files : {} }; const rev = calculateSimpleHash(content); res.setHeader('ETag', rev); res.setHeader('Content-Type', 'application/json; charset=utf-8'); return res.send(JSON.stringify(normalized)); } // For regular markdown, return as-is (fallback) const rev = calculateSimpleHash(content); res.setHeader('ETag', rev); res.setHeader('Content-Type', guessContentType(abs)); return res.send(content); } catch (error) { const code = typeof error?.status === 'number' ? error.status : 500; console.error('GET /api/files error:', error); res.status(code).json({ error: 'Internal server error' }); } }); // PUT file content with If-Match check and size limit (10MB) app.put('/api/files', express.json({ limit: '10mb' }), express.text({ limit: '10mb', type: 'text/markdown' }), (req, res) => { try { const pathParam = req.query.path; if (!pathParam || typeof pathParam !== 'string') { return res.status(400).json({ error: 'Missing or invalid path query parameter' }); } const rel = decodeURIComponent(pathParam); const abs = resolveVaultPath(rel); // Check if the path is a directory (reject directory operations) if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { return res.status(400).json({ error: 'Cannot write to directory path. Specify a file path.' }); } const dir = path.dirname(abs); if (!fs.existsSync(dir)) { // Create parent directories if they don't exist try { fs.mkdirSync(dir, { recursive: true }); } catch (mkdirError) { console.error('[PUT /api/files] Failed to create directory:', mkdirError); return res.status(500).json({ error: 'Failed to create parent directories' }); } } const contentType = (req.headers['content-type'] || '').split(';')[0]; const base = path.basename(abs).toLowerCase(); const isExcalidrawMd = base.endsWith('.excalidraw.md'); let finalContent; let existingFrontMatter = null; console.log('[PUT /api/files] path=%s contentType=%s isExcalidrawMd=%s', rel, contentType, isExcalidrawMd); // Extract existing front matter if file exists if (fs.existsSync(abs) && isExcalidrawMd) { try { const existing = fs.readFileSync(abs, 'utf-8'); existingFrontMatter = extractFrontMatter(existing); } catch (readError) { console.warn('[PUT /api/files] Failed to read existing file for frontmatter:', readError); } } // Handle JSON payload (Excalidraw scene) if (contentType === 'application/json') { const body = req.body; const parsed = excalidrawSceneSchema.safeParse(body); if (!parsed.success) { console.warn('[PUT /api/files] invalid scene schema', parsed.error?.issues?.slice(0,3)); return res.status(400).json({ error: 'Invalid Excalidraw scene', issues: parsed.error.issues?.slice(0, 5) }); } // Convert to Obsidian format if target is .excalidraw.md if (isExcalidrawMd) { finalContent = toObsidianExcalidrawMd(parsed.data, existingFrontMatter); } else { // Plain JSON for .excalidraw or .json files finalContent = JSON.stringify(parsed.data, null, 2); } } // Handle text/markdown payload (already formatted) else if (contentType === 'text/markdown') { finalContent = typeof req.body === 'string' ? req.body : String(req.body); } else { console.warn('[PUT /api/files] unsupported content-type', contentType); return res.status(400).json({ error: 'Unsupported content type. Use application/json for Excalidraw or text/markdown for text files.' }); } // Check size limit if (Buffer.byteLength(finalContent, 'utf-8') > 10 * 1024 * 1024) { console.warn('[PUT /api/files] payload too large path=%s size=%d', rel, Buffer.byteLength(finalContent, 'utf-8')); return res.status(413).json({ error: 'Payload too large (max 10MB)' }); } // Check for conflicts with If-Match const hasExisting = fs.existsSync(abs); const ifMatch = req.headers['if-match']; if (hasExisting && ifMatch) { const current = fs.readFileSync(abs, 'utf-8'); const currentRev = calculateSimpleHash(current); if (ifMatch !== currentRev) { console.warn('[PUT /api/files] conflict path=%s ifMatch=%s current=%s', rel, ifMatch, currentRev); return res.status(409).json({ error: 'Conflict detected. File was modified externally.' }); } } // Atomic write with backup const temp = abs + '.tmp'; const backup = abs + '.bak'; try { if (hasExisting) fs.copyFileSync(abs, backup); fs.writeFileSync(temp, finalContent, 'utf-8'); fs.renameSync(temp, abs); console.log('[PUT /api/files] wrote file path=%s bytes=%d', rel, Buffer.byteLength(finalContent, 'utf-8')); } catch (e) { // Cleanup temp file if (fs.existsSync(temp)) try { fs.unlinkSync(temp); } catch {} // Restore backup if it exists if (hasExisting && fs.existsSync(backup)) try { fs.copyFileSync(backup, abs); } catch {} console.error('[PUT /api/files] write error path=%s', rel, e); return res.status(500).json({ error: 'Failed to write file. Check file permissions and disk space.' }); } // Cleanup backup if (fs.existsSync(backup)) { try { fs.unlinkSync(backup); } catch {} } const rev = calculateSimpleHash(finalContent); res.setHeader('ETag', rev); res.json({ rev, path: rel, size: Buffer.byteLength(finalContent, 'utf-8') }); } catch (error) { const code = typeof error?.status === 'number' ? error.status : 500; console.error('PUT /api/files error:', error); res.status(code).json({ error: 'Internal server error', message: error.message || 'An unexpected error occurred while saving the file.' }); } }); // PUT binary sidecar (e.g., PNG/SVG) app.put('/api/files/blob', (req, res) => { try { const pathParam = req.query.path; if (!pathParam || typeof pathParam !== 'string') { return res.status(400).json({ error: 'Missing path query parameter' }); } const rel = decodeURIComponent(pathParam); const abs = resolveVaultPath(rel); const dir = path.dirname(abs); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // Collect raw body const chunks = []; req.on('data', (chunk) => { chunks.push(chunk); const size = chunks.reduce((a, b) => a + b.length, 0); if (size > 10 * 1024 * 1024) { // 10MB limit req.destroy(); } }); req.on('end', () => { const buf = Buffer.concat(chunks); // Basic allowlist const ext = path.extname(abs).toLowerCase(); if (!['.png', '.svg'].includes(ext)) { return res.status(415).json({ error: 'unsupported_media_type' }); } fs.writeFileSync(abs, buf); res.json({ ok: true }); }); req.on('error', (err) => { console.error('Blob upload error:', err); res.status(500).json({ error: 'internal_error' }); }); } catch (error) { const code = typeof error?.status === 'number' ? error.status : 500; console.error('PUT /api/files/blob error:', error); res.status(code).json({ error: 'internal_error' }); } }); function ensureBookmarksStorage() { const obsidianDir = path.join(vaultDir, '.obsidian'); if (!fs.existsSync(obsidianDir)) { fs.mkdirSync(obsidianDir, { recursive: true }); } const bookmarksPath = path.join(obsidianDir, 'bookmarks.json'); if (!fs.existsSync(bookmarksPath)) { const emptyDoc = { items: [] }; const initialContent = JSON.stringify(emptyDoc, null, 2); fs.writeFileSync(bookmarksPath, initialContent, 'utf-8'); } return { obsidianDir, bookmarksPath }; } // Ensure bookmarks storage is ready on startup ensureBookmarksStorage(); // Graph config API - reads/writes /.obsidian/graph.json function ensureGraphStorage() { const obsidianDir = path.join(vaultDir, '.obsidian'); if (!fs.existsSync(obsidianDir)) { fs.mkdirSync(obsidianDir, { recursive: true }); } const graphPath = path.join(obsidianDir, 'graph.json'); if (!fs.existsSync(graphPath)) { // Create default graph config matching Obsidian defaults const defaultConfig = { 'collapse-filter': false, search: '', showTags: false, showAttachments: false, hideUnresolved: false, showOrphans: true, 'collapse-color-groups': false, colorGroups: [], 'collapse-display': false, showArrow: false, textFadeMultiplier: 0, nodeSizeMultiplier: 1, lineSizeMultiplier: 1, 'collapse-forces': false, centerStrength: 0.3, repelStrength: 17, linkStrength: 0.5, linkDistance: 200, scale: 1, close: false }; const initialContent = JSON.stringify(defaultConfig, null, 2); fs.writeFileSync(graphPath, initialContent, 'utf-8'); } return { obsidianDir, graphPath }; } // Ensure graph storage is ready on startup ensureGraphStorage(); app.get('/api/vault/graph', (req, res) => { try { const { graphPath } = ensureGraphStorage(); const content = fs.readFileSync(graphPath, 'utf-8'); const config = JSON.parse(content); const rev = calculateSimpleHash(content); res.json({ config, rev }); } catch (error) { console.error('Failed to load graph config:', error); res.status(500).json({ error: 'Unable to load graph config.' }); } }); app.put('/api/vault/graph', (req, res) => { try { const { graphPath } = ensureGraphStorage(); const ifMatch = req.headers['if-match']; // Check for conflicts if If-Match header is present if (ifMatch) { const currentContent = fs.readFileSync(graphPath, 'utf-8'); const currentRev = calculateSimpleHash(currentContent); if (ifMatch !== currentRev) { return res.status(409).json({ error: 'Conflict: File modified externally' }); } } // Create backup before writing const backupPath = graphPath + '.bak'; if (fs.existsSync(graphPath)) { fs.copyFileSync(graphPath, backupPath); } // Atomic write: write to temp file, then rename const tempPath = graphPath + '.tmp'; const content = JSON.stringify(req.body, null, 2); try { fs.writeFileSync(tempPath, content, 'utf-8'); fs.renameSync(tempPath, graphPath); } catch (writeError) { // If write failed, restore backup if it exists if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, graphPath); } throw writeError; } const newRev = calculateSimpleHash(content); res.json({ rev: newRev }); } catch (error) { console.error('Failed to save graph config:', error); res.status(500).json({ error: 'Unable to save graph config.' }); } }); app.get('/api/vault/bookmarks', (req, res) => { try { const { bookmarksPath } = ensureBookmarksStorage(); const content = fs.readFileSync(bookmarksPath, 'utf-8'); const doc = JSON.parse(content); const rev = calculateSimpleHash(content); res.json({ ...doc, rev }); } catch (error) { console.error('Failed to load bookmarks:', error); res.status(500).json({ error: 'Unable to load bookmarks.' }); } }); app.put('/api/vault/bookmarks', (req, res) => { try { const { bookmarksPath } = ensureBookmarksStorage(); const ifMatch = req.headers['if-match']; // Check for conflicts if If-Match header is present if (ifMatch) { const currentContent = fs.readFileSync(bookmarksPath, 'utf-8'); const currentRev = calculateSimpleHash(currentContent); if (ifMatch !== currentRev) { return res.status(409).json({ error: 'Conflict: File modified externally' }); } } // Create backup before writing const backupPath = bookmarksPath + '.bak'; if (fs.existsSync(bookmarksPath)) { fs.copyFileSync(bookmarksPath, backupPath); } // Atomic write: write to temp file, then rename const tempPath = bookmarksPath + '.tmp'; const content = JSON.stringify(req.body, null, 2); try { fs.writeFileSync(tempPath, content, 'utf-8'); fs.renameSync(tempPath, bookmarksPath); } catch (writeError) { // If write failed, restore backup if it exists if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, bookmarksPath); } throw writeError; } const newRev = calculateSimpleHash(content); res.json({ rev: newRev }); } catch (error) { console.error('Failed to save bookmarks:', error); res.status(500).json({ error: 'Unable to save bookmarks.' }); } }); // Simple hash function for rev generation function calculateSimpleHash(content) { let hash = 0; for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; } return Math.abs(hash).toString(36) + '-' + content.length; } // Meilisearch API endpoints app.get('/api/search', async (req, res) => { try { const { q = '', limit = '20', offset = '0', sort, highlight = 'true' } = req.query; // Parse Obsidian-style query to Meilisearch format const parsedQuery = mapObsidianQueryToMeili(String(q)); // Build search parameters const searchParams = buildSearchParams(parsedQuery, { limit: Number(limit), offset: Number(offset), sort, highlight: highlight === 'true' }); // Execute search const client = meiliClient(); const indexUid = vaultIndexName(vaultDir); const index = await ensureIndexSettings(client, indexUid); const result = await index.search(searchParams.q, searchParams); // Return results res.json({ hits: result.hits, estimatedTotalHits: result.estimatedTotalHits, facetDistribution: result.facetDistribution, processingTimeMs: result.processingTimeMs, query: q }); } catch (error) { console.error('[Meili] Search failed:', error); res.status(500).json({ error: 'search_failed', message: error.message }); } }); // PUT /api/notes/:idOrPath/tags - Update tags for a specific note // Accepts either a slug id or a vault-relative path (with or without .md), including slashes app.put(/^\/api\/notes\/(.+?)\/tags$/, express.json(), async (req, res) => { try { const rawParam = req.params[0]; const noteParam = decodeURIComponent(rawParam || ''); const { tags } = req.body; if (!Array.isArray(tags)) { return res.status(400).json({ error: 'tags must be an array' }); } // Find note by id or by path const notes = await loadVaultNotes(vaultDir); let note = notes.find(n => n.id === noteParam); if (!note) { // Try by originalPath (without .md) const withoutExt = noteParam.replace(/\.md$/i, ''); note = notes.find(n => n.originalPath === withoutExt); } if (!note) { // Try by filePath (with .md) const withExt = /\.md$/i.test(noteParam) ? noteParam : `${noteParam}.md`; // Normalize slashes const normalized = withExt.replace(/\\/g, '/'); note = notes.find(n => n.filePath === normalized || n.filePath === normalized.replace(/^\//, '')); } if (!note || !note.filePath) { return res.status(404).json({ error: 'Note not found' }); } const absolutePath = path.join(vaultDir, note.filePath); if (!fs.existsSync(absolutePath)) { return res.status(404).json({ error: 'File not found on disk' }); } // Read current content const currentContent = fs.readFileSync(absolutePath, 'utf-8'); // Rewrite with new tags const updatedContent = rewriteTagsFrontmatter(currentContent, tags); // Write back to disk (atomic with backup) const tempPath = absolutePath + '.tmp'; const backupPath = absolutePath + '.bak'; try { // Create backup fs.copyFileSync(absolutePath, backupPath); // Write to temp file fs.writeFileSync(tempPath, updatedContent, 'utf-8'); // Atomic rename fs.renameSync(tempPath, absolutePath); console.log(`[Tags] Updated tags for note ${note.id} (${note.filePath})`); // Extract final tags from updated content const finalTags = extractTagsFromFrontmatter(updatedContent); // Trigger Meilisearch reindex for this file try { await upsertFile(note.filePath); } catch (indexError) { console.warn('[Tags] Failed to reindex after tag update:', indexError); } res.json({ ok: true, tags: finalTags, noteId: note.id }); } catch (writeError) { // Restore from backup on error if (fs.existsSync(tempPath)) { try { fs.unlinkSync(tempPath); } catch {} } if (fs.existsSync(backupPath)) { try { fs.copyFileSync(backupPath, absolutePath); } catch {} } throw writeError; } } catch (error) { console.error('[Tags] Update failed:', error); res.status(500).json({ error: 'Failed to update tags', message: error.message }); } }); app.post('/api/reindex', async (_req, res) => { try { console.log('[Meili] Manual reindex triggered'); const result = await fullReindex(); res.json({ ok: true, ...result }); } catch (error) { console.error('[Meili] Reindex failed:', error); res.status(500).json({ error: 'reindex_failed', message: error.message }); } }); // Get counts for Quick Links (favorites, templates, tasks, drafts, archive) app.get('/api/quick-links/counts', async (req, res) => { try { const client = meiliClient(); const indexUid = vaultIndexName(vaultDir); const index = await ensureIndexSettings(client, indexUid); // Get counts for each filter const [favoritesResult, templatesResult, tasksResult, draftsResult, archiveResult] = await Promise.all([ index.search('', { filter: 'favoris = true', limit: 0 }), index.search('', { filter: 'template = true', limit: 0 }), index.search('', { filter: 'task = true', limit: 0 }), index.search('', { filter: 'draft = true', limit: 0 }), index.search('', { filter: 'archive = true', limit: 0 }) ]); res.json({ favorites: favoritesResult.estimatedTotalHits || 0, templates: templatesResult.estimatedTotalHits || 0, tasks: tasksResult.estimatedTotalHits || 0, drafts: draftsResult.estimatedTotalHits || 0, archive: archiveResult.estimatedTotalHits || 0 }); } catch (error) { console.error('[Quick Links] Failed to get counts:', error); res.status(500).json({ error: 'Failed to get counts', message: error.message }); } }); // Servir l'index.html pour toutes les routes (SPA) const sendIndex = (req, res) => { const indexPath = path.join(distDir, 'index.html'); if (!fs.existsSync(indexPath)) { return res .status(500) .send('Application build missing. Please run `npm run build` before starting the server.'); } res.sendFile(indexPath); }; // Phase 3: Setup performance monitoring endpoint (must be before catch-all) setupPerformanceEndpoint(app, performanceMonitor, metadataCache, meilisearchCircuitBreaker); // Setup create note endpoint (must be before catch-all) setupCreateNoteEndpoint(app, vaultDir); // SSE endpoint for vault events (folder rename, delete, etc.) app.get('/api/vault/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('Access-Control-Allow-Origin', '*'); const client = registerVaultEventClient(res); req.on('close', () => { unregisterVaultEventClient(client); }); }); // Setup rename folder endpoint (must be before catch-all) setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); // Setup delete folder endpoint (must be before catch-all) setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache); app.get('/', sendIndex); app.use((req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Not found' }); } return sendIndex(req, res); }); // Créer le répertoire de la voûte s'il n'existe pas if (!fs.existsSync(vaultDir)) { fs.mkdirSync(vaultDir, { recursive: true }); console.log('Created vault directory:', vaultDir); } // Phase 3: Deferred Meilisearch indexing (non-blocking) let indexingState = { inProgress: false, completed: false }; const scheduleIndexing = async () => { if (indexingState.inProgress) return; indexingState.inProgress = true; setImmediate(async () => { try { console.time('[Meilisearch] Background indexing'); await fullReindex(vaultDir); console.timeEnd('[Meilisearch] Background indexing'); indexingState.completed = true; console.log('[Meilisearch] ✅ Background indexing completed'); } catch (error) { console.error('[Meilisearch] ❌ Background indexing failed:', error.message); indexingState.completed = false; // Retry after 5 minutes setTimeout(() => { indexingState.inProgress = false; scheduleIndexing(); }, 5 * 60 * 1000); } }); }; const server = app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 ObsiViewer server running on http://0.0.0.0:${PORT}`); console.log(`📁 Vault directory: ${vaultDir}`); console.log(`📊 Performance monitoring: http://0.0.0.0:${PORT}/__perf`); // Schedule background indexing (non-blocking) scheduleIndexing(); console.log('✅ Server ready - Meilisearch indexing in background'); }); // Error handlers process.on('uncaughtException', (err) => { console.error('Uncaught Exception:', err); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); process.exit(1); }); // Graceful shutdown process.on('SIGINT', () => { console.log('\n🛑 Shutting down server...'); server.close(() => { console.log('✅ Server shutdown complete'); process.exit(0); }); });