202 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			202 lines
		
	
	
		
			6.0 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
#!/usr/bin/env node
 | 
						|
 | 
						|
/**
 | 
						|
 * Obsidian Excalidraw format utilities
 | 
						|
 * Handles parsing and serialization of .excalidraw.md files in Obsidian format
 | 
						|
 */
 | 
						|
 | 
						|
import LZString from 'lz-string';
 | 
						|
 | 
						|
/**
 | 
						|
 * Extract front matter from markdown content
 | 
						|
 * @param {string} md - Markdown content
 | 
						|
 * @returns {string|null} - Front matter content (including --- delimiters) or null
 | 
						|
 */
 | 
						|
export function extractFrontMatter(md) {
 | 
						|
  const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
 | 
						|
  return match ? match[0] : null;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Parse Obsidian Excalidraw markdown format
 | 
						|
 * Extracts compressed-json block and decompresses using LZ-String
 | 
						|
 * @param {string} md - Markdown content
 | 
						|
 * @returns {object|null} - Excalidraw scene data or null if parsing fails
 | 
						|
 */
 | 
						|
export function parseObsidianExcalidrawMd(md) {
 | 
						|
  if (!md || typeof md !== 'string') return null;
 | 
						|
 | 
						|
  // Try to extract compressed-json block
 | 
						|
  const compressedMatch = md.match(/```\s*compressed-json\s*\n([\s\S]*?)\n```/i);
 | 
						|
  
 | 
						|
  if (compressedMatch && compressedMatch[1]) {
 | 
						|
    try {
 | 
						|
      // Remove whitespace from base64 data
 | 
						|
      const compressed = compressedMatch[1].replace(/\s+/g, '');
 | 
						|
      
 | 
						|
      // Decompress using LZ-String
 | 
						|
      const decompressed = LZString.decompressFromBase64(compressed);
 | 
						|
      
 | 
						|
      if (!decompressed) {
 | 
						|
        console.warn('[Excalidraw] LZ-String decompression returned null');
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      
 | 
						|
      // Parse JSON
 | 
						|
      const data = JSON.parse(decompressed);
 | 
						|
      return data;
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[Excalidraw] Failed to parse compressed-json:', error.message);
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // Fallback: try to extract plain json block
 | 
						|
  const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
 | 
						|
  
 | 
						|
  if (jsonMatch && jsonMatch[1]) {
 | 
						|
    try {
 | 
						|
      const data = JSON.parse(jsonMatch[1].trim());
 | 
						|
      return data;
 | 
						|
    } catch (error) {
 | 
						|
      console.error('[Excalidraw] Failed to parse json block:', error.message);
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  return null;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Parse flat JSON format (legacy ObsiViewer format)
 | 
						|
 * @param {string} text - JSON text
 | 
						|
 * @returns {object|null} - Excalidraw scene data or null if parsing fails
 | 
						|
 */
 | 
						|
export function parseFlatJson(text) {
 | 
						|
  if (!text || typeof text !== 'string') return null;
 | 
						|
  
 | 
						|
  try {
 | 
						|
    const data = JSON.parse(text);
 | 
						|
    
 | 
						|
    // Validate it has the expected structure
 | 
						|
    if (data && typeof data === 'object' && Array.isArray(data.elements)) {
 | 
						|
      return data;
 | 
						|
    }
 | 
						|
    
 | 
						|
    return null;
 | 
						|
  } catch (error) {
 | 
						|
    return null;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Convert Excalidraw scene to Obsidian markdown format
 | 
						|
 * @param {object} data - Excalidraw scene data
 | 
						|
 * @param {string|null} existingFrontMatter - Existing front matter to preserve
 | 
						|
 * @returns {string} - Markdown content in Obsidian format
 | 
						|
 */
 | 
						|
export function toObsidianExcalidrawMd(data, existingFrontMatter = null) {
 | 
						|
  // Normalize scene data
 | 
						|
  const scene = {
 | 
						|
    elements: Array.isArray(data?.elements) ? data.elements : [],
 | 
						|
    appState: (data && typeof data.appState === 'object') ? data.appState : {},
 | 
						|
    files: (data && typeof data.files === 'object') ? data.files : {}
 | 
						|
  };
 | 
						|
 | 
						|
  // Serialize to JSON
 | 
						|
  const json = JSON.stringify(scene);
 | 
						|
  
 | 
						|
  // Compress using LZ-String
 | 
						|
  const compressed = LZString.compressToBase64(json);
 | 
						|
 | 
						|
  // Build front matter: merge existing with required keys
 | 
						|
  const ensureFrontMatter = (fmRaw) => {
 | 
						|
    const wrap = (inner) => `---\n${inner}\n---`;
 | 
						|
    if (!fmRaw || typeof fmRaw !== 'string' || !/^---[\s\S]*?---\s*$/m.test(fmRaw)) {
 | 
						|
      return wrap(`excalidraw-plugin: parsed\ntags: [excalidraw]`);
 | 
						|
    }
 | 
						|
    // Strip leading/trailing ---
 | 
						|
    const inner = fmRaw.replace(/^---\s*\n?/, '').replace(/\n?---\s*$/, '');
 | 
						|
    const lines = inner.split(/\r?\n/);
 | 
						|
    let hasPlugin = false;
 | 
						|
    let tagsLineIdx = -1;
 | 
						|
    for (let i = 0; i < lines.length; i++) {
 | 
						|
      const l = lines[i].trim();
 | 
						|
      if (/^excalidraw-plugin\s*:/i.test(l)) hasPlugin = true;
 | 
						|
      if (/^tags\s*:/i.test(l)) tagsLineIdx = i;
 | 
						|
    }
 | 
						|
    if (!hasPlugin) {
 | 
						|
      lines.push('excalidraw-plugin: parsed');
 | 
						|
    }
 | 
						|
    if (tagsLineIdx === -1) {
 | 
						|
      lines.push('tags: [excalidraw]');
 | 
						|
    } else {
 | 
						|
      // Ensure 'excalidraw' tag present; naive merge without YAML parse
 | 
						|
      const orig = lines[tagsLineIdx];
 | 
						|
      if (!/excalidraw\b/.test(orig)) {
 | 
						|
        const mArr = orig.match(/^tags\s*:\s*\[(.*)\]\s*$/i);
 | 
						|
        if (mArr) {
 | 
						|
          const inside = mArr[1].trim();
 | 
						|
          const updatedInside = inside ? `${inside}, excalidraw` : 'excalidraw';
 | 
						|
          lines[tagsLineIdx] = `tags: [${updatedInside}]`;
 | 
						|
        } else if (/^tags\s*:\s*$/i.test(orig)) {
 | 
						|
          lines[tagsLineIdx] = 'tags: [excalidraw]';
 | 
						|
        } else {
 | 
						|
          // Fallback: append a separate tags line
 | 
						|
          lines.push('tags: [excalidraw]');
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return wrap(lines.join('\n'));
 | 
						|
  };
 | 
						|
 | 
						|
  const frontMatter = ensureFrontMatter(existingFrontMatter?.trim() || null);
 | 
						|
 | 
						|
  // Banner text (Obsidian standard)
 | 
						|
  const banner = `==⚠  Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
 | 
						|
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'`;
 | 
						|
 | 
						|
  // Construct full markdown
 | 
						|
  return `${frontMatter}
 | 
						|
${banner}
 | 
						|
 | 
						|
# Excalidraw Data
 | 
						|
 | 
						|
## Text Elements
 | 
						|
%%
 | 
						|
## Drawing
 | 
						|
\`\`\`compressed-json
 | 
						|
${compressed}
 | 
						|
\`\`\`
 | 
						|
%%`;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Parse Excalidraw content from any supported format
 | 
						|
 * Tries Obsidian MD format first, then falls back to flat JSON
 | 
						|
 * @param {string} text - File content
 | 
						|
 * @returns {object|null} - Excalidraw scene data or null
 | 
						|
 */
 | 
						|
export function parseExcalidrawAny(text) {
 | 
						|
  // Try Obsidian format first
 | 
						|
  let data = parseObsidianExcalidrawMd(text);
 | 
						|
  if (data) return data;
 | 
						|
  
 | 
						|
  // Fallback to flat JSON
 | 
						|
  data = parseFlatJson(text);
 | 
						|
  if (data) return data;
 | 
						|
  
 | 
						|
  return null;
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Validate Excalidraw scene structure
 | 
						|
 * @param {any} data - Data to validate
 | 
						|
 * @returns {boolean} - True if valid
 | 
						|
 */
 | 
						|
export function isValidExcalidrawScene(data) {
 | 
						|
  if (!data || typeof data !== 'object') return false;
 | 
						|
  if (!Array.isArray(data.elements)) return false;
 | 
						|
  return true;
 | 
						|
}
 |