ObsiViewer/server/index.mjs

364 lines
10 KiB
JavaScript

#!/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));
// 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.' });
}
});
// 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}`);
});