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 {};
 | 
						|
  }
 | 
						|
}
 |