ObsiViewer/server/ensureFrontmatter.mjs

249 lines
6.9 KiB
JavaScript

#!/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<object>} - 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 {};
}
}