ObsiViewer/docs/EXCALIDRAW_IMPLEMENTATION.md

260 lines
6.7 KiB
Markdown

# Excalidraw Implementation - Obsidian Format Support
## Overview
ObsiViewer now fully supports Obsidian's Excalidraw plugin format, including:
- ✅ Reading `.excalidraw.md` files with LZ-String compressed data
- ✅ Writing files in Obsidian-compatible format
- ✅ Preserving front matter and metadata
- ✅ Round-trip compatibility (ObsiViewer ⇄ Obsidian)
- ✅ Backward compatibility with legacy flat JSON format
- ✅ Migration tool for converting old files
## File Format
### Obsidian Format (`.excalidraw.md`)
```markdown
---
excalidraw-plugin: parsed
tags: [excalidraw]
---
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
# Excalidraw Data
## Text Elements
%%
## Drawing
```compressed-json
<LZ-STRING_BASE64_COMPRESSED_DATA>
```
%%
```
The `compressed-json` block contains the Excalidraw scene data compressed using **LZ-String** (`compressToBase64`/`decompressFromBase64`).
### Legacy Format (`.excalidraw`, `.json`)
Plain JSON format:
```json
{
"elements": [...],
"appState": {...},
"files": {...}
}
```
## Architecture
### Backend (`server/`)
#### `excalidraw-obsidian.mjs`
Core utilities for parsing and serializing Excalidraw files:
- **`parseObsidianExcalidrawMd(md)`** - Parse Obsidian format, decompress LZ-String
- **`parseFlatJson(text)`** - Parse legacy JSON format
- **`toObsidianExcalidrawMd(data, frontMatter?)`** - Convert to Obsidian format
- **`extractFrontMatter(md)`** - Extract YAML front matter
- **`parseExcalidrawAny(text)`** - Auto-detect and parse any format
- **`isValidExcalidrawScene(data)`** - Validate scene structure
#### API Routes (`server/index.mjs`)
**GET `/api/files?path=<file>`**
- Accepts `path` as query parameter (properly URL-encoded)
- Returns parsed Excalidraw scene as JSON
- Supports `.excalidraw.md`, `.excalidraw`, `.json`
- Sets `ETag` header for conflict detection
**PUT `/api/files?path=<file>`**
- Accepts `path` as query parameter
- Content-Type: `application/json` (Excalidraw scene)
- Automatically converts to Obsidian format for `.excalidraw.md` files
- Preserves existing front matter
- Supports `If-Match` header for conflict detection
- Atomic write with backup (`.bak`)
**PUT `/api/files/blob?path=<file>`**
- For binary sidecars (PNG/SVG exports)
- Accepts `path` as query parameter
### Frontend (`src/app/features/drawings/`)
#### `excalidraw-io.service.ts`
Frontend parsing and serialization service:
- **`parseObsidianMd(md)`** - Parse Obsidian format
- **`parseFlatJson(text)`** - Parse legacy format
- **`toObsidianMd(data, frontMatter?)`** - Convert to Obsidian format
- **`parseAny(text)`** - Auto-detect format
- **`isValidScene(data)`** - Validate scene
#### `drawings-file.service.ts`
HTTP service for file operations:
- **`get(path)`** - Load Excalidraw file
- **`put(path, scene)`** - Save with conflict detection
- **`putForce(path, scene)`** - Force overwrite
- **`putBinary(path, blob, mime)`** - Save binary sidecar
All methods use query parameters for proper URL encoding.
#### `drawings-editor.component.ts`
Editor component with:
- Auto-save (debounced)
- Conflict detection and resolution
- Manual save (Ctrl+S)
- Export to PNG/SVG
- Theme synchronization
## Usage
### Opening Files
Files are automatically detected and parsed:
```typescript
// Backend automatically detects format
GET /api/files?path=drawing.excalidraw.md
// Returns: { elements: [...], appState: {...}, files: {...} }
```
### Saving Files
The backend automatically converts to Obsidian format:
```typescript
// Frontend sends JSON
PUT /api/files?path=drawing.excalidraw.md
Content-Type: application/json
{ elements: [...], appState: {...}, files: {...} }
// Backend writes Obsidian format with LZ-String compression
```
### Migration
Convert old flat JSON files to Obsidian format:
```bash
# Dry run (preview changes)
npm run migrate:excalidraw:dry
# Apply migration
npm run migrate:excalidraw
# Custom vault path
node server/migrate-excalidraw.mjs --vault-path=/path/to/vault
```
The migration script:
- Scans for `.excalidraw` and `.json` files
- Validates Excalidraw structure
- Converts to `.excalidraw.md` with Obsidian format
- Creates `.bak` backups
- Removes original files
## Testing
### Unit Tests
```bash
# Run backend utility tests
npm run test:excalidraw
```
Tests cover:
- ✅ Front matter extraction
- ✅ Obsidian format parsing
- ✅ LZ-String compression/decompression
- ✅ Round-trip conversion
- ✅ Legacy JSON parsing
- ✅ Edge cases (empty scenes, special characters, large files)
### E2E Tests
```bash
# Run Playwright tests
npm run test:e2e -- excalidraw.spec.ts
```
Tests cover:
- ✅ Editor loading
- ✅ API endpoints with query params
- ✅ Obsidian format parsing
- ✅ File structure validation
## Compatibility
### Obsidian → ObsiViewer
✅ Open `.excalidraw.md` files created in Obsidian
✅ Render drawings correctly
✅ Preserve all metadata and front matter
### ObsiViewer → Obsidian
✅ Save in Obsidian-compatible format
✅ Files open correctly in Obsidian
✅ No warnings or data loss
✅ Front matter preserved
### Legacy Support
✅ Open old flat JSON files
✅ Automatically convert on save
✅ Migration tool available
## Key Changes from Previous Implementation
### Backend
- ❌ Removed `zlib` decompression (wrong algorithm)
- ✅ Added **LZ-String** support (`lz-string` package)
- ❌ Removed splat routes (`/api/files/*splat`)
- ✅ Added query param routes (`/api/files?path=...`)
- ✅ Proper URL encoding/decoding
- ✅ Front matter preservation
- ✅ Atomic writes with backups
### Frontend
- ✅ Created `ExcalidrawIoService` for parsing
- ✅ Updated all HTTP calls to use query params
- ✅ Proper `encodeURIComponent` usage
- ✅ No more 400 errors on special characters
## Troubleshooting
### 400 Bad Request
**Cause**: Path not properly encoded or missing query parameter
**Fix**: Ensure using `?path=` query param, not URL path
### Invalid Excalidraw Format
**Cause**: Corrupted compressed data or wrong compression algorithm
**Fix**: Check file was created with LZ-String, not zlib
### Conflict Detected (409)
**Cause**: File modified externally while editing
**Fix**: Use "Reload from disk" or "Overwrite" buttons in UI
### File Opens in Obsidian but Not ObsiViewer
**Cause**: Unsupported Excalidraw version or corrupted data
**Fix**: Check console for parsing errors, validate JSON structure
## Dependencies
- **Backend**: `lz-string` (^1.5.0)
- **Frontend**: `lz-string` (^1.5.0)
## Future Enhancements
- [ ] Support for Excalidraw libraries
- [ ] Embedded images optimization
- [ ] Collaborative editing
- [ ] Version history
- [ ] Template system
## References
- [Obsidian Excalidraw Plugin](https://github.com/zsviczian/obsidian-excalidraw-plugin)
- [LZ-String](https://github.com/pieroxy/lz-string)
- [Excalidraw](https://excalidraw.com/)