249 lines
6.9 KiB
JavaScript
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 {};
|
|
}
|
|
}
|