#!/usr/bin/env node import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import chokidar from 'chokidar'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 4000; const rootDir = path.resolve(__dirname, '..'); const distDir = path.join(rootDir, 'dist'); const vaultDir = path.join(rootDir, 'vault'); const vaultEventClients = new Set(); const registerVaultEventClient = (res) => { const heartbeat = setInterval(() => { try { res.write(':keepalive\n\n'); } catch { // Write failures will be handled by the close handler. } }, 20000); 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 = (vaultPath) => { const notes = []; const walk = (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()) { walk(entryPath); continue; } if (!isMarkdownFile(entry)) { continue; } try { 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 note at ${entryPath}:`, err); } } }; walk(vaultPath); return notes; }; 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(), }); }); }); 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)); // 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', (req, res) => { try { const notes = 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.' }); } }); app.get('/api/files/metadata', (req, res) => { try { const notes = loadVaultNotes(vaultDir); res.json(buildFileMetadata(notes)); } catch (error) { console.error('Failed to load file metadata:', error); res.status(500).json({ error: 'Unable to load file metadata.' }); } }); app.get('/api/files/by-date', (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 = 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', (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 = 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()); 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.5, repelStrength: 10, linkStrength: 1, linkDistance: 250, 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; } // 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); }; 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); } app.listen(PORT, '0.0.0.0', () => { console.log(`ObsiViewer server running on http://0.0.0.0:${PORT}`); console.log(`Vault directory: ${vaultDir}`); });