userId: string | null;
  };
};

export type OnlyOfficeForcesavePreparationResult = {
  requests: Array<{
    via: string;
    url: string;
    method: string;
    headers: OnlyOfficeHeader[];
    bodyJson: string;
  }>;
};

function buildOnlyOfficeServiceContext(request: Request, input: OnlyOfficeServiceActorInput): BridgeContext {
  return buildDocumentBridgeContextWithActor({
    request,
    workspaceId: input.workspaceId ?? null,
    actor: {
      actorType: input.actorType ?? "service",
      actorId: input.actorId?.trim() || "onlyoffice-service",
      sessionId: input.sessionId ?? null,
    },
    source: {
      channel: input.sourceChannel,
      client: input.sourceClient,
    },
    idempotencyKey: input.idempotencyKey ?? null,
  });
}

async function runOnlyOfficeTool<TResult>(input: {
  request: Request;
  toolName:
    | "onlyoffice_session_resolve"
    | "onlyoffice_sign"
    | "onlyoffice_prepare_proxy"
    | "onlyoffice_prepare_callback"
    | "onlyoffice_prepare_forcesave";
  args?: Record<string, unknown>;
  data?: Record<string, unknown>;
  target?: BridgeTarget | null;
  actor?: Partial<OnlyOfficeServiceActorInput>;
}): Promise<TResult> {
  const context = buildOnlyOfficeServiceContext(input.request, {
    actorId: input.actor?.actorId,
    actorType: input.actor?.actorType,
    sessionId: input.actor?.sessionId ?? null,
    workspaceId: input.actor?.workspaceId ?? null,
    sourceChannel: input.actor?.sourceChannel?.trim() || `onlyoffice:${input.toolName}`,
    sourceClient: input.actor?.sourceClient?.trim() || "wolai-frontend",
    idempotencyKey: input.actor?.idempotencyKey ?? null,
  });
  const result = await executeRustBridgeTool<TResult>({
    context,
    toolName: input.toolName,
    invocationKind: "job",
    args: input.args ?? {},
    data: input.data ?? {},
    target: input.target ?? null,


workspaceId: input.actor?.workspaceId ?? null,
    sourceChannel: input.actor?.sourceChannel?.trim() || `onlyoffice:${input.toolName}`,
    sourceClient: input.actor?.sourceClient?.trim() || "wolai-frontend",
    idempotencyKey: input.actor?.idempotencyKey ?? null,
  });
  const result = await executeRustBridgeTool<TResult>({
    context,
    toolName: input.toolName,
    invocationKind: "job",
    args: input.args ?? {},
    data: input.data ?? {},
    target: input.target ?? null,
    mode: "result",
  });
  return result.result;
}

export async function signOnlyOfficeConfig(input: {
  request: Request;
  config: Record<string, unknown>;
  secret?: string | null;
}): Promise<OnlyOfficeSignResult> {
  return await runOnlyOfficeTool<OnlyOfficeSignResult>({
    request: input.request,
    toolName: "onlyoffice_sign",
    data: {
      config: input.config,
      secret: input.secret ?? "",
    },
    actor: {
      sourceChannel: "onlyoffice-sign",
      sourceClient: "onlyoffice-sign-route",
    },
  });
}

export async function prepareOnlyOfficeProxyRequest(input: {
  request: Request;
  encodedUrl: string;
  method: "GET" | "HEAD";
  range: string | null;
  runtimeConfig: MnoteRuntimeConfig;
}): Promise<OnlyOfficeProxyPreparationResult> {
  return await runOnlyOfficeTool<OnlyOfficeProxyPreparationResult>({
    request: input.request,
    toolName: "onlyoffice_prepare_proxy",
    data: {
      encodedUrl: input.encodedUrl,
      method: input.method,
      range: input.range,
      supabaseUrl: input.runtimeConfig.supabaseUrl ?? "",
      supabaseInternalUrl:
        input.runtimeConfig.supabaseInternalUrl ?? process.env.SUPABASE_INTERNAL_URL ?? "",
      onlyofficeStorageHostOverride: input.runtimeConfig.onlyofficeStorageHostOverride ?? "",
      convexOrigin:
        process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "",
      supabaseAnonKey: input.runtimeConfig.supabaseAnonKey ?? "",
    },
    actor: {
      sourceChannel: "onlyoffice-proxy",


method: input.method,
      range: input.range,
      supabaseUrl: input.runtimeConfig.supabaseUrl ?? "",
      supabaseInternalUrl:
        input.runtimeConfig.supabaseInternalUrl ?? process.env.SUPABASE_INTERNAL_URL ?? "",
      onlyofficeStorageHostOverride: input.runtimeConfig.onlyofficeStorageHostOverride ?? "",
      convexOrigin:
        process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "",
      supabaseAnonKey: input.runtimeConfig.supabaseAnonKey ?? "",
    },
    actor: {
      sourceChannel: "onlyoffice-proxy",
      sourceClient: "onlyoffice-proxy-route",
    },
  });
}

export async function prepareOnlyOfficeCallback(input: {
  request: Request;
  assetId: string;
  documentId?: string | null;
  workspaceId?: string | null;
  userId?: string | null;
  sessionId?: string | null;
  status: number;
  url?: string | null;
  key?: string | null;
  onlyofficeInternalUrl: string;
}): Promise<OnlyOfficeCallbackPreparationResult> {
  return await runOnlyOfficeTool<OnlyOfficeCallbackPreparationResult>({
    request: input.request,
    toolName: "onlyoffice_prepare_callback",
    data: {
      assetId: input.assetId,
      documentId: input.documentId ?? null,
      workspaceId: input.workspaceId ?? null,
      userId: input.userId ?? null,
      sessionId: input.sessionId ?? "onlyoffice-callback",
      status: input.status,
      url: input.url ?? null,
      key: input.key ?? null,
      onlyofficeInternalUrl: input.onlyofficeInternalUrl,
    },
    actor: {
      actorId: input.userId?.trim() || "onlyoffice-callback",
      sessionId: "onlyoffice-callback",
      workspaceId: input.workspaceId ?? null,
      idempotencyKey: input.key ?? null,
      sourceChannel: "onlyoffice-callback",
      sourceClient: "onlyoffice-document-server",
    },
    target: {
      workspaceId: input.workspaceId ?? null,
      pageId: input.documentId ?? null,
      blockId: input.assetId,
    },
  });
}

export async function prepareOnlyOfficeForcesave(input: {


sourceChannel: "onlyoffice-callback",
      sourceClient: "onlyoffice-document-server",
    },
    target: {
      workspaceId: input.workspaceId ?? null,
      pageId: input.documentId ?? null,
      blockId: input.assetId,
    },
  });
}

export async function prepareOnlyOfficeForcesave(input: {
  request: Request;
  assetId: string;
  key: string;
  onlyofficeInternalUrl: string;
  secret?: string | null;
  actorId?: string;
  workspaceId?: string | null;
  documentId?: string | null;
}): Promise<OnlyOfficeForcesavePreparationResult> {
  return await runOnlyOfficeTool<OnlyOfficeForcesavePreparationResult>({
    request: input.request,
    toolName: "onlyoffice_prepare_forcesave",
    data: {
      assetId: input.assetId,
      key: input.key,
      onlyofficeInternalUrl: input.onlyofficeInternalUrl,
      secret: input.secret ?? "",
    },
    actor: {
      actorId: input.actorId?.trim() || "onlyoffice-forcesave",
      sessionId: "onlyoffice-forcesave",
      workspaceId: input.workspaceId ?? null,
      idempotencyKey: input.key,
      sourceChannel: "onlyoffice-forcesave",
      sourceClient: "onlyoffice-forcesave-route",
    },
    target: {
      workspaceId: input.workspaceId ?? null,
      pageId: input.documentId ?? null,
      blockId: input.assetId,
    },
  });
}


import "server-only";

const DEFAULT_MNOTE_WEB_INTERNAL_URL = "http://127.0.0.1:3104";
const DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES = [
  DEFAULT_MNOTE_WEB_INTERNAL_URL,
  "http://localhost:3104",
];
const MNOTE_WEB_PROBE_PATH = "/health";
const RESOLVE_CACHE_TTL_MS = 30_000;

let cachedMnoteWebInternalUrl = "";
let cachedMnoteWebInternalUrlAt = 0;
let pendingMnoteWebInternalUrl: Promise<string> | null = null;

const normalizeMnoteWebInternalUrl = (raw?: string | null) => {
  const value = String(raw || "").trim().replace(/\/+$/, "");
  if (!value) return "";
  try {
    const url = new URL(value);
    if (url.protocol !== "http:" && url.protocol !== "https:") {
      return "";
    }
    return url.toString().replace(/\/+$/, "");
  } catch {
    return "";
  }
};

const getMnoteWebInternalUrlCandidates = () => {
  const candidates: string[] = [];
  const push = (value?: string | null) => {
    const normalized = normalizeMnoteWebInternalUrl(value);
    if (!normalized) return;
    if (!candidates.includes(normalized)) {
      candidates.push(normalized);
    }
  };

  push(process.env.MNOTE_WEB_INTERNAL_URL);

  for (const raw of String(process.env.MNOTE_WEB_INTERNAL_URL_CANDIDATES || "").split(",")) {
    push(raw);
  }

  for (const candidate of DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES) {
    push(candidate);
  }

  return candidates.length > 0 ? candidates : [DEFAULT_MNOTE_WEB_INTERNAL_URL];
};

