#!/usr/bin/env node /** * Front-matter enrichment utility for ObsiViewer * Ensures all markdown files have complete, standardized YAML front-matter */ import { promises as fs } from 'fs'; import path from 'path'; import matter from 'gray-matter'; import { Document, parseDocument } from 'yaml'; const TZ_OFFSET = '-04:00'; // America/Toronto /** * Mutex map to prevent concurrent writes to the same file */ const fileLocks = new Map(); /** * Acquire a lock for a file path */ async function acquireLock(filePath) { while (fileLocks.has(filePath)) { await new Promise(resolve => setTimeout(resolve, 10)); } fileLocks.set(filePath, true); } /** * Release a lock for a file path */ function releaseLock(filePath) { fileLocks.delete(filePath); } /** * Format a date to ISO 8601 with Toronto timezone offset * @param {Date} date - Date to format * @returns {string} - ISO 8601 formatted date with timezone */ function formatDateISO(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${TZ_OFFSET}`; } /** * Get current timestamp in ISO 8601 format with timezone */ function nowISO() { return formatDateISO(new Date()); } /** * Get file creation date (birthtime or fallback to ctime/mtime) */ async function getCreationDate(absPath) { try { const stats = await fs.stat(absPath); // Use birthtime if available (Windows, macOS), otherwise ctime const creationTime = stats.birthtime && stats.birthtime.getTime() > 0 ? stats.birthtime : (stats.ctime || stats.mtime); return formatDateISO(creationTime); } catch (error) { console.warn(`[ensureFrontmatter] Could not get creation date for ${absPath}:`, error.message); return nowISO(); } } /** * Enrich front-matter of a markdown file with required properties * * @param {string} absPath - Absolute path to the markdown file * @returns {Promise<{modified: boolean, content: string}>} */ export async function enrichFrontmatterOnOpen(absPath) { await acquireLock(absPath); try { // Read file const raw = await fs.readFile(absPath, 'utf-8'); // Parse front-matter and content (disable date parsing to keep strings) const parsed = matter(raw, { language: 'yaml', delimiters: '---', engines: { yaml: { parse: (str) => { // Use yaml library directly to preserve types const doc = parseDocument(str); return doc.toJSON(); } } } }); // Get file basename without extension const basename = path.basename(absPath, '.md'); // Get creation date const creationDate = await getCreationDate(absPath); const modificationDate = nowISO(); // Define required properties in order const requiredProps = [ ['titre', basename], ['auteur', 'Bruno Charest'], ['creation_date', creationDate], ['modification_date', modificationDate], ['catégorie', ''], ['tags', []], ['aliases', []], ['status', 'en-cours'], ['publish', false], ['favoris', false], ['template', false], ['task', false], ['archive', false], ['draft', false], ['private', false], ]; // Parse existing front-matter data const existingData = parsed.data || {}; // Track if we inserted any new properties let inserted = false; // Build complete data object with defaults for missing keys const completeData = {}; // First, add all required properties in order for (const [key, defaultValue] of requiredProps) { if (existingData.hasOwnProperty(key)) { // Preserve existing value completeData[key] = existingData[key]; } else { // Add default value completeData[key] = defaultValue; inserted = true; } } // Update modification_date only if we inserted something if (inserted) { completeData['modification_date'] = modificationDate; } // Then add any custom properties that exist for (const key of Object.keys(existingData)) { if (!completeData.hasOwnProperty(key)) { completeData[key] = existingData[key]; } } // Build new ordered document from complete data const orderedDoc = new Document(); for (const key of Object.keys(completeData)) { orderedDoc.set(key, completeData[key]); } // Serialize YAML without blank lines let yamlContent = orderedDoc.toString().trim(); // Remove any blank lines within the YAML yamlContent = yamlContent.split('\n').filter(line => line.trim() !== '').join('\n'); // Reconstruct the file const frontmatter = `---\n${yamlContent}\n---`; const bodyContent = parsed.content; // Ensure proper spacing after front-matter const newContent = bodyContent.startsWith('\n') ? `${frontmatter}${bodyContent}` : `${frontmatter}\n${bodyContent}`; // Check if content actually changed (compare normalized content) const modified = raw.trim() !== newContent.trim(); if (modified) { // Atomic write: temp file + rename const tempPath = `${absPath}.tmp`; const backupPath = `${absPath}.bak`; try { // Create backup await fs.copyFile(absPath, backupPath); // Write to temp await fs.writeFile(tempPath, newContent, 'utf-8'); // Atomic rename await fs.rename(tempPath, absPath); console.log(`[ensureFrontmatter] Enriched: ${path.basename(absPath)}`); } catch (writeError) { // Cleanup on error try { await fs.unlink(tempPath).catch(() => {}); await fs.copyFile(backupPath, absPath).catch(() => {}); } catch {} throw writeError; } } return { modified, content: newContent }; } finally { releaseLock(absPath); } } /** * Extract front-matter properties from a markdown file * * @param {string} absPath - Absolute path to the markdown file * @returns {Promise} - Front-matter data */ export async function extractFrontmatter(absPath) { try { const raw = await fs.readFile(absPath, 'utf-8'); const parsed = matter(raw, { language: 'yaml', delimiters: '---', engines: { yaml: { parse: (str) => { // Use yaml library directly to preserve types const doc = parseDocument(str); return doc.toJSON(); } } } }); return parsed.data || {}; } catch (error) { console.warn(`[ensureFrontmatter] Could not extract front-matter from ${absPath}:`, error.message); return {}; } }