ObsiViewer/server/index.mjs
Bruno Charest 83603e2d97 feat: enhance notes list UI with improved visual hierarchy
- Added new folder creation endpoint with support for path or parent/name parameters
- Updated notes list styling with consistent row cards and active state indicators
- Improved theme-aware color variables for better light/dark mode contrast
- Added visual depth with subtle gradient overlays and active item highlighting
- Implemented consistent styling between virtual and standard note list views
- Enhanced new note button styling for better visibility
2025-10-24 13:08:13 -04:00

1607 lines
52 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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,
setupCreateFolderEndpoint
} 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 <vault>/.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 <vault>/.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);
// Setup create folder endpoint (must be before catch-all)
setupCreateFolderEndpoint(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);
});
});