const probeMnoteWebInternalUrl = async (candidate: string) => {
  try {
    const url = new URL(MNOTE_WEB_PROBE_PATH, `${candidate}/`);
    const response = await fetch(url, {
      method: "GET",
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(2_500),
    });


return candidates.length > 0 ? candidates : [DEFAULT_MNOTE_WEB_INTERNAL_URL];
};

const probeMnoteWebInternalUrl = async (candidate: string) => {
  try {
    const url = new URL(MNOTE_WEB_PROBE_PATH, `${candidate}/`);
    const response = await fetch(url, {
      method: "GET",
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(2_500),
    });
    return response.ok;
  } catch {
    return false;
  }
};

export const resolveMnoteWebInternalUrl = async () => {
  const now = Date.now();
  if (cachedMnoteWebInternalUrl && now - cachedMnoteWebInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
    return cachedMnoteWebInternalUrl;
  }

  if (pendingMnoteWebInternalUrl) {
    return pendingMnoteWebInternalUrl;
  }

  pendingMnoteWebInternalUrl = (async () => {
    const candidates = getMnoteWebInternalUrlCandidates();
    for (const candidate of candidates) {
      if (await probeMnoteWebInternalUrl(candidate)) {
        return candidate;
      }
    }
    return candidates[0] || DEFAULT_MNOTE_WEB_INTERNAL_URL;
  })();

  try {
    const resolved = await pendingMnoteWebInternalUrl;
    cachedMnoteWebInternalUrl = resolved;
    cachedMnoteWebInternalUrlAt = Date.now();
    return resolved;
  } finally {
    pendingMnoteWebInternalUrl = null;
  }
};


import { describe, expect, it, vi } from "vitest";
import {
  createMindmapAdapterProjectionEndpoint,
  createMindmapCommandApplyEndpoint,
  executeMindmapCommandApply,
  executeMindmapCommandApplyAndRefreshProjection,
  executeMindmapDataPutAndRefreshProjection,
  isMindmapAdapterProjection,
  requestMindmapAdapterProjection,
} from "./leptos-mindmap-adapter";
import { createFallbackMindmapAdapterProjection } from "./simple-mind-map-bridge";

describe("leptos-mindmap adapter projection", () => {
  it("识别 adapter projection contract", () => {
    const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
    expect(isMindmapAdapterProjection(projection)).toBe(true);
    expect(isMindmapAdapterProjection({ ...projection, runtime: "other" })).toBe(false);
  });

  it("构建 simple_mind_map_scene 查询 endpoint", () => {
    expect(createMindmapAdapterProjectionEndpoint("doc 1", "mind/1")).toBe(
      "/api/mindmap/doc%201/mind%2F1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get",
    );
    expect(createMindmapCommandApplyEndpoint("doc 1", "mind/1")).toBe(
      "/api/mindmap/doc%201/mind%2F1",
    );
  });

  it("从 result wrapper 中读取 projection", async () => {
    const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
    const fetcher = vi.fn(async () => ({
      ok: true,
      json: async () => ({ result: projection }),
    })) as unknown as typeof fetch;

    await expect(
      requestMindmapAdapterProjection({
        documentId: "doc_1",
        mindmapId: "mind_1",
        fetcher,
      }),
    ).resolves.toEqual(projection);
  });

  it("command apply 成功后可刷新 adapter projection", async () => {
    const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1", kernelRevision: 12 });
    const fetcher = vi
      .fn()
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ ok: true, kernelRevision: 11 }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ result: projection }),
      }) as unknown as typeof fetch;

    await expect(
      executeMindmapCommandApplyAndRefreshProjection({
        documentId: "doc_1",


.mockResolvedValueOnce({
        ok: true,
        json: async () => ({ ok: true, kernelRevision: 11 }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({ result: projection }),
      }) as unknown as typeof fetch;

    await expect(
      executeMindmapCommandApplyAndRefreshProjection({
        documentId: "doc_1",
        mindmapId: "mind_1",
        commands: [{ type: "updateText", mindmapId: "mind_1", nodeId: "root", text: "新标题" }],
        projectionRevision: 10,
        fetcher,
      }),
    ).resolves.toEqual(projection);
    expect(fetcher).toHaveBeenNthCalledWith(
      1,
      "/api/mindmap/doc_1/mind_1",
      expect.objectContaining({
        method: "POST",
        body: expect.stringContaining('"commandName":"mindmap.command.apply"'),
      }),
    );
  });

  it("command apply 失败时抛出可定位 command_failed 错误", async () => {
    const fetcher = vi.fn(async () => ({
      ok: false,
      status: 409,
      json: async () => ({ error: "revision_conflict" }),
    })) as unknown as typeof fetch;

    await expect(
      executeMindmapCommandApply({
        documentId: "doc_1",
        mindmapId: "mind_1",
        commands: [{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" }],
        fetcher,
      }),
    ).rejects.toMatchObject({ code: "command_failed", status: 409, message: "command_failed:409:revision_conflict" });
  });

  it("data put 失败时透出 route 错误细节", async () => {
    const fetcher = vi.fn(async () => ({
      ok: false,
      status: 400,
      json: async () => ({ error: "mindmap_put_failed", details: ["missing_root_uid"] }),
    })) as unknown as typeof fetch;

    await expect(
      executeMindmapDataPutAndRefreshProjection({
        documentId: "doc_1",
        mindmapId: "mind_1",
        data: { data: { text: "中心主题" }, children: [] },
        fetcher,
      }),
    ).rejects.toMatchObject({ code: "command_failed", status: 400, message: "command_failed:400:mindmap_put_failed" });


status: 400,
      json: async () => ({ error: "mindmap_put_failed", details: ["missing_root_uid"] }),
    })) as unknown as typeof fetch;

    await expect(
      executeMindmapDataPutAndRefreshProjection({
        documentId: "doc_1",
        mindmapId: "mind_1",
        data: { data: { text: "中心主题" }, children: [] },
        fetcher,
      }),
    ).rejects.toMatchObject({ code: "command_failed", status: 400, message: "command_failed:400:mindmap_put_failed" });
  });
});


import {
  createSimpleMindMapBridge,
  type MindmapAdapterProjection,
  type SimpleMindMapBridge,
  type SimpleMindMapBridgeEvent,
  type SimpleMindMapPluginName,
} from "./simple-mind-map-bridge";
import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";

export type {
  MindmapAdapterProjection,
  SimpleMindMapBridge,
  SimpleMindMapBridgeEvent,
} from "./simple-mind-map-bridge";

export type LeptosMindmapAdapterOptions = {
  el: HTMLElement;
  projection: MindmapAdapterProjection;
  mode?: "edit" | "readonly";
  pluginNames?: SimpleMindMapPluginName[];
  runtimeOptions?: Record<string, unknown>;
  onEvent?: (event: SimpleMindMapBridgeEvent) => void;
};

export type RequestMindmapAdapterProjectionInput = {
  documentId: string;
  mindmapId: string;
  endpoint?: string;
  fetcher?: typeof fetch;
};

export type MindmapCommandApplyInput = {
  documentId: string;
  mindmapId: string;
  commands: Array<MindmapKernelCommand | MindmapCompatPayloadPatch>;
  projectionRevision?: number | null;
  endpoint?: string;
  fetcher?: typeof fetch;
};

export type MindmapDataPutInput = {
  documentId: string;
  mindmapId: string;
  data: unknown;
  projectionEndpoint?: string;
  endpoint?: string;
  fetcher?: typeof fetch;
};

export type MindmapCommandApplyResult = {
  ok: true;
  kernelRevision: number | null;
  raw: unknown;
};

export class MindmapCommandBridgeError extends Error {
  readonly code: "command_failed";
  readonly status: number | null;

  constructor(message: string, status: number | null = null) {


export type MindmapCommandApplyResult = {
  ok: true;
  kernelRevision: number | null;
  raw: unknown;
};

export class MindmapCommandBridgeError extends Error {
  readonly code: "command_failed";
  readonly status: number | null;

  constructor(message: string, status: number | null = null) {
    super(message);
    this.name = "MindmapCommandBridgeError";
    this.code = "command_failed";
    this.status = status;
  }
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const readErrorDetailFromPayload = (raw: unknown): string => {
  if (!isRecord(raw)) return "";
  const detail = [raw.error, raw.message, raw.details]
    .map((value) => {
      if (typeof value === "string") return value.trim();
      if (Array.isArray(value) && value.length > 0) return value.join("|");
      return "";
    })
    .find(Boolean);
  return detail ? `:${detail}` : "";
};

const readErrorDetail = async (response: Response): Promise<string> => {
  if (typeof response.text !== "function") {
    const raw = (await response.json().catch(() => null)) as unknown;
    return readErrorDetailFromPayload(raw);
  }
  const rawText = await response.text().catch(() => "");
  if (!rawText.trim()) return "";
  try {
    return readErrorDetailFromPayload(JSON.parse(rawText) as unknown) || `:${rawText.trim()}`;
  } catch {
    return `:${rawText.trim()}`;
  }
};

export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
  if (!isRecord(value)) return false;
  return (
    value.schema === "mnote.mindmap.simple_mind_map_scene.v1" &&
    value.runtime === "simple-mind-map" &&
    "root" in value &&
    typeof value.kernelRevision === "number"
  );
};

export const createMindmapAdapterProjectionEndpoint = (documentId: string, mindmapId: string) => {
  const doc = encodeURIComponent(documentId);


export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
  if (!isRecord(value)) return false;
  return (
    value.schema === "mnote.mindmap.simple_mind_map_scene.v1" &&
    value.runtime === "simple-mind-map" &&
    "root" in value &&
    typeof value.kernelRevision === "number"
  );
};

export const createMindmapAdapterProjectionEndpoint = (documentId: string, mindmapId: string) => {
  const doc = encodeURIComponent(documentId);
  const map = encodeURIComponent(mindmapId);
  return `/api/mindmap/${doc}/${map}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`;
};

export const createMindmapCommandApplyEndpoint = (documentId: string, mindmapId: string) => {
  const doc = encodeURIComponent(documentId);
  const map = encodeURIComponent(mindmapId);
  return `/api/mindmap/${doc}/${map}`;
};

export const requestMindmapAdapterProjection = async (
  input: RequestMindmapAdapterProjectionInput,
): Promise<MindmapAdapterProjection> => {
  const fetcher = input.fetcher ?? fetch;
  const endpoint = input.endpoint ?? createMindmapAdapterProjectionEndpoint(input.documentId, input.mindmapId);
  const response = await fetcher(endpoint, {
    headers: { Accept: "application/json" },
  });
  if (!response.ok) {
    throw new Error(`projection_load_failed:${response.status}`);
  }
  const payload = (await response.json()) as unknown;
  const projection = isRecord(payload) && "result" in payload ? payload.result : payload;
  if (!isMindmapAdapterProjection(projection)) {
    throw new Error("projection_load_failed:invalid_adapter_projection");
  }
  return projection;
};

const readKernelRevision = (value: unknown): number | null => {
  if (!isRecord(value)) return null;
  const candidates = [
    value.kernelRevision,
    value.projectionRevision,
    isRecord(value.result) ? value.result.kernelRevision : null,
    isRecord(value.result) ? value.result.projectionRevision : null,
  ];
  for (const candidate of candidates) {
    if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
  }
  return null;
};

export const executeMindmapCommandApply = async (
  input: MindmapCommandApplyInput,
): Promise<MindmapCommandApplyResult> => {
  if (input.commands.length === 0) {
    throw new MindmapCommandBridgeError("command_failed:empty_commands");


];
  for (const candidate of candidates) {
    if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
  }
  return null;
};

export const executeMindmapCommandApply = async (
  input: MindmapCommandApplyInput,
): Promise<MindmapCommandApplyResult> => {
  if (input.commands.length === 0) {
    throw new MindmapCommandBridgeError("command_failed:empty_commands");
  }
  const fetcher = input.fetcher ?? fetch;
  const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
  const requestBody = JSON.stringify({
    commandName: "mindmap.command.apply",
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    commands: input.commands,
    projectionRevision: input.projectionRevision ?? null,
  });
  const response = await fetcher(endpoint, {
    method: "POST",
    keepalive: requestBody.length <= 60_000,
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: requestBody,
  });
  const raw = (await response.json().catch(() => null)) as unknown;
  if (!response.ok) {
    const detail = readErrorDetailFromPayload(raw);
    throw new MindmapCommandBridgeError(
      `command_failed:${response.status}${detail}`,
      response.status,
    );
  }
  return {
    ok: true,
    kernelRevision: readKernelRevision(raw),
    raw,
  };
};

export const executeMindmapCommandApplyAndRefreshProjection = async (
  input: MindmapCommandApplyInput & { projectionEndpoint?: string },
): Promise<MindmapAdapterProjection> => {
  await executeMindmapCommandApply(input);
  return requestMindmapAdapterProjection({
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    endpoint: input.projectionEndpoint,
    fetcher: input.fetcher,
  });
};

export const executeMindmapDataPutAndRefreshProjection = async (
  input: MindmapDataPutInput,


): Promise<MindmapAdapterProjection> => {
  await executeMindmapCommandApply(input);
  return requestMindmapAdapterProjection({
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    endpoint: input.projectionEndpoint,
    fetcher: input.fetcher,
  });
};

export const executeMindmapDataPutAndRefreshProjection = async (
  input: MindmapDataPutInput,
): Promise<MindmapAdapterProjection> => {
  const fetcher = input.fetcher ?? fetch;
  const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
  const response = await fetcher(endpoint, {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      data: input.data,
      createOnly: false,
    }),
  });
  if (!response.ok) {
    const detail = await readErrorDetail(response);
    throw new MindmapCommandBridgeError(`command_failed:${response.status}${detail}`, response.status);
  }
  await response.json().catch(() => null);
  return requestMindmapAdapterProjection({
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    endpoint: input.projectionEndpoint,
    fetcher,
  });
};

export const createLeptosMindmapAdapter = async (
  input: LeptosMindmapAdapterOptions,
): Promise<SimpleMindMapBridge> => {
  if (!isMindmapAdapterProjection(input.projection)) {
    throw new Error("adapter_init_failed:invalid_projection");
  }
  return createSimpleMindMapBridge(input);
};


import { describe, expect, it } from "vitest";
import {
  getMindmapActionMapping,
  listMindmapActionMappings,
  mapMindmapActionToCommand,
} from "./mindmap-action-map";

