/** * MiActionExecutor — Executes parsed MI actions against the live canvas * and module APIs. * * Canvas actions use `window.__canvasApi` exposed by canvas.html. * Module actions (create-content, etc.) make authenticated fetch calls to * the module's API endpoints via the routing map in mi-module-routes.ts. */ import type { MiAction } from "./mi-actions"; import { resolveModuleRoute } from "./mi-module-routes"; export interface ExecutionResult { action: MiAction; ok: boolean; shapeId?: string; /** ID returned by module APIs (e.g. created event ID) */ contentId?: string; error?: string; } /** Progress callback for scaffold/batch execution. */ export type ProgressCallback = (current: number, total: number, label: string) => void; interface CanvasApi { newShape: (tagName: string, props?: Record) => any; findFreePosition: (width: number, height: number) => { x: number; y: number }; SHAPE_DEFAULTS: Record; setupShapeEventListeners: (shape: any) => void; sync: any; canvasContent: HTMLElement; } function getCanvasApi(): CanvasApi | null { return (window as any).__canvasApi || null; } /** Resolve `$N` backreferences in an id string. */ function resolveRef(id: string, refMap: Map): string { if (id.startsWith("$")) { return refMap.get(id) || id; } return id; } export class MiActionExecutor { static instance: MiActionExecutor | null = null; #space = ""; #token = ""; constructor() { if (MiActionExecutor.instance) return MiActionExecutor.instance; MiActionExecutor.instance = this; } /** Set the current space and auth token for module API calls. */ setContext(space: string, token: string): void { this.#space = space; this.#token = token; } execute(actions: MiAction[], onProgress?: ProgressCallback): ExecutionResult[] { const results: ExecutionResult[] = []; const refMap = new Map(); for (let i = 0; i < actions.length; i++) { const action = actions[i]; onProgress?.(i + 1, actions.length, this.#actionLabel(action)); try { const result = this.#executeOne(action, refMap); results.push(result); } catch (e: any) { results.push({ action, ok: false, error: e.message }); } } return results; } /** Execute actions that may include async module API calls. */ async executeAsync(actions: MiAction[], onProgress?: ProgressCallback): Promise { const results: ExecutionResult[] = []; const refMap = new Map(); for (let i = 0; i < actions.length; i++) { const action = actions[i]; onProgress?.(i + 1, actions.length, this.#actionLabel(action)); try { const result = await this.#executeOneAsync(action, refMap); results.push(result); } catch (e: any) { results.push({ action, ok: false, error: e.message }); } } return results; } #actionLabel(action: MiAction): string { switch (action.type) { case "create-shape": return `Creating ${action.tagName}`; case "create-content": return `Creating ${action.contentType} in ${action.module}`; case "scaffold": return `Running "${action.name}"`; case "batch": return `Executing batch`; default: return action.type; } } #executeOne(action: MiAction, refMap: Map): ExecutionResult { switch (action.type) { case "create-shape": case "update-shape": case "delete-shape": case "connect": case "move-shape": case "transform": case "navigate": return this.#executeCanvasAction(action, refMap); // Module/composite actions need async — return immediately with a note case "create-content": case "update-content": case "delete-content": case "enable-module": case "disable-module": case "configure-module": case "scaffold": case "batch": return { action, ok: false, error: "Use executeAsync() for module actions" }; default: return { action, ok: false, error: "Unknown action type" }; } } async #executeOneAsync(action: MiAction, refMap: Map): Promise { switch (action.type) { // Canvas actions are sync case "create-shape": case "update-shape": case "delete-shape": case "connect": case "move-shape": case "transform": case "navigate": return this.#executeCanvasAction(action, refMap); // Module content actions case "create-content": return this.#executeCreateContent(action, refMap); case "update-content": return this.#executeUpdateContent(action); case "delete-content": return this.#executeDeleteContent(action); // Admin actions case "enable-module": case "disable-module": case "configure-module": return { action, ok: false, error: "Admin actions not yet implemented" }; // Composite case "scaffold": return this.#executeScaffold(action, refMap); case "batch": return this.#executeBatch(action, refMap); default: return { action, ok: false, error: "Unknown action type" }; } } #executeCanvasAction(action: MiAction, refMap: Map): ExecutionResult { const api = getCanvasApi(); if (!api) { return { action, ok: false, error: "Canvas API not available" }; } switch (action.type) { case "create-shape": { const shape = api.newShape(action.tagName, action.props || {}); if (!shape) { return { action, ok: false, error: `Failed to create ${action.tagName}` }; } if (action.ref) { refMap.set(action.ref, shape.id); } return { action, ok: true, shapeId: shape.id }; } case "update-shape": { const el = api.canvasContent.querySelector(`#${CSS.escape(action.shapeId)}`); if (!el) { return { action, ok: false, error: `Shape ${action.shapeId} not found` }; } for (const [key, value] of Object.entries(action.fields)) { (el as any)[key] = value; } api.sync.updateShape?.(action.shapeId); return { action, ok: true, shapeId: action.shapeId }; } case "delete-shape": { const el = api.canvasContent.querySelector(`#${CSS.escape(action.shapeId)}`); if (!el) { return { action, ok: false, error: `Shape ${action.shapeId} not found` }; } api.sync.deleteShape(action.shapeId); el.remove(); return { action, ok: true, shapeId: action.shapeId }; } case "connect": { const sourceId = resolveRef(action.sourceId, refMap); const targetId = resolveRef(action.targetId, refMap); const arrow = document.createElement("folk-arrow"); const arrowId = `arrow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; arrow.id = arrowId; (arrow as any).sourceId = sourceId; (arrow as any).targetId = targetId; if (action.color) (arrow as any).color = action.color; api.canvasContent.appendChild(arrow); api.sync.registerShape(arrow); return { action, ok: true, shapeId: arrowId }; } case "move-shape": { const el = api.canvasContent.querySelector(`#${CSS.escape(action.shapeId)}`); if (!el) { return { action, ok: false, error: `Shape ${action.shapeId} not found` }; } (el as any).x = action.x; (el as any).y = action.y; api.sync.updateShape?.(action.shapeId); return { action, ok: true, shapeId: action.shapeId }; } case "transform": { const transforms = (window as any).__miSelectionTransforms; if (transforms && transforms[action.transform]) { const shapeEls = action.shapeIds .map((id) => api.canvasContent.querySelector(`#${CSS.escape(id)}`)) .filter(Boolean) as HTMLElement[]; if (shapeEls.length === 0) { return { action, ok: false, error: "No matching shapes found" }; } transforms[action.transform](shapeEls); for (const el of shapeEls) { api.sync.updateShape?.(el.id); } return { action, ok: true }; } return { action, ok: false, error: `Unknown transform: ${action.transform}` }; } case "navigate": { window.location.href = action.path; return { action, ok: true }; } default: return { action, ok: false, error: "Unknown canvas action" }; } } // ── Module content actions ── async #executeCreateContent( action: Extract, refMap: Map, ): Promise { const route = resolveModuleRoute(action.module, action.contentType, this.#space); if (!route) { return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; } try { const res = await fetch(route.url, { method: route.method, headers: { "Content-Type": "application/json", ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), }, body: JSON.stringify(action.body), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); return { action, ok: false, error: err.error || `HTTP ${res.status}` }; } const data = await res.json().catch(() => ({})); const contentId = data.id || data._id || undefined; if (action.ref && contentId) { refMap.set(action.ref, contentId); } return { action, ok: true, contentId }; } catch (e: any) { return { action, ok: false, error: e.message }; } } async #executeUpdateContent( action: Extract, ): Promise { const route = resolveModuleRoute(action.module, action.contentType, this.#space); if (!route) { return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; } try { const url = `${route.url}/${encodeURIComponent(action.id)}`; const res = await fetch(url, { method: "PATCH", headers: { "Content-Type": "application/json", ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), }, body: JSON.stringify(action.body), }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); return { action, ok: false, error: err.error || `HTTP ${res.status}` }; } return { action, ok: true, contentId: action.id }; } catch (e: any) { return { action, ok: false, error: e.message }; } } async #executeDeleteContent( action: Extract, ): Promise { const route = resolveModuleRoute(action.module, action.contentType, this.#space); if (!route) { return { action, ok: false, error: `No route for ${action.module}/${action.contentType}` }; } try { const url = `${route.url}/${encodeURIComponent(action.id)}`; const res = await fetch(url, { method: "DELETE", headers: { ...(this.#token ? { Authorization: `Bearer ${this.#token}` } : {}), }, }); if (!res.ok) { const err = await res.json().catch(() => ({ error: "Request failed" })); return { action, ok: false, error: err.error || `HTTP ${res.status}` }; } return { action, ok: true, contentId: action.id }; } catch (e: any) { return { action, ok: false, error: e.message }; } } // ── Composite actions ── async #executeScaffold( action: Extract, refMap: Map, ): Promise { const stepResults: ExecutionResult[] = []; for (const step of action.steps) { const result = await this.#executeOneAsync(step, refMap); stepResults.push(result); if (!result.ok) { return { action, ok: false, error: `Scaffold "${action.name}" failed at step: ${result.error}`, }; } } return { action, ok: true }; } async #executeBatch( action: Extract, refMap: Map, ): Promise { const results: ExecutionResult[] = []; for (const sub of action.actions) { results.push(await this.#executeOneAsync(sub, refMap)); } const failures = results.filter((r) => !r.ok); if (failures.length) { return { action, ok: false, error: `${failures.length}/${results.length} actions failed`, }; } return { action, ok: true }; } }