diff --git a/backend/indexer.py b/backend/indexer.py
index e493608..2b6384e 100644
--- a/backend/indexer.py
+++ b/backend/indexer.py
@@ -63,6 +63,7 @@ SUPPORTED_EXTENSIONS = {
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
".php", ".sql", ".r", ".m", ".swift", ".kt",
".dockerfile", ".makefile", ".cmake",
+ ".excalidraw",
}
diff --git a/backend/main.py b/backend/main.py
index 46eb98a..1342547 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -118,6 +118,15 @@ class FileContentResponse(BaseModel):
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
+ is_pdf: bool | None = Field(default=None, description="True for PDF files")
+ is_image: bool | None = Field(default=None, description="True for image files")
+ is_csv: bool | None = Field(default=None, description="True for CSV files")
+ is_json: bool | None = Field(default=None, description="True for JSON files")
+ is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
+ excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
+ pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
+ pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
+ image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
@@ -1793,8 +1802,13 @@ async def api_file_create(
# Create parent directories if needed
file_path.parent.mkdir(parents=True, exist_ok=True)
+ # For .excalidraw files, inject skeleton JSON if content is empty
+ content = body.content
+ if ext == ".excalidraw" and not content.strip():
+ content = '{"type":"excalidraw","version":2,"elements":[],"appState":{"viewBackgroundColor":"#ffffff"},"files":{}}'
+
# Create file with initial content
- file_path.write_text(body.content, encoding="utf-8")
+ file_path.write_text(content, encoding="utf-8")
logger.info(f"File created: {vault_name}/{body.path}")
# Update index
@@ -2489,6 +2503,35 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"is_markdown": False, "is_json": True,
}
+ # === Excalidraw: diagram editor ===
+ if ext == ".excalidraw":
+ import json as json_mod
+ try:
+ parsed = json_mod.loads(raw)
+ except json_mod.JSONDecodeError:
+ parsed = None
+ if parsed and parsed.get("type") == "excalidraw":
+ return {
+ "vault": vault_name,
+ "path": path,
+ "title": parsed.get("appState", {}).get("name") or file_path.name,
+ "tags": [],
+ "frontmatter": {},
+ "html": "",
+ "raw_length": len(raw),
+ "extension": ext,
+ "is_markdown": False,
+ "is_excalidraw": True,
+ "excalidraw_data": {
+ "elements": parsed.get("elements", []),
+ "appState": parsed.get("appState", {}),
+ "files": parsed.get("files", {}),
+ },
+ }
+ else:
+ # Not a valid Excalidraw file โ fall through to text viewer
+ pass
+
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
diff --git a/frontend/excalidraw-editor.html b/frontend/excalidraw-editor.html
new file mode 100644
index 0000000..36a4a0e
--- /dev/null
+++ b/frontend/excalidraw-editor.html
@@ -0,0 +1,292 @@
+
+
+
+
+
+ Excalidraw Editor
+
+
+
+ Loading Excalidrawโฆ
+
+
+ โ Modified
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/js/excalidraw-viewer.js b/frontend/js/excalidraw-viewer.js
new file mode 100644
index 0000000..4600662
--- /dev/null
+++ b/frontend/js/excalidraw-viewer.js
@@ -0,0 +1,152 @@
+/**
+ * Excalidraw Viewer โ iframe-based diagram editor integration for ObsiGate.
+ *
+ * Renders .excalidraw files in an iframe that loads the standalone
+ * excalidraw-editor.html. Communication between parent and iframe
+ * via postMessage:
+ *
+ * Parent โ Iframe: init {data, theme} | theme {theme} | requestSave
+ * Iframe โ Parent: ready | save {data} | modified {dirty}
+ */
+
+import { api } from './auth.js';
+import { showToast } from './ui.js';
+
+// Track active Excalidraw iframes by a unique id
+const _activeEditors = new Map();
+let _editorIdCounter = 0;
+
+/**
+ * Render an Excalidraw editor inside the given container element.
+ *
+ * @param {HTMLElement} container - DOM element to host the iframe (usually content-area)
+ * @param {object} data - File data from /api/file/{vault} (must have is_excalidraw: true)
+ * @param {string} vaultName
+ * @param {string} filePath
+ * @param {object} [opts]
+ * @param {boolean} [opts.readOnly=false] - If true, hide save button in iframe
+ */
+export function renderExcalidraw(container, data, vaultName, filePath, opts = {}) {
+ const editorId = `excalidraw-${++_editorIdCounter}`;
+
+ // Build the iframe
+ const iframe = document.createElement('iframe');
+ iframe.id = editorId;
+ iframe.src = '/static/excalidraw-editor.html';
+ iframe.sandbox.add('allow-scripts');
+ iframe.sandbox.add('allow-same-origin');
+ iframe.style.cssText = 'width:100%;height:100%;border:none;';
+
+ // Clean container and insert iframe
+ container.innerHTML = '';
+ container.appendChild(iframe);
+
+ // Track this editor instance
+ const editorState = {
+ id: editorId,
+ iframe,
+ vault: vaultName,
+ path: filePath,
+ isDirty: false,
+ ready: false,
+ saveTimer: null,
+ };
+ _activeEditors.set(editorId, editorState);
+
+ // Get current theme
+ const theme = document.documentElement.getAttribute('data-theme') || 'dark';
+
+ // Wait for iframe to load, then send init
+ iframe.addEventListener('load', () => {
+ // Send init message when iframe sends 'ready'
+ // (iframe may load before React mounts, so we wait for the ready signal)
+ // We set up the message listener before sending anything
+ });
+
+ // Handle messages from iframe
+ window.addEventListener('message', (event) => {
+ // Verify source is our iframe
+ if (event.source !== iframe.contentWindow) return;
+
+ const msg = event.data;
+ if (!msg || typeof msg.type !== 'string') return;
+
+ switch (msg.type) {
+ case 'ready':
+ // Iframe is mounted โ send initial data
+ editorState.ready = true;
+ iframe.contentWindow.postMessage({
+ type: 'init',
+ data: data.excalidraw_data || {
+ elements: [],
+ appState: { viewBackgroundColor: '#ffffff' },
+ files: {},
+ },
+ theme: theme,
+ }, '*');
+ break;
+
+ case 'save':
+ if (msg.data) {
+ _saveExcalidrawFile(editorState, msg.data);
+ }
+ break;
+
+ case 'modified':
+ editorState.isDirty = msg.dirty === true;
+ // If dirty, start auto-save timer (2s debounce)
+ if (editorState.isDirty) {
+ if (editorState.saveTimer) clearTimeout(editorState.saveTimer);
+ editorState.saveTimer = setTimeout(() => {
+ if (editorState.isDirty && editorState.ready) {
+ iframe.contentWindow.postMessage({ type: 'requestSave' }, '*');
+ }
+ }, 2000);
+ }
+ break;
+ }
+ });
+}
+
+/**
+ * Save Excalidraw data back to the server.
+ */
+async function _saveExcalidrawFile(state, excalidrawData) {
+ try {
+ const jsonStr = JSON.stringify(excalidrawData, null, 2);
+ await api(`/api/file/${encodeURIComponent(state.vault)}/save?path=${encodeURIComponent(state.path)}&backup=false`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: jsonStr }),
+ });
+ // Toast is handled by the caller or we show a quick one
+ showToast('Excalidraw saved', 'success');
+ } catch (err) {
+ console.error('[Excalidraw] Save failed:', err);
+ showToast('Failed to save Excalidraw: ' + (err.message || 'unknown error'), 'error');
+ }
+}
+
+/**
+ * Notify all active Excalidraw editors of a theme change.
+ */
+export function notifyExcalidrawThemeChange(theme) {
+ _activeEditors.forEach((state) => {
+ if (state.ready && state.iframe.contentWindow) {
+ state.iframe.contentWindow.postMessage({ type: 'theme', theme }, '*');
+ }
+ });
+}
+
+/**
+ * Clean up an editor instance (e.g., when tab is closed).
+ */
+export function destroyExcalidrawEditor(editorId) {
+ const state = _activeEditors.get(editorId);
+ if (state) {
+ if (state.saveTimer) clearTimeout(state.saveTimer);
+ _activeEditors.delete(editorId);
+ }
+}
+
+export default { renderExcalidraw, notifyExcalidrawThemeChange, destroyExcalidrawEditor };
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index 36c0c5d..cdac13d 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -776,6 +776,7 @@ const FileOperations = {
+
diff --git a/frontend/js/utils.js b/frontend/js/utils.js
index b5ab338..e9e0c1d 100644
--- a/frontend/js/utils.js
+++ b/frontend/js/utils.js
@@ -79,6 +79,7 @@ const EXT_ICONS = {
".xml": "file-code",
".csv": "table",
".tsv": "table",
+ ".excalidraw": "pen-tool",
".sql": "database",
".db": "database",
".sqlite": "database",
diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js
index a98c6f8..2381cfc 100644
--- a/frontend/js/viewer.js
+++ b/frontend/js/viewer.js
@@ -7,6 +7,7 @@ import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTr
import { AutocompleteDropdown, performAdvancedSearch } from './search.js';
import { initDashboardTabs } from './sync.js';
import { renderMermaidBlocks } from './mermaid-viewer.js';
+import { renderExcalidraw } from './excalidraw-viewer.js';
import { DashboardStatsWidget, DashboardRecentWidget, DashboardBookmarkWidget, DashboardSharedWidget, DashboardConflictsWidget } from './dashboard.js';
import { openShareDialog } from './config.js';
import { cacheViewedFile, getCachedFile } from './offline.js';
@@ -448,6 +449,12 @@ export function renderFile(data) {
return;
}
+ // Handle Excalidraw โ render in iframe editor
+ if (data.is_excalidraw) {
+ renderExcalidraw(area, data, data.vault, data.path);
+ return;
+ }
+
// Handle images
if (data.is_image) {
const imgUrl = `/api/file/${encodeURIComponent(data.vault)}/raw?path=${encodeURIComponent(data.path)}`;
diff --git a/tests/test_excalidraw.py b/tests/test_excalidraw.py
new file mode 100644
index 0000000..928f86b
--- /dev/null
+++ b/tests/test_excalidraw.py
@@ -0,0 +1,166 @@
+"""Tests for Excalidraw file support in ObsiGate."""
+
+import json
+
+
+EXCALIDRAW_SKELETON = {
+ "type": "excalidraw",
+ "version": 2,
+ "elements": [],
+ "appState": {"viewBackgroundColor": "#ffffff"},
+ "files": {},
+}
+
+EXCALIDRAW_WITH_CONTENT = {
+ "type": "excalidraw",
+ "version": 2,
+ "elements": [
+ {
+ "id": "rect-1",
+ "type": "rectangle",
+ "x": 100, "y": 200, "width": 300, "height": 150,
+ "angle": 0, "strokeColor": "#1e1e1e",
+ "backgroundColor": "#ffec99", "fillStyle": "solid",
+ "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 1, "opacity": 100, "groupIds": [],
+ "roundness": {"type": 3},
+ "boundElements": None, "updated": 1,
+ "link": None, "locked": False,
+ },
+ {
+ "id": "text-1", "type": "text",
+ "x": 150, "y": 260, "width": 200, "height": 30,
+ "angle": 0, "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent", "fillStyle": "solid",
+ "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 1, "opacity": 100, "groupIds": [],
+ "roundness": None, "boundElements": None,
+ "updated": 1, "link": None, "locked": False,
+ "text": "Hello Excalidraw", "fontSize": 20,
+ "fontFamily": 1, "textAlign": "center",
+ "verticalAlign": "middle", "containerId": "rect-1",
+ "originalText": "Hello Excalidraw",
+ "autoResize": True, "lineHeight": 1.25,
+ },
+ ],
+ "appState": {"viewBackgroundColor": "#ffffff", "name": "My Diagram"},
+ "files": {},
+}
+
+
+class TestExcalidrawDetection:
+ """Test that the /api/file endpoint correctly detects .excalidraw files."""
+
+ VAULT = "TestVault"
+
+ def test_valid_excalidraw_file(self, client):
+ """A valid .excalidraw file should return is_excalidraw: true."""
+ path = "diagram.excalidraw"
+ resp = client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": json.dumps(EXCALIDRAW_WITH_CONTENT)},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert data["is_excalidraw"] is True
+ assert "excalidraw_data" in data
+ assert len(data["excalidraw_data"]["elements"]) == 2
+ assert data["excalidraw_data"]["elements"][0]["type"] == "rectangle"
+ assert data["title"] == "My Diagram"
+
+ def test_empty_excalidraw_creation(self, client):
+ """Creating a .excalidraw with empty content injects skeleton JSON."""
+ path = "empty.excalidraw"
+ resp = client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": ""},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert data["is_excalidraw"] is True
+ assert data["excalidraw_data"]["elements"] == []
+ assert data["excalidraw_data"]["appState"]["viewBackgroundColor"] == "#ffffff"
+
+ def test_invalid_json_excalidraw_fallback(self, client):
+ """A .excalidraw with invalid JSON should show as text, not Excalidraw."""
+ path = "corrupt.excalidraw"
+ resp = client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": "this is not valid json!!!"},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert not data.get("is_excalidraw")
+
+ def test_json_without_excalidraw_type(self, client):
+ """A .excalidraw with valid JSON but wrong type falls through."""
+ path = "not-excalidraw.excalidraw"
+ resp = client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": '{"type":"something-else","data":[1,2,3]}'},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert not data.get("is_excalidraw")
+
+ def test_excalidraw_in_tree(self, client):
+ """A .excalidraw file appears in the file tree."""
+ path = "tree-diagram.excalidraw"
+ resp = client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": json.dumps(EXCALIDRAW_SKELETON)},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/browse/{self.VAULT}", params={"path": ""})
+ assert resp.status_code == 200, resp.text
+ items = resp.json()["items"]
+ names = [i["name"] for i in items]
+ assert "tree-diagram.excalidraw" in names, f"Not found in: {names}"
+
+ def test_excalidraw_save_roundtrip(self, client):
+ """Create, edit (PUT), and re-read an .excalidraw file."""
+ path = "roundtrip.excalidraw"
+
+ client.post(
+ f"/api/file/{self.VAULT}",
+ json={"path": path, "content": ""},
+ )
+
+ modified = dict(EXCALIDRAW_SKELETON)
+ modified["elements"] = [{
+ "id": "ellipse-1", "type": "ellipse",
+ "x": 50, "y": 50, "width": 100, "height": 80,
+ "angle": 0, "strokeColor": "#1e1e1e",
+ "backgroundColor": "#a5d8ff", "fillStyle": "solid",
+ "strokeWidth": 2, "strokeStyle": "solid",
+ "roughness": 1, "opacity": 100, "groupIds": [],
+ "roundness": {"type": 2},
+ "boundElements": None, "updated": 1,
+ "link": None, "locked": False,
+ }]
+ resp = client.put(
+ f"/api/file/{self.VAULT}/save",
+ params={"path": path, "backup": "false"},
+ json={"content": json.dumps(modified)},
+ )
+ assert resp.status_code == 200, resp.text
+
+ resp = client.get(f"/api/file/{self.VAULT}", params={"path": path})
+ assert resp.status_code == 200, resp.text
+ data = resp.json()
+ assert data["is_excalidraw"] is True
+ assert len(data["excalidraw_data"]["elements"]) == 1
+ assert data["excalidraw_data"]["elements"][0]["type"] == "ellipse"