describe("mindmap action map", () => {
  it("把核心节点动作映射到 runtime command 和 kernel command", () => {
    expect(mapMindmapActionToCommand({ actionId: "insertChild", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
      runtimeCommand: "INSERT_CHILD_NODE",
      command: {
        type: "insertChild",
        mindmapId: "mind_1",
        parentNodeId: "node_1",
        node: { text: "新节点" },
      },
    });
    expect(
      mapMindmapActionToCommand({ actionId: "insertSiblingAfter", mindmapId: "mind_1", activeNodeId: "node_1" }),
    ).toEqual({
      runtimeCommand: "INSERT_NODE",
      command: {
        type: "insertSiblingAfter",
        mindmapId: "mind_1",
        targetNodeId: "node_1",
        node: { text: "新节点" },
      },
    });
    expect(mapMindmapActionToCommand({ actionId: "deleteNode", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
      runtimeCommand: "REMOVE_NODE",
      command: { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" },
    });
  });

  it("把视图动作映射到 localView，不依赖 active node", () => {
    expect(getMindmapActionMapping("centerRoot")).toMatchObject({ target: "localView", runtimeMethod: "centerRoot" });
    expect(getMindmapActionMapping("zoomIn")).toMatchObject({ target: "localView", runtimeMethod: "zoomIn" });
    expect(getMindmapActionMapping("zoomOut")).toMatchObject({ target: "localView", runtimeMethod: "zoomOut" });
    expect(getMindmapActionMapping("fitView")).toMatchObject({ target: "localView", runtimeMethod: "fitView" });
    expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ target: "localView", requiresActiveNode: false });
    expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ target: "localView", requiresActiveNode: false });
    expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ target: "localView", requiresActiveNode: false });
    expect(getMindmapActionMapping("showMenu")).toMatchObject({ target: "localView", requiresActiveNode: false });
  });

  it("全屏动作不产生 kernel command，readonly 下仍可使用", () => {
    expect(mapMindmapActionToCommand({ actionId: "fullscreenCanvas", mindmapId: "mind_1" })).toEqual({});
    expect(mapMindmapActionToCommand({ actionId: "fullscreenPage", mindmapId: "mind_1" })).toEqual({});
    expect(mapMindmapActionToCommand({ actionId: "exitFullscreen", mindmapId: "mind_1" })).toEqual({});
    expect(mapMindmapActionToCommand({ actionId: "showMenu", mindmapId: "mind_1" })).toEqual({});
    expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
  });

  it("导出能力暂缓时不下发未受控 runtime command", () => {
    expect(getMindmapActionMapping("export")).toMatchObject({ target: "localView", readonlyAllowed: true });
    expect(getMindmapActionMapping("export")?.runtimeCommand).toBeUndefined();


expect(mapMindmapActionToCommand({ actionId: "fullscreenPage", mindmapId: "mind_1" })).toEqual({});
    expect(mapMindmapActionToCommand({ actionId: "exitFullscreen", mindmapId: "mind_1" })).toEqual({});
    expect(mapMindmapActionToCommand({ actionId: "showMenu", mindmapId: "mind_1" })).toEqual({});
    expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ readonlyAllowed: true });
    expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
  });

  it("导出能力暂缓时不下发未受控 runtime command", () => {
    expect(getMindmapActionMapping("export")).toMatchObject({ target: "localView", readonlyAllowed: true });
    expect(getMindmapActionMapping("export")?.runtimeCommand).toBeUndefined();
    expect(mapMindmapActionToCommand({ actionId: "export", mindmapId: "mind_1" })).toEqual({});
  });

  it("把主题、结构和扩展字段映射到 kernel/compat", () => {
    expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
      command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" },
    });
    expect(mapMindmapActionToCommand({ actionId: "setLayout", mindmapId: "mind_1", value: "logicalStructure" })).toEqual({
      command: { type: "setLayout", mindmapId: "mind_1", layout: "logicalStructure" },
    });
    expect(mapMindmapActionToCommand({ actionId: "note", mindmapId: "mind_1", activeNodeId: "node_1", value: "备注" })).toEqual({
      command: {
        type: "compatPayloadPatch",
        mindmapId: "mind_1",
        path: "nodes.node_1.data.note",
        value: "备注",
        source: "toolbar",
        actionId: "note",
      },
    });
    expect(
      mapMindmapActionToCommand({
        actionId: "painter",
        mindmapId: "mind_1",
        activeNodeId: "node_1",
        value: { fillColor: "#dbeafe" },
        source: "sidebar",
      }),
    ).toEqual({
      command: {
        type: "compatPayloadPatch",
        mindmapId: "mind_1",
        path: "nodes.node_1.data.style",
        value: { fillColor: "#dbeafe" },
        source: "sidebar",
        actionId: "painter",
      },
    });
  });

  it("所有 action id 映射唯一", () => {
    const mappings = listMindmapActionMappings();
    const ids = mappings.map((mapping) => mapping.actionId);
    expect(new Set(ids).size).toBe(ids.length);
  });
});


import {
  createCompatPayloadPatch,
  createLayoutCommand,
  createThemeCommand,
  createToolbarKernelCommand,
  type MindmapCompatPayloadPatch,
  type MindmapKernelCommand,
  type MindmapNodeInput,
} from "./mindmap-command-diff";
import type { MindmapUiActionId } from "./mindmap-ui-schema";

export type MindmapActionTarget = "runtimeCommand" | "kernelCommand" | "compatPatch" | "localView";

export type MindmapRuntimeViewMethod = "centerRoot" | "zoomIn" | "zoomOut" | "fitView";

export type MindmapActionMapping = {
  actionId: MindmapUiActionId;
  target: MindmapActionTarget;
  runtimeCommand?: string;
  runtimeMethod?: MindmapRuntimeViewMethod;
  requiresActiveNode: boolean;
  readonlyAllowed: boolean;
  compatPath?: (activeNodeId: string | null) => string | null;
};

export type MindmapActionCommandInput = {
  actionId: MindmapUiActionId;
  mindmapId: string;
  activeNodeId?: string | null;
  value?: unknown;
  node?: MindmapNodeInput;
  themeConfig?: unknown;
  source?: MindmapCompatPayloadPatch["source"];
  runtimeRevision?: number | null;
  kernelRevision?: number | null;
};

export type MindmapActionCommandResult = {
  runtimeCommand?: string;
  command?: MindmapKernelCommand | MindmapCompatPayloadPatch;
};

const nodeDataPath = (field: string) => (activeNodeId: string | null): string | null =>
  activeNodeId ? `nodes.${activeNodeId}.data.${field}` : null;

