ObsiViewer/server/index.mjs

720 lines
21 KiB
JavaScript
Raw Permalink 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 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 <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];
records.forEach((record) => {
if (!record || typeof record !== 'object') {
console.warn('[FrontendLog] Ignored invalid record', record);
return;
}
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 });
} catch (error) {
console.error('Failed to process frontend logs:', error);
return res.status(500).json({ error: 'Failed to process logs' });
}
});
app.post('/api/logs', (req, res) => {
const { source = 'frontend', level = 'info', message = '', data = null, timestamp = Date.now() } = req.body || {};
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' });
});
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;
}
// 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}`);
});