ObsiViewer/server/excalidraw-obsidian.mjs

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