const mappings: MindmapActionMapping[] = [
  { actionId: "undo", target: "runtimeCommand", runtimeCommand: "BACK", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "redo", target: "runtimeCommand", runtimeCommand: "FORWARD", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "editNode", target: "localView", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "insertSiblingAfter", target: "runtimeCommand", runtimeCommand: "INSERT_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "insertChild", target: "runtimeCommand", runtimeCommand: "INSERT_CHILD_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "deleteNode", target: "runtimeCommand", runtimeCommand: "REMOVE_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "summary", target: "runtimeCommand", runtimeCommand: "ADD_GENERALIZATION", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "associativeLine", target: "compatPatch", runtimeCommand: "ADD_ASSOCIATIVE_LINE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "setTheme", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "setLayout", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "tag", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("tag") },
  { actionId: "hyperlink", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("hyperlink") },
  { actionId: "note", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("note") },
  { actionId: "image", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("image") },


{ actionId: "editNode", target: "localView", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "insertSiblingAfter", target: "runtimeCommand", runtimeCommand: "INSERT_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "insertChild", target: "runtimeCommand", runtimeCommand: "INSERT_CHILD_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "deleteNode", target: "runtimeCommand", runtimeCommand: "REMOVE_NODE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "summary", target: "runtimeCommand", runtimeCommand: "ADD_GENERALIZATION", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "associativeLine", target: "compatPatch", runtimeCommand: "ADD_ASSOCIATIVE_LINE", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "setTheme", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "setLayout", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
  { actionId: "tag", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("tag") },
  { actionId: "hyperlink", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("hyperlink") },
  { actionId: "note", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("note") },
  { actionId: "image", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("image") },
  { actionId: "icon", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("icon") },
  { actionId: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
  { actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
  { actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" },
  // 导出能力暂缓：UI state 会禁用该入口，这里不能继续下发未受控的 runtime EXPORT 命令。
  { actionId: "export", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "fitView", target: "localView", runtimeMethod: "fitView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "fullscreenCanvas", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "fullscreenPage", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "exitFullscreen", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "search", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "showMenu", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
  { actionId: "expandCollapse", target: "localView", requiresActiveNode: true, readonlyAllowed: false },
  { actionId: "copyNodeText", target: "localView", requiresActiveNode: true, readonlyAllowed: true },
  { actionId: "readonly", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
];

export const listMindmapActionMappings = (): MindmapActionMapping[] => mappings;

export const getMindmapActionMapping = (actionId: MindmapUiActionId): MindmapActionMapping | null =>
  mappings.find((mapping) => mapping.actionId === actionId) ?? null;

const readActiveNodeId = (value: string | null | undefined): string | null => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : null;
};


export const mapMindmapActionToCommand = (input: MindmapActionCommandInput): MindmapActionCommandResult | null => {
  const mapping = getMindmapActionMapping(input.actionId);
  if (!mapping) return null;

  const activeNodeId = readActiveNodeId(input.activeNodeId);
  if (mapping.requiresActiveNode && !activeNodeId) return null;

  if (input.actionId === "setTheme") {
    return { command: createThemeCommand(input.mindmapId, input.value, input.themeConfig) };
  }
  if (input.actionId === "setLayout") {
    return { command: createLayoutCommand(input.mindmapId, input.value) };
  }
  if (input.actionId === "editNode") {
    return {
      command: {
        type: "updateText",
        mindmapId: input.mindmapId,


if (input.actionId === "setTheme") {
    return { command: createThemeCommand(input.mindmapId, input.value, input.themeConfig) };
  }
  if (input.actionId === "setLayout") {
    return { command: createLayoutCommand(input.mindmapId, input.value) };
  }
  if (input.actionId === "editNode") {
    return {
      command: {
        type: "updateText",
        mindmapId: input.mindmapId,
        nodeId: activeNodeId ?? "root",
        text: typeof input.value === "string" ? input.value : "",
      },
    };
  }

  if (mapping.runtimeCommand && ["INSERT_CHILD_NODE", "INSERT_NODE", "DELETE_NODE", "REMOVE_NODE"].includes(mapping.runtimeCommand)) {
    const command = createToolbarKernelCommand({
      mindmapId: input.mindmapId,
      runtimeCommand: mapping.runtimeCommand,
      activeNodeId,
      newNode: input.node,
    });
    return command ? { runtimeCommand: mapping.runtimeCommand, command } : { runtimeCommand: mapping.runtimeCommand };
  }

  if (mapping.compatPath) {
    const path = mapping.compatPath(activeNodeId);
    if (!path) return null;
    return {
      command: createCompatPayloadPatch({
        mindmapId: input.mindmapId,
        path,
        value: input.value ?? true,
        source: input.source ?? "toolbar",
        actionId: input.actionId,
        runtimeRevision: input.runtimeRevision,
        kernelRevision: input.kernelRevision,
      }),
    };
  }

  if (mapping.runtimeCommand) {
    return { runtimeCommand: mapping.runtimeCommand };
  }

  return {};
};


import { describe, expect, it } from "vitest";
import {
  applyMindmapCommandsLocally,
  applyMetadataOnlyMindmapCommands,
  isLocallyApplicableMindmapCommandSet,
  isMetadataOnlyMindmapCommandSet,
} from "./mindmap-command-apply-local";

describe("mindmap command apply local", () => {
  it("识别 metadata-only 命令集合", () => {
    expect(
      isMetadataOnlyMindmapCommandSet([
        { type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
        { type: "setTheme", mindmapId: "mind_1", theme: "dark" },
        { type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.2 } } },
        { type: "compatPayloadPatch", mindmapId: "mind_1", path: "root.data.fillColor", value: "#dbeafe" },
      ]),
    ).toBe(true);
    expect(
      isMetadataOnlyMindmapCommandSet([
        { type: "insertChild", mindmapId: "mind_1", parentNodeId: "root", node: { text: "新节点" } },
      ]),
    ).toBe(false);
  });

  it("把 layout/theme/view/compat patch 合并回完整 mindmap blob", () => {
    const result = applyMetadataOnlyMindmapCommands(
      {
        data: { uid: "root", text: "KMIND" },
        children: [{ data: { uid: "node_1", text: "子节点" }, children: [] }],
        layout: "logicalStructure",
        theme: "classic",
        themeConfig: { lineColor: "#60a5fa" },
        view: { state: { scale: 1 } },
        compatPayload: { source: "compat-blob" },
      },
      [
        { type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
        { type: "setTheme", mindmapId: "mind_1", theme: "dark" },
        { type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.25, x: 10 } } },
        { type: "compatPayloadPatch", mindmapId: "mind_1", path: "nodes.node_1.data.fillColor", value: "#dbeafe" },
        { type: "compatPayloadPatch", mindmapId: "mind_1", path: "style.map.backgroundColor", value: "#0f172a" },
      ],
    );

    expect(result.errors).toEqual([]);
    expect(result.applied).toBe(5);
    expect(result.data.layout).toBe("mindMap");
    expect(result.data.theme).toBe("dark");
    expect(result.data.view).toEqual({ state: { scale: 1.25, x: 10 } });
    expect(result.data.children[0].data.fillColor).toBe("#dbeafe");
    expect(result.data.compatPayload).toEqual({
      source: "compat-blob",
      style: {
        map: {
          backgroundColor: "#0f172a",
        },
      },
    });
  });


expect(result.data.theme).toBe("dark");
    expect(result.data.view).toEqual({ state: { scale: 1.25, x: 10 } });
    expect(result.data.children[0].data.fillColor).toBe("#dbeafe");
    expect(result.data.compatPayload).toEqual({
      source: "compat-blob",
      style: {
        map: {
          backgroundColor: "#0f172a",
        },
      },
    });
  });

  it("识别可本地应用的结构命令集合", () => {
    expect(
      isLocallyApplicableMindmapCommandSet([
        { type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "改名" },
        { type: "insertChild", mindmapId: "mind_1", parentNodeId: "node_1", node: { uid: "node_2", text: "子节点" } },
        { type: "insertSiblingAfter", mindmapId: "mind_1", targetNodeId: "node_1", node: { uid: "node_3", text: "同级" } },
        { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_3" },
      ]),
    ).toBe(true);
    expect(
      isLocallyApplicableMindmapCommandSet([
        { type: "moveNode", mindmapId: "mind_1", nodeId: "node_1", newParentNodeId: "root" },
      ]),
    ).toBe(false);
  });

  it("可本地应用 update/insert/delete 到 mindmap tree，避免每次都整块重挂载", () => {
    const result = applyMindmapCommandsLocally(
      {
        data: { uid: "root", text: "KMIND" },
        children: [
          {
            data: { uid: "node_1", text: "节点 1" },
            children: [],
          },
          {
            data: { uid: "node_2", text: "节点 2" },
            children: [],
          },
        ],
        layout: "logicalStructure",
        theme: "default",
      },
      [
        { type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "节点 1A" },
        {
          type: "insertChild",
          mindmapId: "mind_1",
          parentNodeId: "node_1",
          node: { uid: "node_1_child", text: "子节点 A" },
        },
        {
          type: "insertSiblingAfter",
          mindmapId: "mind_1",
          targetNodeId: "node_1",
          node: { uid: "node_1_sibling", text: "同级 A" },
        },


{
          type: "insertChild",
          mindmapId: "mind_1",
          parentNodeId: "node_1",
          node: { uid: "node_1_child", text: "子节点 A" },
        },
        {
          type: "insertSiblingAfter",
          mindmapId: "mind_1",
          targetNodeId: "node_1",
          node: { uid: "node_1_sibling", text: "同级 A" },
        },
        { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_2" },
      ],
    );

    expect(result.errors).toEqual([]);
    expect(result.applied).toBe(4);
    expect(result.data.children).toHaveLength(2);
    expect(result.data.children[0].data.text).toBe("节点 1A");
    expect(result.data.children[0].children[0].data.uid).toBe("node_1_child");
    expect(result.data.children[1].data.uid).toBe("node_1_sibling");
    expect(result.data.children[1].data.text).toBe("同级 A");
  });
});


import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";

const defaultMindmapData = {
  data: { text: "中心主题" },
  children: [],
};

type MetadataOnlyMindmapCommand =
  | Extract<MindmapKernelCommand, { type: "setLayout" | "setTheme" | "patchView" }>
  | MindmapCompatPayloadPatch;

type LocallyApplicableMindmapCommand =
  | Exclude<MindmapKernelCommand, { type: "moveNode" }>
  | MindmapCompatPayloadPatch;

type ApplyMetadataOnlyMindmapCommandsResult = {
  applied: number;
  data: Record<string, unknown>;
  errors: string[];
};

type ApplyMindmapCommandsLocallyResult = ApplyMetadataOnlyMindmapCommandsResult;

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;

const ensureMindmapBlob = (value: unknown): Record<string, unknown> => {
  const next = isRecord(value) ? cloneJson(value) : cloneJson(defaultMindmapData);
  if (!isRecord(next.data)) next.data = { text: "中心主题" };
  if (!Array.isArray(next.children)) next.children = [];
  return next;
};

const mergeJsonObject = (base: unknown, patch: unknown): unknown => {
  if (!isRecord(base)) return cloneJson(patch);
  if (!isRecord(patch)) return cloneJson(patch);
  const next = { ...base };
  Object.entries(patch).forEach(([key, value]) => {
    if (value === null) {
      delete next[key];
      return;
    }
    next[key] = isRecord(next[key]) && isRecord(value) ? mergeJsonObject(next[key], value) : cloneJson(value);
  });
  return next;
};

const setNestedPath = (root: Record<string, unknown>, path: string, value: unknown): boolean => {
  const segments = path.split(".").filter(Boolean);
  if (segments.length === 0) return false;
  let current: Record<string, unknown> = root;
  for (const segment of segments.slice(0, -1)) {
    if (!isRecord(current[segment])) current[segment] = {};
    current = current[segment] as Record<string, unknown>;
  }
  current[segments[segments.length - 1]] = cloneJson(value);
  return true;
};


const setNestedPath = (root: Record<string, unknown>, path: string, value: unknown): boolean => {
  const segments = path.split(".").filter(Boolean);
  if (segments.length === 0) return false;
  let current: Record<string, unknown> = root;
  for (const segment of segments.slice(0, -1)) {
    if (!isRecord(current[segment])) current[segment] = {};
    current = current[segment] as Record<string, unknown>;
  }
  current[segments[segments.length - 1]] = cloneJson(value);
  return true;
};

const findMindmapNodeByUid = (node: unknown, uid: string): Record<string, unknown> | null => {
  if (!isRecord(node)) return null;
  const data = isRecord(node.data) ? node.data : null;
  if (typeof data?.uid === "string" && data.uid === uid) return node;
  const children = Array.isArray(node.children) ? node.children : [];
  for (const child of children) {
    const found = findMindmapNodeByUid(child, uid);
    if (found) return found;
  }
  return null;
};

const findMindmapNodeContainerByUid = (
  nodes: unknown[],
  uid: string,
): { parentChildren: Record<string, unknown>[]; index: number } | null => {
  for (let index = 0; index < nodes.length; index += 1) {
    const candidate = nodes[index];
    if (!isRecord(candidate)) continue;
    const data = isRecord(candidate.data) ? candidate.data : null;
    if (typeof data?.uid === "string" && data.uid === uid) {
      return {
        parentChildren: nodes as Record<string, unknown>[],
        index,
      };
    }
    const children = Array.isArray(candidate.children) ? candidate.children : [];
    const found = findMindmapNodeContainerByUid(children, uid);
    if (found) return found;
  }
  return null;
};

const createMindmapNodeRecord = (node: {
  uid?: string;
  text: string;
  hyperlink?: string;
  note?: string;
  refs?: unknown[];
}): Record<string, unknown> => ({
  data: {
    uid: node.uid ?? `node_${Date.now().toString(36)}`,
    text: node.text,
    ...(typeof node.hyperlink === "string" ? { hyperlink: node.hyperlink } : {}),
    ...(typeof node.note === "string" ? { note: node.note } : {}),
    ...(Array.isArray(node.refs) ? { refs: cloneJson(node.refs) } : {}),
  },


text: string;
  hyperlink?: string;
  note?: string;
  refs?: unknown[];
}): Record<string, unknown> => ({
  data: {
    uid: node.uid ?? `node_${Date.now().toString(36)}`,
    text: node.text,
    ...(typeof node.hyperlink === "string" ? { hyperlink: node.hyperlink } : {}),
    ...(typeof node.note === "string" ? { note: node.note } : {}),
    ...(Array.isArray(node.refs) ? { refs: cloneJson(node.refs) } : {}),
  },
  children: [],
});

const applyCompatPayloadPatch = (root: Record<string, unknown>, command: MindmapCompatPayloadPatch): boolean => {
  const path = String(command.path || "").trim();
  if (!path) return false;

  if (path.startsWith("root.data.")) {
    const field = path.slice("root.data.".length);
    const rootData = isRecord(root.data) ? root.data : (root.data = {});
    return setNestedPath(rootData as Record<string, unknown>, field, command.value);
  }

  if (path.startsWith("nodes.")) {
    const [, uid, scope, ...rest] = path.split(".");
    if (!uid || scope !== "data" || rest.length === 0) return false;
    const node = findMindmapNodeByUid(root, uid);
    if (!node) return false;
    const nodeData = isRecord(node.data) ? node.data : (node.data = {});
    return setNestedPath(nodeData as Record<string, unknown>, rest.join("."), command.value);
  }

  const compatPayload = isRecord(root.compatPayload) ? root.compatPayload : (root.compatPayload = {});
  return setNestedPath(compatPayload as Record<string, unknown>, path, command.value);
};

export const isMetadataOnlyMindmapCommandSet = (commands: unknown[]): commands is MetadataOnlyMindmapCommand[] =>
  Array.isArray(commands) &&
  commands.every((command) => {
    if (!isRecord(command) || typeof command.type !== "string") return false;
    return ["setLayout", "setTheme", "patchView", "compatPayloadPatch"].includes(command.type);
  });

export const isLocallyApplicableMindmapCommandSet = (
  commands: unknown[],
): commands is LocallyApplicableMindmapCommand[] =>
  Array.isArray(commands) &&
  commands.every((command) => {
    if (!isRecord(command) || typeof command.type !== "string") return false;
    return [
      "updateText",
      "insertChild",
      "insertSiblingAfter",
      "deleteNode",
      "setLayout",
      "setTheme",
      "patchView",
      "compatPayloadPatch",


Array.isArray(commands) &&
  commands.every((command) => {
    if (!isRecord(command) || typeof command.type !== "string") return false;
    return [
      "updateText",
      "insertChild",
      "insertSiblingAfter",
      "deleteNode",
      "setLayout",
      "setTheme",
      "patchView",
      "compatPayloadPatch",
    ].includes(command.type);
  });

export const applyMetadataOnlyMindmapCommands = (
  currentData: unknown,
  commands: MetadataOnlyMindmapCommand[],
): ApplyMetadataOnlyMindmapCommandsResult => {
  const nextData = ensureMindmapBlob(currentData);
  const errors: string[] = [];
  let applied = 0;

  commands.forEach((command) => {
    if (command.type === "setLayout") {
      nextData.layout = cloneJson(command.layout);
      applied += 1;
      return;
    }
    if (command.type === "setTheme") {
      nextData.theme = cloneJson(command.theme);
      if (command.themeConfig !== undefined && command.themeConfig !== null) {
        nextData.themeConfig = cloneJson(command.themeConfig);
      }
      applied += 1;
      return;
    }
    if (command.type === "patchView") {
      nextData.view = mergeJsonObject(nextData.view ?? {}, command.patch) as Record<string, unknown>;
      applied += 1;
      return;
    }
    if (command.type === "compatPayloadPatch") {
      if (applyCompatPayloadPatch(nextData, command)) {
        applied += 1;
      } else {
        errors.push(`无法应用 compatPayloadPatch:${command.path}`);
      }
    }
  });

  return {
    applied,
    data: nextData,
    errors,
  };
};

export const applyMindmapCommandsLocally = (
  currentData: unknown,


}
  });

  return {
    applied,
    data: nextData,
    errors,
  };
};

export const applyMindmapCommandsLocally = (
  currentData: unknown,
  commands: LocallyApplicableMindmapCommand[],
): ApplyMindmapCommandsLocallyResult => {
  const nextData = ensureMindmapBlob(currentData);
  const errors: string[] = [];
  let applied = 0;

  commands.forEach((command) => {
    if (command.type === "setLayout" || command.type === "setTheme" || command.type === "patchView" || command.type === "compatPayloadPatch") {
      const result = applyMetadataOnlyMindmapCommands(nextData, [command]);
      Object.assign(nextData, result.data);
      applied += result.applied;
      errors.push(...result.errors);
      return;
    }

    if (command.type === "updateText") {
      const node = findMindmapNodeByUid(nextData, command.nodeId);
      if (!node) {
        errors.push(`未找到节点:${command.nodeId}`);
        return;
      }
      const data = isRecord(node.data) ? node.data : (node.data = {});
      data.text = command.text;
      applied += 1;
      return;
    }

    if (command.type === "insertChild") {
      const parent = findMindmapNodeByUid(nextData, command.parentNodeId);
      if (!parent) {
        errors.push(`未找到父节点:${command.parentNodeId}`);
        return;
      }
      const children = Array.isArray(parent.children) ? parent.children : (parent.children = []);
      children.push(createMindmapNodeRecord(command.node));
      applied += 1;
      return;
    }

    if (command.type === "insertSiblingAfter") {
      const container = findMindmapNodeContainerByUid([nextData], command.targetNodeId);
      if (!container) {
        errors.push(`未找到同级节点:${command.targetNodeId}`);
        return;
      }
      if (container.parentChildren[container.index] === nextData) {
        errors.push("根节点不支持插入同级节点");
        return;


return;
    }

    if (command.type === "insertSiblingAfter") {
      const container = findMindmapNodeContainerByUid([nextData], command.targetNodeId);
      if (!container) {
        errors.push(`未找到同级节点:${command.targetNodeId}`);
        return;
      }
      if (container.parentChildren[container.index] === nextData) {
        errors.push("根节点不支持插入同级节点");
        return;
      }
      container.parentChildren.splice(container.index + 1, 0, createMindmapNodeRecord(command.node));
      applied += 1;
      return;
    }

    if (command.type === "deleteNode") {
      const container = findMindmapNodeContainerByUid([nextData], command.nodeId);
      if (!container) {
        errors.push(`未找到删除节点:${command.nodeId}`);
        return;
      }
      if (container.parentChildren[container.index] === nextData) {
        errors.push("根节点不支持删除");
        return;
      }
      container.parentChildren.splice(container.index, 1);
      applied += 1;
    }
  });

  return {
    applied,
    data: nextData,
    errors,
  };
};


import { describe, expect, it } from "vitest";
import {
  createCompatPayloadPatch,
  createLayoutCommand,
  createThemeCommand,
  createToolbarKernelCommand,
  createViewPatchCommand,
  diffMindmapRuntimeDataToKernelCommands,
} from "./mindmap-command-diff";

describe("mindmap command bridge mapping", () => {
  it("把明确 toolbar 动作映射到 kernel command", () => {
    expect(
      createToolbarKernelCommand({
        mindmapId: "mind_1",
        runtimeCommand: "INSERT_CHILD_NODE",
        activeNodeId: "node_1",
      }),
    ).toEqual({
      type: "insertChild",
      mindmapId: "mind_1",
      parentNodeId: "node_1",
      node: { text: "新节点" },
    });

    expect(
      createToolbarKernelCommand({
        mindmapId: "mind_1",
        runtimeCommand: "REMOVE_NODE",
        activeNodeId: "node_2",
      }),
    ).toEqual({
      type: "deleteNode",
      mindmapId: "mind_1",
      nodeId: "node_2",
    });
  });

  it("把暂未语义化的 toolbar 动作落入 compatPayload patch", () => {
    expect(
      createToolbarKernelCommand({
        mindmapId: "mind_1",
        runtimeCommand: "ADD_GENERALIZATION",
        activeNodeId: "node_1",
      }),
    ).toEqual({
      type: "compatPayloadPatch",
      mindmapId: "mind_1",
      path: "root.data.generalization",
      value: { text: "概要" },
      source: "toolbar",
    });
  });

  it("构建 view/theme/layout 和通用 compat patch command", () => {
    expect(createViewPatchCommand("mind_1", { scale: 0.9 })).toEqual({
      type: "patchView",
      mindmapId: "mind_1",
      patch: { scale: 0.9 },
    });


path: "root.data.generalization",
      value: { text: "概要" },
      source: "toolbar",
    });
  });

  it("构建 view/theme/layout 和通用 compat patch command", () => {
    expect(createViewPatchCommand("mind_1", { scale: 0.9 })).toEqual({
      type: "patchView",
      mindmapId: "mind_1",
      patch: { scale: 0.9 },
    });
    expect(createThemeCommand("mind_1", "classic4", { root: true })).toEqual({
      type: "setTheme",
      mindmapId: "mind_1",
      theme: "classic4",
      themeConfig: { root: true },
    });
    expect(createLayoutCommand("mind_1", "logicalStructure")).toEqual({
      type: "setLayout",
      mindmapId: "mind_1",
      layout: "logicalStructure",
    });
    expect(
      createCompatPayloadPatch({
        mindmapId: "mind_1",
        path: "root.data.image",
        value: "asset:img_1",
      source: "sidebar",
      actionId: "image",
      runtimeRevision: 7,
      kernelRevision: 3,
    }),
  ).toEqual({
      type: "compatPayloadPatch",
      mindmapId: "mind_1",
      path: "root.data.image",
      value: "asset:img_1",
      source: "sidebar",
      actionId: "image",
      runtimeRevision: 7,
      kernelRevision: 3,
    });
  });

  it("从 data_change diff 识别文本、插入、删除、移动和 compat 字段", () => {
    const previous = {
      data: { uid: "root", text: "KMIND" },
      children: [
        { data: { uid: "a", text: "A", image: "asset:old" }, children: [] },
        { data: { uid: "b", text: "B" }, children: [] },
      ],
    };
    const next = {
      data: { uid: "root", text: "KMIND" },
      children: [
        {
          data: { uid: "a", text: "A2", image: "asset:new" },
          children: [{ data: { uid: "c", text: "C" }, children: [] }],
        },


children: [
        { data: { uid: "a", text: "A", image: "asset:old" }, children: [] },
        { data: { uid: "b", text: "B" }, children: [] },
      ],
    };
    const next = {
      data: { uid: "root", text: "KMIND" },
      children: [
        {
          data: { uid: "a", text: "A2", image: "asset:new" },
          children: [{ data: { uid: "c", text: "C" }, children: [] }],
        },
      ],
    };

    expect(
      diffMindmapRuntimeDataToKernelCommands({
        mindmapId: "mind_1",
        previous,
        next,
      }),
    ).toEqual({
      commands: [
        { type: "updateText", mindmapId: "mind_1", nodeId: "a", text: "A2" },
        {
          type: "insertChild",
          mindmapId: "mind_1",
          parentNodeId: "a",
          node: { uid: "c", text: "C" },
        },
        { type: "deleteNode", mindmapId: "mind_1", nodeId: "b" },
      ],
      compatPatches: [
        {
          type: "compatPayloadPatch",
          mindmapId: "mind_1",
          path: "nodes.a.data.image",
          value: "asset:new",
          source: "adapter-diff",
        },
      ],
    });
  });

  it("从 data_change diff 识别节点移动", () => {
    const previous = {
      data: { uid: "root", text: "KMIND" },
      children: [
        { data: { uid: "a", text: "A" }, children: [] },
        { data: { uid: "b", text: "B" }, children: [] },
      ],
    };
    const next = {
      data: { uid: "root", text: "KMIND" },
      children: [{ data: { uid: "a", text: "A" }, children: [{ data: { uid: "b", text: "B" }, children: [] }] }],
    };

    expect(
      diffMindmapRuntimeDataToKernelCommands({
        mindmapId: "mind_1",


{ data: { uid: "a", text: "A" }, children: [] },
        { data: { uid: "b", text: "B" }, children: [] },
      ],
    };
    const next = {
      data: { uid: "root", text: "KMIND" },
      children: [{ data: { uid: "a", text: "A" }, children: [{ data: { uid: "b", text: "B" }, children: [] }] }],
    };

    expect(
      diffMindmapRuntimeDataToKernelCommands({
        mindmapId: "mind_1",
        previous,
        next,
      }).commands,
    ).toContainEqual({
      type: "moveNode",
      mindmapId: "mind_1",
      nodeId: "b",
      newParentNodeId: "a",
      order: 0,
    });
  });
});


import { canonicalizeMindmapData, type MindMapData } from "./mindmap-projection";

export type MindmapNodeInput = {
  uid?: string;
  text: string;
  hyperlink?: string;
  note?: string;
  refs?: unknown[];
};

export type MindmapKernelCommand =
  | { type: "updateText"; mindmapId: string; nodeId: string; text: string }
  | { type: "insertChild"; mindmapId: string; parentNodeId: string; node: MindmapNodeInput }
  | { type: "insertSiblingAfter"; mindmapId: string; targetNodeId: string; node: MindmapNodeInput }
  | { type: "deleteNode"; mindmapId: string; nodeId: string }
  | { type: "moveNode"; mindmapId: string; nodeId: string; newParentNodeId: string; order?: number }
  | { type: "patchView"; mindmapId: string; patch: Record<string, unknown> }
  | { type: "setLayout"; mindmapId: string; layout: unknown }
  | { type: "setTheme"; mindmapId: string; theme: unknown; themeConfig?: unknown };

export type MindmapCompatPayloadPatch = {
  type: "compatPayloadPatch";
  mindmapId: string;
  path: string;
  value: unknown;
  source: "toolbar" | "sidebar" | "runtime-event" | "adapter-diff";
  actionId?: string;
  runtimeRevision?: number | null;
  kernelRevision?: number | null;
};

export type MindmapCommandDiffResult = {
  commands: MindmapKernelCommand[];
  compatPatches: MindmapCompatPayloadPatch[];
};

export type ToolbarCommandInput = {
  mindmapId: string;
  runtimeCommand: string;
  activeNodeId?: string | null;
  newNode?: MindmapNodeInput;
  layout?: unknown;
  theme?: unknown;
  themeConfig?: unknown;
};

const defaultNode = (node?: MindmapNodeInput): MindmapNodeInput => ({
  text: node?.text?.trim() || "新节点",
  ...(node?.uid ? { uid: node.uid } : {}),
  ...(node?.hyperlink ? { hyperlink: node.hyperlink } : {}),
  ...(node?.note ? { note: node.note } : {}),
  ...(node?.refs ? { refs: node.refs } : {}),
});

export const createToolbarKernelCommand = (
  input: ToolbarCommandInput,
): MindmapKernelCommand | MindmapCompatPayloadPatch | null => {
  const activeNodeId = input.activeNodeId?.trim();
  switch (input.runtimeCommand) {
    case "INSERT_CHILD_NODE":


...(node?.uid ? { uid: node.uid } : {}),
  ...(node?.hyperlink ? { hyperlink: node.hyperlink } : {}),
  ...(node?.note ? { note: node.note } : {}),
  ...(node?.refs ? { refs: node.refs } : {}),
});

export const createToolbarKernelCommand = (
  input: ToolbarCommandInput,
): MindmapKernelCommand | MindmapCompatPayloadPatch | null => {
  const activeNodeId = input.activeNodeId?.trim();
  switch (input.runtimeCommand) {
    case "INSERT_CHILD_NODE":
      if (!activeNodeId) return null;
      return {
        type: "insertChild",
        mindmapId: input.mindmapId,
        parentNodeId: activeNodeId,
        node: defaultNode(input.newNode),
      };
    case "INSERT_NODE":
      if (!activeNodeId) return null;
      return {
        type: "insertSiblingAfter",
        mindmapId: input.mindmapId,
        targetNodeId: activeNodeId,
        node: defaultNode(input.newNode),
      };
    case "REMOVE_NODE":
    case "DELETE_NODE":
      if (!activeNodeId) return null;
      return { type: "deleteNode", mindmapId: input.mindmapId, nodeId: activeNodeId };
    case "ADD_GENERALIZATION":
      return {
        type: "compatPayloadPatch",
        mindmapId: input.mindmapId,
        path: "root.data.generalization",
        value: { text: "概要" },
        source: "toolbar",
      };
    case "ADD_OUTER_FRAME":
      return {
        type: "compatPayloadPatch",
        mindmapId: input.mindmapId,
        path: "outerFrame",
        value: { enabled: true, activeNodeId },
        source: "toolbar",
      };
    default:
      return null;
  }
};

export const createViewPatchCommand = (
  mindmapId: string,
  patch: Record<string, unknown>,
): MindmapKernelCommand => ({
  type: "patchView",
  mindmapId,
  patch,
});


return null;
  }
};

export const createViewPatchCommand = (
  mindmapId: string,
  patch: Record<string, unknown>,
): MindmapKernelCommand => ({
  type: "patchView",
  mindmapId,
  patch,
});

export const createThemeCommand = (
  mindmapId: string,
  theme: unknown,
  themeConfig?: unknown,
): MindmapKernelCommand => ({
  type: "setTheme",
  mindmapId,
  theme,
  themeConfig,
});

export const createLayoutCommand = (mindmapId: string, layout: unknown): MindmapKernelCommand => ({
  type: "setLayout",
  mindmapId,
  layout,
});

export const createCompatPayloadPatch = (input: {
  mindmapId: string;
  path: string;
  value: unknown;
  source: MindmapCompatPayloadPatch["source"];
  actionId?: string;
  runtimeRevision?: number | null;
  kernelRevision?: number | null;
}): MindmapCompatPayloadPatch => ({
  type: "compatPayloadPatch",
  mindmapId: input.mindmapId,
  path: input.path,
  value: input.value,
  source: input.source,
  ...(input.actionId ? { actionId: input.actionId } : {}),
  ...(typeof input.runtimeRevision === "number" ? { runtimeRevision: input.runtimeRevision } : {}),
  ...(typeof input.kernelRevision === "number" ? { kernelRevision: input.kernelRevision } : {}),
});

type IndexedRuntimeNode = {
  uid: string;
  parentUid: string | null;
  order: number;
  text: string;
  data: Record<string, unknown>;
  node: MindMapData;
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);


type IndexedRuntimeNode = {
  uid: string;
  parentUid: string | null;
  order: number;
  text: string;
  data: Record<string, unknown>;
  node: MindMapData;
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const readNodeUid = (node: MindMapData, fallback: string): string => {
  const uid = node.data?.uid;
  return typeof uid === "string" && uid.trim() ? uid.trim() : fallback;
};

const readNodeText = (node: MindMapData): string => {
  const text = node.data?.text;
  return typeof text === "string" ? text : String(text ?? "");
};

const indexMindmapRuntimeTree = (input: unknown): Map<string, IndexedRuntimeNode> => {
  const root = canonicalizeMindmapData(input);
  const index = new Map<string, IndexedRuntimeNode>();
  const visit = (node: MindMapData, parentUid: string | null, order: number, path: string) => {
    const uid = readNodeUid(node, path);
    const data = isRecord(node.data) ? node.data : {};
    index.set(uid, {
      uid,
      parentUid,
      order,
      text: readNodeText(node),
      data,
      node,
    });
    const children = Array.isArray(node.children) ? node.children : [];
    children.forEach((child, childIndex) => {
      visit(canonicalizeMindmapData(child), uid, childIndex, `${path}.${childIndex}`);
    });
  };
  visit(root, null, 0, "root");
  return index;
};

const serializeComparable = (value: unknown): string => JSON.stringify(value ?? null);

const buildNodeInputFromRuntimeNode = (node: IndexedRuntimeNode): MindmapNodeInput => ({
  uid: node.uid,
  text: node.text || "新节点",
  ...(typeof node.data.hyperlink === "string" ? { hyperlink: node.data.hyperlink } : {}),
  ...(typeof node.data.note === "string" ? { note: node.data.note } : {}),
  ...(Array.isArray(node.data.refs) ? { refs: node.data.refs } : {}),
});

const pushCompatDataPatches = (
  mindmapId: string,
  prev: IndexedRuntimeNode,
  next: IndexedRuntimeNode,


const buildNodeInputFromRuntimeNode = (node: IndexedRuntimeNode): MindmapNodeInput => ({
  uid: node.uid,
  text: node.text || "新节点",
  ...(typeof node.data.hyperlink === "string" ? { hyperlink: node.data.hyperlink } : {}),
  ...(typeof node.data.note === "string" ? { note: node.data.note } : {}),
  ...(Array.isArray(node.data.refs) ? { refs: node.data.refs } : {}),
});

const pushCompatDataPatches = (
  mindmapId: string,
  prev: IndexedRuntimeNode,
  next: IndexedRuntimeNode,
  compatPatches: MindmapCompatPayloadPatch[],
) => {
  const semanticKeys = new Set(["uid", "text", "refs"]);
  const keys = new Set([...Object.keys(prev.data), ...Object.keys(next.data)]);
  keys.forEach((key) => {
    if (semanticKeys.has(key)) return;
    if (serializeComparable(prev.data[key]) === serializeComparable(next.data[key])) return;
    compatPatches.push(
      createCompatPayloadPatch({
        mindmapId,
        path: `nodes.${next.uid}.data.${key}`,
        value: next.data[key],
        source: "adapter-diff",
      }),
    );
  });
};

export const diffMindmapRuntimeDataToKernelCommands = (input: {
  mindmapId: string;
  previous: unknown;
  next: unknown;
}): MindmapCommandDiffResult => {
  const previousIndex = indexMindmapRuntimeTree(input.previous);
  const nextIndex = indexMindmapRuntimeTree(input.next);
  const commands: MindmapKernelCommand[] = [];
  const compatPatches: MindmapCompatPayloadPatch[] = [];

  nextIndex.forEach((nextNode, uid) => {
    const previousNode = previousIndex.get(uid);
    if (!previousNode) {
      const previousSibling = Array.from(nextIndex.values()).find(
        (candidate) =>
          candidate.parentUid === nextNode.parentUid &&
          candidate.order === nextNode.order - 1 &&
          previousIndex.has(candidate.uid),
      );
      if (previousSibling) {
        commands.push({
          type: "insertSiblingAfter",
          mindmapId: input.mindmapId,
          targetNodeId: previousSibling.uid,
          node: buildNodeInputFromRuntimeNode(nextNode),
        });
      } else if (nextNode.parentUid) {
        commands.push({
          type: "insertChild",
          mindmapId: input.mindmapId,


);
      if (previousSibling) {
        commands.push({
          type: "insertSiblingAfter",
          mindmapId: input.mindmapId,
          targetNodeId: previousSibling.uid,
          node: buildNodeInputFromRuntimeNode(nextNode),
        });
      } else if (nextNode.parentUid) {
        commands.push({
          type: "insertChild",
          mindmapId: input.mindmapId,
          parentNodeId: nextNode.parentUid,
          node: buildNodeInputFromRuntimeNode(nextNode),
        });
      }
      return;
    }

    if (previousNode.text !== nextNode.text) {
      commands.push({
        type: "updateText",
        mindmapId: input.mindmapId,
        nodeId: uid,
        text: nextNode.text,
      });
    }

    if (
      previousNode.parentUid !== nextNode.parentUid ||
      previousNode.order !== nextNode.order
    ) {
      commands.push({
        type: "moveNode",
        mindmapId: input.mindmapId,
        nodeId: uid,
        newParentNodeId: nextNode.parentUid ?? "",
        order: nextNode.order,
      });
    }

    pushCompatDataPatches(input.mindmapId, previousNode, nextNode, compatPatches);
  });

  previousIndex.forEach((previousNode, uid) => {
    if (!nextIndex.has(uid) && previousNode.parentUid) {
      commands.push({ type: "deleteNode", mindmapId: input.mindmapId, nodeId: uid });
    }
  });

  return { commands, compatPatches };
};


import { describe, expect, it } from "vitest";
import { buildInitialMindmapAssetRefreshPayload } from "./mindmap-initial-sync";

describe("buildInitialMindmapAssetRefreshPayload", () => {
  it("远端 createOnly 成功时返回文件树刷新 payload", () => {
    expect(
      buildInitialMindmapAssetRefreshPayload({
        ok: true,
        docId: "doc-1",
        mindmapId: "mind-1",
      }),
    ).toEqual({
      id: "mind-1",
      document_id: "doc-1",
      asset_type: "mindmap",
      file_name: "mindmap-mind-1.json",
      file_url: "/documents/doc-1/mindmap-mind-1.json",
    });
  });

  it("远端 createOnly 失败时不应伪造文件树刷新事件", () => {
    expect(
      buildInitialMindmapAssetRefreshPayload({
        ok: false,
        docId: "doc-1",
        mindmapId: "mind-1",
      }),
    ).toBeNull();
  });
});


export type InitialMindmapAssetRefreshPayload = {
  id: string;
  document_id: string;
  asset_type: "mindmap";
  file_name: string;
  file_url: string;
};

export function buildInitialMindmapAssetRefreshPayload(input: {
  ok: boolean;
  docId: string;
  mindmapId: string;
}): InitialMindmapAssetRefreshPayload | null {
  if (!input.ok) {
    return null;
  }
  const fileName = `mindmap-${input.mindmapId}.json`;
  return {
    id: input.mindmapId,
    document_id: input.docId,
    asset_type: "mindmap",
    file_name: fileName,
    file_url: `/documents/${input.docId}/${fileName}`,
  };
}


// 说明：这里保留的是 legacy/blob compat projection 工具，服务导图导入、
// 旧 React mindmap 参考实现和独立页兼容。Phase 6 文档页默认编辑主链
// 使用 Rust kernel projection 与 simple-mind-map adapter projection。
export type MindMapData = {
  data: Record<string, unknown>;
  children?: unknown[];
};

export type MindmapRouteMeta = {
  requestId?: string;
  traceId?: string;
  workspaceId?: string | null;
  documentId?: string;
  pageId?: string;
  mindmapId?: string;
  attachmentId?: string;
  updatedAt?: string | null;
};

export type MindmapProjectionNode = {
  uid: string;
  text: string;
  depth: number;
  childCount: number;
};

export type MindmapProjection = {
  schema: "mnote.mindmap_projection.v1";
  projectionId: string;
  projection: "mindmap_subtree";
  source: string;
  owner: string;
  documentId: string;
  mindmapId: string;
  rootNodeId: string | null;
  title: string;
  nodeCount: number;
  nodes: MindmapProjectionNode[];
  data: MindMapData;
  meta: MindmapRouteMeta | null;
};

export type MindmapEditorSceneNode = MindmapProjectionNode & {
  id: string;
  parentId: string | null;
};

export type MindmapEditorSceneEdge = {
  id: string;
  source: string;
  target: string;
};

export type MindmapEditorScene = {
  schema: "mnote.mindmap_editor_scene.v1";
  source: string;
  owner: string;
  documentId: string;
  mindmapId: string;
  rootNodeId: string | null;


id: string;
  source: string;
  target: string;
};

export type MindmapEditorScene = {
  schema: "mnote.mindmap_editor_scene.v1";
  source: string;
  owner: string;
  documentId: string;
  mindmapId: string;
  rootNodeId: string | null;
  title: string;
  nodes: MindmapEditorSceneNode[];
  edges: MindmapEditorSceneEdge[];
  capabilities: {
    canEditText: boolean;
    canAddChild: boolean;
    canAddSiblingAfter: boolean;
    canDeleteNode: boolean;
  };
  data: MindMapData;
  meta: MindmapRouteMeta | null;
};

export type MindmapSimpleMindMapScene = {
  schema: "mnote.mindmap.simple_mind_map_scene.v1";
  runtime: "simple-mind-map";
  documentId: string;
  mindmapId: string;
  rootNodeId: string | null;
  root: MindMapData;
  layout: unknown;
  theme: unknown;
  themeConfig: unknown;
  view: unknown;
  config: unknown;
  compatPayload: unknown;
  kernelRevision: number;
  source: string;
  owner: string;
  meta: MindmapRouteMeta | null;
};

export const defaultMindmapData: MindMapData = {
  data: { text: "中心主题" },
  children: [],
};

export const DEFAULT_MINDMAP_LAYOUT = "logicalStructure";
export const DEFAULT_MINDMAP_THEME = "default";

export const createDefaultMindmapThemeConfig = (): Record<string, unknown> => ({
  lineColor: "#7aa2ff",
  lineStyle: "curve",
  rootLineKeepSameInCurve: true,
  rootLineStartPositionKeepSameInCurve: true,
  generalizationLineColor: "#ef6a5b",
  backgroundColor: "#f6f8fc",
  root: {


export const DEFAULT_MINDMAP_LAYOUT = "logicalStructure";
export const DEFAULT_MINDMAP_THEME = "default";

export const createDefaultMindmapThemeConfig = (): Record<string, unknown> => ({
  lineColor: "#7aa2ff",
  lineStyle: "curve",
  rootLineKeepSameInCurve: true,
  rootLineStartPositionKeepSameInCurve: true,
  generalizationLineColor: "#ef6a5b",
  backgroundColor: "#f6f8fc",
  root: {
    fillColor: "#e25563",
    color: "#ffffff",
    fontWeight: "bold",
    borderColor: "transparent",
    borderWidth: 0,
    borderRadius: 8,
  },
  second: {
    fillColor: "#4f7df3",
    color: "#ffffff",
    borderColor: "transparent",
    borderWidth: 0,
    borderRadius: 8,
  },
  node: {
    fillColor: "transparent",
    color: "#315aa9",
    borderColor: "transparent",
    borderWidth: 0,
  },
  generalization: {
    fillColor: "#ffffff",
    color: "#ef6a5b",
    borderColor: "#ef6a5b",
    borderWidth: 1,
    borderRadius: 8,
  },
});

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

// simple-mind-map 在文本字段缺失时会直接崩溃，这里统一做兜底归一化。
export const normalizeMindmapData = (input: unknown): unknown => {
  if (!input || typeof input !== "object") return defaultMindmapData;
  const root = (input as { root?: unknown }).root ?? input;

  const walk = (node: unknown) => {
    if (!isRecord(node)) return;
    const data = isRecord(node.data) ? node.data : {};
    node.data = data;
    const rawText = data.text;
    data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");

    const gen = data.generalization;
    const fixGen = (value: unknown) => {
      if (!isRecord(value)) return;
      const text = value.text;


const walk = (node: unknown) => {
    if (!isRecord(node)) return;
    const data = isRecord(node.data) ? node.data : {};
    node.data = data;
    const rawText = data.text;
    data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");

    const gen = data.generalization;
    const fixGen = (value: unknown) => {
      if (!isRecord(value)) return;
      const text = value.text;
      value.text = typeof text === "string" ? text : String(text ?? "");
    };

    if (Array.isArray(gen)) gen.forEach(fixGen);
    else fixGen(gen);

    if (Array.isArray(node.children)) {
      node.children.forEach(walk);
    }
  };

  walk(root);
  return input;
};

// 持久化/初始化统一使用根节点对象，避免 wrapper 误传给 simple-mind-map。
export const canonicalizeMindmapData = (input: unknown): MindMapData => {
  const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as Record<string, unknown>;
  const root =
    normalized && typeof normalized === "object" && "root" in normalized
      ? normalized.root
      : normalized;
  return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
};

export const extractMindmapTitle = (input: unknown): string => {
  const canonical = canonicalizeMindmapData(input);
  const text = canonical?.data?.text;
  if (typeof text === "string" && text.trim()) {
    return text.trim();
  }
  return "未命名导图";
};

export const summarizeMindmapProjectionNodes = (input: unknown): MindmapProjectionNode[] => {
  const root = canonicalizeMindmapData(input);
  const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
  const nodes: MindmapProjectionNode[] = [];

  while (queue.length > 0) {
    const current = queue.shift();
    if (!current) break;
    const uidRaw = current.node?.data?.uid;
    const textRaw = current.node?.data?.text;
    const children = Array.isArray(current.node?.children) ? current.node.children : [];
    nodes.push({
      uid:
        typeof uidRaw === "string" && uidRaw.trim()


const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
  const nodes: MindmapProjectionNode[] = [];

  while (queue.length > 0) {
    const current = queue.shift();
    if (!current) break;
    const uidRaw = current.node?.data?.uid;
    const textRaw = current.node?.data?.text;
    const children = Array.isArray(current.node?.children) ? current.node.children : [];
    nodes.push({
      uid:
        typeof uidRaw === "string" && uidRaw.trim()
          ? uidRaw
          : `depth:${current.depth}:index:${nodes.length}`,
      text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
      depth: current.depth,
      childCount: children.length,
    });
    children.forEach((child) => {
      queue.push({
        node: canonicalizeMindmapData(child),
        depth: current.depth + 1,
      });
    });
  }

  return nodes;
};

export const buildMindmapProjection = (input: {
  documentId: string;
  mindmapId: string;
  data: unknown;
  source?: string;
  owner?: string;
  meta?: unknown;
}): MindmapProjection => {
  const data = canonicalizeMindmapData(input.data);
  const nodes = summarizeMindmapProjectionNodes(data);
  const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
  const rootNodeId = nodes[0]?.uid ?? null;

  return {
    schema: "mnote.mindmap_projection.v1",
    projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
    projection: "mindmap_subtree",
    source: input.source ?? "rust-kernel",
    owner: input.owner ?? "rust-kernel",
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    rootNodeId,
    title: extractMindmapTitle(data),
    nodeCount: nodes.length,
    nodes,
    data,
    meta,
  };
};

export const buildMindmapEditorScene = (input: {


documentId: input.documentId,
    mindmapId: input.mindmapId,
    rootNodeId,
    title: extractMindmapTitle(data),
    nodeCount: nodes.length,
    nodes,
    data,
    meta,
  };
};

export const buildMindmapEditorScene = (input: {
  documentId: string;
  mindmapId: string;
  rootNodeId?: string | null;
  data: unknown;
  source?: string;
  owner?: string;
  meta?: unknown;
}): MindmapEditorScene => {
  const data = canonicalizeMindmapData(input.data);
  const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
  const nodes: MindmapEditorSceneNode[] = [];
  const edges: MindmapEditorSceneEdge[] = [];

  const walk = (node: MindMapData, depth: number, parentId: string | null) => {
    const uidRaw = node?.data?.uid;
    const textRaw = node?.data?.text;
    const id =
      typeof uidRaw === "string" && uidRaw.trim()
        ? uidRaw.trim()
        : `depth:${depth}:index:${nodes.length}`;
    const children = Array.isArray(node?.children) ? node.children : [];
    nodes.push({
      id,
      uid: id,
      text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
      depth,
      childCount: children.length,
      parentId,
    });
    if (parentId) {
      edges.push({
        id: `${parentId}->${id}`,
        source: parentId,
        target: id,
      });
    }
    children.forEach((child) => {
      walk(canonicalizeMindmapData(child), depth + 1, id);
    });
  };

  walk(data, 0, null);
  const fallbackRootId = nodes[0]?.id ?? null;
  const rootNodeId = input.rootNodeId && input.rootNodeId.trim() ? input.rootNodeId.trim() : fallbackRootId;

  return {
    schema: "mnote.mindmap_editor_scene.v1",
    source: input.source ?? "rust-kernel",


children.forEach((child) => {
      walk(canonicalizeMindmapData(child), depth + 1, id);
    });
  };

  walk(data, 0, null);
  const fallbackRootId = nodes[0]?.id ?? null;
  const rootNodeId = input.rootNodeId && input.rootNodeId.trim() ? input.rootNodeId.trim() : fallbackRootId;

  return {
    schema: "mnote.mindmap_editor_scene.v1",
    source: input.source ?? "rust-kernel",
    owner: input.owner ?? "rust-kernel",
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    rootNodeId,
    title: extractMindmapTitle(data),
    nodes,
    edges,
    capabilities: {
      canEditText: true,
      canAddChild: true,
      canAddSiblingAfter: true,
      canDeleteNode: true,
    },
    data,
    meta,
  };
};

export const buildMindmapSimpleMindMapScene = (input: {
  documentId: string;
  mindmapId: string;
  data: unknown;
  source?: string;
  owner?: string;
  meta?: unknown;
}): MindmapSimpleMindMapScene => {
  const root = canonicalizeMindmapData(input.data);
  const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
  const rootNodeIdRaw = root?.data?.uid;
  const rootNodeId = typeof rootNodeIdRaw === "string" && rootNodeIdRaw.trim() ? rootNodeIdRaw.trim() : "root";
  if (!root.data.uid) {
    root.data.uid = rootNodeId;
  }

  return {
    schema: "mnote.mindmap.simple_mind_map_scene.v1",
    runtime: "simple-mind-map",
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    rootNodeId,
    root,
    layout: DEFAULT_MINDMAP_LAYOUT,
    theme: DEFAULT_MINDMAP_THEME,
    themeConfig: createDefaultMindmapThemeConfig(),
    view: { x: 0, y: 0, scale: 1 },
    config: {},
    compatPayload: {},
    kernelRevision: 1,


runtime: "simple-mind-map",
    documentId: input.documentId,
    mindmapId: input.mindmapId,
    rootNodeId,
    root,
    layout: DEFAULT_MINDMAP_LAYOUT,
    theme: DEFAULT_MINDMAP_THEME,
    themeConfig: createDefaultMindmapThemeConfig(),
    view: { x: 0, y: 0, scale: 1 },
    config: {},
    compatPayload: {},
    kernelRevision: 1,
    source: input.source ?? "rust-kernel",
    owner: input.owner ?? "rust-kernel",
    meta,
  };
};


import { describe, expect, it } from "vitest";
import {
  resolveMindmapShortcutAction,
  shouldInterceptMindmapShortcut,
} from "./mindmap-shortcuts";

describe("mindmap shortcuts", () => {
  it("把基础快捷键解析为导图动作", () => {
    expect(resolveMindmapShortcutAction({ key: "Enter" })).toBe("insertSiblingAfter");
    expect(resolveMindmapShortcutAction({ key: "Tab" })).toBe("insertChild");
    expect(resolveMindmapShortcutAction({ key: "Delete" })).toBe("deleteNode");
    expect(resolveMindmapShortcutAction({ key: "F2" })).toBe("editNode");
    expect(resolveMindmapShortcutAction({ key: "Enter", ctrlKey: true })).toBeNull();
  });

  it("已进入 mindmap 时即使暂时没有 active node，也要拦截危险按键，避免 ProseMirror 误删整个 block", () => {
    expect(
      shouldInterceptMindmapShortcut({
        debugChromeEnabled: false,
        bridgeReady: true,
        readonly: false,
        isComposing: false,
        isEditableTarget: false,
        targetInsideRoot: false,
        keyboardShortcutArmed: true,
      }),
    ).toBe(true);
    expect(
      shouldInterceptMindmapShortcut({
        debugChromeEnabled: false,
        bridgeReady: true,
        readonly: false,
        isComposing: false,
        isEditableTarget: false,
        targetInsideRoot: false,
        keyboardShortcutArmed: false,
      }),
    ).toBe(false);
  });
});


import type { MindmapUiActionId } from "./mindmap-ui-schema";

type MindmapShortcutEventLike = {
  key: string;
  shiftKey?: boolean;
  ctrlKey?: boolean;
  metaKey?: boolean;
  altKey?: boolean;
};

export type MindmapShortcutGateInput = {
  debugChromeEnabled: boolean;
  bridgeReady: boolean;
  readonly: boolean;
  isComposing: boolean;
  isEditableTarget: boolean;
  targetInsideRoot: boolean;
  keyboardShortcutArmed: boolean;
};

export const resolveMindmapShortcutAction = (
  event: MindmapShortcutEventLike,
): MindmapUiActionId | null => {
  if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
  if (event.key === "Enter") return "insertSiblingAfter";
  if (event.key === "Tab" || event.key === "Insert") return "insertChild";
  if (event.key === "Delete" || event.key === "Backspace") return "deleteNode";
  if (event.key === "F2") return "editNode";
  return null;
};

export const shouldInterceptMindmapShortcut = (
  input: MindmapShortcutGateInput,
): boolean => {
  if (input.debugChromeEnabled) return false;
  if (!input.bridgeReady || input.readonly || input.isComposing) return false;
  if (input.isEditableTarget) return false;
  if (input.targetInsideRoot) return true;
  return input.keyboardShortcutArmed;
};


import { describe, expect, it } from "vitest";
import {
  MINDMAP_TOOLBAR_MORE_ACTION_ID,
  MINDMAP_DEBUG_CHROME_QUERY_PARAM,
  isMindmapDebugChromeEnabled,
  mindmapDefaultUiSchema,
  mindmapToolbarFileActionOrder,
  mindmapToolbarPrimaryActionOrder,
} from "./mindmap-ui-schema";
import { getMindmapActionMapping } from "./mindmap-action-map";

describe("mindmap UI schema", () => {
  it("默认 schema 暴露第一阶段 UI shell 分区", () => {
    expect(mindmapDefaultUiSchema.toolbarGroups.map((group) => group.id)).toEqual([
      "history",
      "node",
      "insert",
      "file",
      "view",
    ]);
    expect(mindmapDefaultUiSchema.sidebarPanels.map((panel) => panel.id)).toEqual([
      "nodeStyle",
      "baseStyle",
      "theme",
      "structure",
      "outline",
      "shortcutKey",
      "settings",
    ]);
    expect(mindmapDefaultUiSchema.navigatorItems.map((item) => item.id)).toEqual([
      "stats",
      "centerRoot",
      "search",
      "zoomOut",
      "zoom",
      "zoomIn",
      "fullscreen",
      "readonly",
    ]);
  });

  it("默认不启用旧手写 chrome，只有显式 debug 参数才启用", () => {
    expect(MINDMAP_DEBUG_CHROME_QUERY_PARAM).toBe("mnoteMindmapDebugChrome");
    expect(isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1")).toBe(false);
    expect(
      isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=1"),
    ).toBe(true);
    expect(
      isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=true"),
    ).toBe(true);
  });

  it("toolbar action id 不重复", () => {
    const actionIds = mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions);
    expect(new Set(actionIds).size).toBe(actionIds.length);
  });

  it("单行 toolbar 主按钮顺序对齐 lx-doc/KMind", () => {
    expect([...mindmapToolbarPrimaryActionOrder, MINDMAP_TOOLBAR_MORE_ACTION_ID]).toEqual([
      "undo",


isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=true"),
    ).toBe(true);
  });

  it("toolbar action id 不重复", () => {
    const actionIds = mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions);
    expect(new Set(actionIds).size).toBe(actionIds.length);
  });

  it("单行 toolbar 主按钮顺序对齐 lx-doc/KMind", () => {
    expect([...mindmapToolbarPrimaryActionOrder, MINDMAP_TOOLBAR_MORE_ACTION_ID]).toEqual([
      "undo",
      "redo",
      "editNode",
      "insertSiblingAfter",
      "deleteNode",
      "insertChild",
      "tag",
      "hyperlink",
      "note",
      "image",
      "icon",
      "summary",
      "associativeLine",
      "formula",
      "more",
    ]);
  });

  it("toolbar 元信息包含图标、短标签、长标签、优先级和溢出归类", () => {
    const primaryMeta = mindmapToolbarPrimaryActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId]);
    expect(primaryMeta.map((meta) => meta.priority)).toEqual([...primaryMeta.map((meta) => meta.priority)].sort((a, b) => a - b));
    expect(primaryMeta.every((meta) => meta.cluster === "main")).toBe(true);
    expect(primaryMeta.every((meta) => meta.iconKey && meta.shortLabel && meta.longLabel && meta.overflowGroup)).toBe(true);
  });

  it("导入导出属于右侧独立 toolbar cluster", () => {
    expect([...mindmapToolbarFileActionOrder]).toEqual(["import", "export"]);
    expect(mindmapToolbarFileActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId].cluster)).toEqual([
      "file",
      "file",
    ]);
  });

  it("默认 schema 中所有 action 都有 action map 映射", () => {
    const actionIds = [
      ...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
      ...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
      ...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
    ];

    expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
  });

  it("toolbar 真实可见 action 都有 action map，more 仅作为 shell 虚拟按钮", () => {
    const visibleActionIds = [
      ...mindmapToolbarPrimaryActionOrder,
      ...mindmapToolbarFileActionOrder,
    ];


...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
    ];

    expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
  });

  it("toolbar 真实可见 action 都有 action map，more 仅作为 shell 虚拟按钮", () => {
    const visibleActionIds = [
      ...mindmapToolbarPrimaryActionOrder,
      ...mindmapToolbarFileActionOrder,
    ];

    expect(visibleActionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
    expect(mindmapDefaultUiSchema.toolbarActionMeta).not.toHaveProperty(MINDMAP_TOOLBAR_MORE_ACTION_ID);
  });

  it("第一阶段 context menu 覆盖节点和画布动作", () => {
    expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => item.requiresNode).map((item) => item.actionId)).toEqual([
      "insertChild",
      "insertSiblingAfter",
      "deleteNode",
      "summary",
      "associativeLine",
      "expandCollapse",
      "copyNodeText",
    ]);
    expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => !item.requiresNode).map((item) => item.actionId)).toEqual([
      "centerRoot",
      "fitView",
      "search",
      "readonly",
      "showMenu",
    ]);
  });

  it("第一阶段 sidebar panel 数量受控", () => {
    expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(7);
  });

  it("第一阶段 sidebar 暴露主题、结构和 compat patch 选项", () => {
    const themePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "theme");
    expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4", "simple", "dark"]);

    const structurePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "structure");
    expect(structurePanel?.options.map((option) => option.value)).toEqual([
      "logicalStructure",
      "mindMap",
      "organizationStructure",
      "catalogOrganization",
      "timeline",
      "fishbone",
    ]);
    expect(structurePanel?.options.every((option) => option.controlType === "layoutCard")).toBe(true);

    const nodeStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "nodeStyle");
    const baseStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "baseStyle");
    expect(nodeStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
    expect(baseStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
  });


"catalogOrganization",
      "timeline",
      "fishbone",
    ]);
    expect(structurePanel?.options.every((option) => option.controlType === "layoutCard")).toBe(true);

    const nodeStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "nodeStyle");
    const baseStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "baseStyle");
    expect(nodeStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
    expect(baseStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
  });

  it("每个 sidebar option 要么可写，要么显式标记只读", () => {
    const options = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) => panel.options);
    expect(options.length).toBeGreaterThan(0);
    expect(options.every((option) => option.actionId !== null || option.readonly === true)).toBe(true);
  });

  it("可写 sidebar option 必须能映射到 compat patch 或 kernel command", () => {
    const writableOptions = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) =>
      panel.options.filter((option) => option.readonly !== true),
    );
    expect(writableOptions.length).toBeGreaterThan(0);
    expect(
      writableOptions.every((option) => {
        if (option.compatPath) return true;
        if (!option.actionId) return false;
        const mapping = getMindmapActionMapping(option.actionId);
        return mapping?.target === "kernelCommand" || mapping?.target === "compatPatch";
      }),
    ).toBe(true);
  });
});


