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"