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