export const MINDMAP_DEBUG_CHROME_QUERY_PARAM = "mnoteMindmapDebugChrome";

export type MindmapUiActionId =
  | "undo"
  | "redo"
  | "editNode"
  | "insertSiblingAfter"
  | "insertChild"
  | "deleteNode"
  | "tag"
  | "hyperlink"
  | "note"
  | "image"
  | "icon"
  | "summary"
  | "associativeLine"
  | "formula"
  | "painter"
  | "import"
  | "export"
  | "setTheme"
  | "setLayout"
  | "zoomIn"
  | "zoomOut"
  | "fitView"
  | "centerRoot"
  | "fullscreenCanvas"
  | "fullscreenPage"
  | "exitFullscreen"
  | "search"
  | "showMenu"
  | "expandCollapse"
  | "copyNodeText"
  | "readonly";

export const MINDMAP_TOOLBAR_MORE_ACTION_ID = "more" as const;

export type MindmapToolbarVirtualActionId = typeof MINDMAP_TOOLBAR_MORE_ACTION_ID;

export type MindmapToolbarActionId = MindmapUiActionId | MindmapToolbarVirtualActionId;

export type MindmapToolbarActionCluster = "main" | "file" | "view";

export type MindmapToolbarOverflowGroup = "history" | "node" | "insert" | "file" | "view";

