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