399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
/**
|
|
* 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<string, any>) => any;
|
|
findFreePosition: (width: number, height: number) => { x: number; y: number };
|
|
SHAPE_DEFAULTS: Record<string, { width: number; height: number }>;
|
|
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, string>): 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<string, string>();
|
|
|
|
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<ExecutionResult[]> {
|
|
const results: ExecutionResult[] = [];
|
|
const refMap = new Map<string, string>();
|
|
|
|
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<string, string>): 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<string, string>): Promise<ExecutionResult> {
|
|
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<string, string>): 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<MiAction, { type: "create-content" }>,
|
|
refMap: Map<string, string>,
|
|
): Promise<ExecutionResult> {
|
|
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<MiAction, { type: "update-content" }>,
|
|
): Promise<ExecutionResult> {
|
|
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<MiAction, { type: "delete-content" }>,
|
|
): Promise<ExecutionResult> {
|
|
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<MiAction, { type: "scaffold" }>,
|
|
refMap: Map<string, string>,
|
|
): Promise<ExecutionResult> {
|
|
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<MiAction, { type: "batch" }>,
|
|
refMap: Map<string, string>,
|
|
): Promise<ExecutionResult> {
|
|
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 };
|
|
}
|
|
}
|