export type MindmapToolbarActionMeta = {
  id: MindmapUiActionId;
  iconKey: string;
  shortLabel: string;
  longLabel: string;
  priority: number;
  overflowGroup: MindmapToolbarOverflowGroup;
  cluster: MindmapToolbarActionCluster;
};

export type MindmapToolbarGroup = {
  id: MindmapToolbarOverflowGroup;
  label: string;
  actions: MindmapUiActionId[];
  collapsePriority: number;


shortLabel: string;
  longLabel: string;
  priority: number;
  overflowGroup: MindmapToolbarOverflowGroup;
  cluster: MindmapToolbarActionCluster;
};

export type MindmapToolbarGroup = {
  id: MindmapToolbarOverflowGroup;
  label: string;
  actions: MindmapUiActionId[];
  collapsePriority: number;
};

export type MindmapSidebarPanel = {
  id: MindmapSidebarPanelId;
  kind: MindmapSidebarPanelId;
  label: string;
  icon: string;
  runtimeCapability: string;
  phase: 1;
  options: MindmapSidebarOption[];
};

export type MindmapSidebarPanelId =
  | "nodeStyle"
  | "baseStyle"
  | "theme"
  | "structure"
  | "outline"
  | "shortcutKey"
  | "settings";

export type MindmapSidebarOptionControlType =
  | "button"
  | "swatch"
  | "segmented"
  | "slider"
  | "numberInput"
  | "select"
  | "layoutCard"
  | "treeItem"
  | "toggle";

export type MindmapSidebarOption = {
  id: string;
  label: string;
  actionId: MindmapUiActionId | null;
  value: unknown;
  controlType: MindmapSidebarOptionControlType;
  preview?: string;
  description?: string;
  readonly?: boolean;
  compatPath?: string;
};

export type MindmapNavigatorItem = {
  id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "fullscreen" | "readonly";
  label: string;
  actionId: MindmapUiActionId | null;