export {};

declare global {
  interface Window {
    /**
     * 仅用于开发调试：在控制台访问当前思维导图实例。
     * 生产环境不依赖该字段。
     */
    __mindmapInstance?: unknown | null;
  }
}


import { beforeEach, describe, expect, it } from "vitest";
import { useAiAgentUiStore } from "./ai-agent-ui";

describe("useAiAgentUiStore", () => {
  beforeEach(() => {
    useAiAgentUiStore.setState({
      documentAgentAvailable: false,
      documentAgentOpen: false,
      documentAgentActivated: false,
      globalAgentOpen: false,
      globalAgentActivated: false,
    });
  });

  it("全局 AI 开关不依赖页面 AI 可用态", () => {
    const state = useAiAgentUiStore.getState();

    state.toggleGlobalAgentOpen();
    expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(true);
    expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);

    state.toggleGlobalAgentOpen();
    expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(false);
    expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);
  });
});


"use client";

import { create } from "zustand";

type AiAgentUiState = {
  globalAgentOpen: boolean;
  globalAgentActivated: boolean;
  documentAgentAvailable: boolean;
  documentAgentOpen: boolean;
  documentAgentActivated: boolean;
  setGlobalAgentOpen: (open: boolean) => void;
  toggleGlobalAgentOpen: () => void;
  setDocumentAgentAvailable: (available: boolean) => void;
  setDocumentAgentOpen: (open: boolean) => void;
  toggleDocumentAgentOpen: () => void;
};

export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
  globalAgentOpen: false,
  globalAgentActivated: false,
  documentAgentAvailable: false,
  documentAgentOpen: false,
  documentAgentActivated: false,
  setGlobalAgentOpen: (open) =>
    set((state) => ({
      globalAgentOpen: open,
      globalAgentActivated: state.globalAgentActivated || open,
    })),
  toggleGlobalAgentOpen: () => {
    const s = get();
    set({
      globalAgentOpen: !s.globalAgentOpen,
      globalAgentActivated: s.globalAgentActivated || !s.globalAgentOpen,
    });
  },
  setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
  setDocumentAgentOpen: (open) =>
    set((state) => ({
      documentAgentOpen: open,
      documentAgentActivated: state.documentAgentActivated || open,
    })),
  toggleDocumentAgentOpen: () => {
    const s = get();
    if (!s.documentAgentAvailable) return;
    set({
      documentAgentOpen: !s.documentAgentOpen,
      documentAgentActivated: s.documentAgentActivated || !s.documentAgentOpen,
    });
  },
}));


"use client";

import { create } from "zustand";

export type ThemeMode = "system" | "light" | "dark";

type PersistedPreferences = {
  theme: ThemeMode;
  showStructure: boolean;
  spellCheck: boolean;
  flightMode: boolean;
};

const STORAGE_KEY = "mnote:preferences:v1";

const loadPersisted = (): PersistedPreferences | null => {
  if (typeof window === "undefined") return null;
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw) as Partial<PersistedPreferences>;
    const theme = parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" ? parsed.theme : "system";
    return {
      theme,
      showStructure: typeof parsed.showStructure === "boolean" ? parsed.showStructure : false,
      spellCheck: typeof parsed.spellCheck === "boolean" ? parsed.spellCheck : false,
      flightMode: typeof parsed.flightMode === "boolean" ? parsed.flightMode : false,
    };
  } catch {
    return null;
  }
};

const persist = (value: PersistedPreferences) => {
  if (typeof window === "undefined") return;
  try {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
  } catch {
    // ignore
  }
};

interface AppPreferencesState extends PersistedPreferences {
  hydrated: boolean;
  hydrate: () => void;
  setTheme: (theme: ThemeMode) => void;
  setShowStructure: (showStructure: boolean) => void;
  setSpellCheck: (spellCheck: boolean) => void;
  setFlightMode: (flightMode: boolean) => void;
}

export const useAppPreferencesStore = create<AppPreferencesState>((set, get) => ({
  hydrated: false,
  theme: "system",
  showStructure: false,
  spellCheck: false,
  flightMode: false,
  hydrate: () => {
    const current = get();
    if (current.hydrated) return;


setFlightMode: (flightMode: boolean) => void;
}

export const useAppPreferencesStore = create<AppPreferencesState>((set, get) => ({
  hydrated: false,
  theme: "system",
  showStructure: false,
  spellCheck: false,
  flightMode: false,
  hydrate: () => {
    const current = get();
    if (current.hydrated) return;
    const persisted = loadPersisted();
    if (persisted) {
      set({ ...persisted, hydrated: true });
    } else {
      set({ hydrated: true });
    }
  },
  setTheme: (theme) =>
    set((state) => {
      const next = { ...state, theme };
      persist({
        theme: next.theme,
        showStructure: next.showStructure,
        spellCheck: next.spellCheck,
        flightMode: next.flightMode,
      });
      return next;
    }),
  setShowStructure: (showStructure) =>
    set((state) => {
      const next = { ...state, showStructure };
      persist({
        theme: next.theme,
        showStructure: next.showStructure,
        spellCheck: next.spellCheck,
        flightMode: next.flightMode,
      });
      return next;
    }),
  setSpellCheck: (spellCheck) =>
    set((state) => {
      const next = { ...state, spellCheck };
      persist({
        theme: next.theme,
        showStructure: next.showStructure,
        spellCheck: next.spellCheck,
        flightMode: next.flightMode,
      });
      return next;
    }),
  setFlightMode: (flightMode) =>
    set((state) => {
      const next = { ...state, flightMode };
      persist({
        theme: next.theme,
        showStructure: next.showStructure,
        spellCheck: next.spellCheck,
        flightMode: next.flightMode,


flightMode: next.flightMode,
      });
      return next;
    }),
  setFlightMode: (flightMode) =>
    set((state) => {
      const next = { ...state, flightMode };
      persist({
        theme: next.theme,
        showStructure: next.showStructure,
        spellCheck: next.spellCheck,
        flightMode: next.flightMode,
      });
      return next;
    }),
}));


"use client";

import { create } from "zustand";

export type CommentTarget = {
  workspaceId: string;
  documentId: string;
  blockId: string | null;
};

interface CommentsUiState {
  open: boolean;
  target: CommentTarget | null;
  openForPage: (args: { workspaceId: string; documentId: string }) => void;
  openForBlock: (args: { workspaceId: string; documentId: string; blockId: string }) => void;
  close: () => void;
}

export const useCommentsUiStore = create<CommentsUiState>((set) => ({
  open: false,
  target: null,
  openForPage: ({ workspaceId, documentId }) =>
    set({
      open: true,
      target: { workspaceId, documentId, blockId: null },
    }),
  openForBlock: ({ workspaceId, documentId, blockId }) =>
    set({
      open: true,
      target: { workspaceId, documentId, blockId },
    }),
  close: () => set({ open: false }),
}));


"use client";

import { create } from "zustand";

interface CurrentDocumentState {
  documentId: string | null;
  readOnly: boolean;
  disableDownload: boolean;
  disableCopy: boolean;
  setCurrent: (documentId: string, readOnly: boolean, disableDownload: boolean, disableCopy: boolean) => void;
  clearIfMatch: (documentId: string) => void;
}

export const useCurrentDocumentStore = create<CurrentDocumentState>((set, get) => ({
  documentId: null,
  readOnly: false,
  disableDownload: false,
  disableCopy: false,
  setCurrent: (documentId, readOnly, disableDownload, disableCopy) =>
    set({ documentId, readOnly, disableDownload, disableCopy }),
  clearIfMatch: (documentId) => {
    const state = get();
    if (state.documentId === documentId) {
      set({ documentId: null, readOnly: false, disableDownload: false, disableCopy: false });
    }
  },
}));


"use client";

import { create } from "zustand";
import type { ReferenceTarget } from "@/types/search";
import type { Json } from "@/types/supabase";
import type { MediaAsset } from "@/types/media";

export interface EditorReferenceBridgeResult {
  blockId: string | null;
}

export interface EditorReferenceBridge {
  insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult;
  insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult;
  undo?: () => void;
  redo?: () => void;
  getCursorBlockId?: () => string | null;
  /**
   * 向当前打开的文档插入一个“附件/媒体”块。
   * 主要用于侧边栏文件树拖拽上传后，把文件显示到主编辑区。
   */
  insertMediaAsset?: (asset: MediaAsset) => void;
  /**
   * 向当前打开的文档插入一个“思维导图”块（使用 mindmapId 作为 block.id）。
   * 主要用于侧边栏垃圾桶“恢复思维导图”后，把导图重新显示到主编辑区。
   */
  insertMindmapAsset?: (args: { documentId: string; mindmapId: string }) => void;
  /**
   * 向当前打开的文档插入一个“在线表格（luckysheet）”块。
   * 主要用于侧边栏垃圾桶“恢复表格”后，把表格重新显示到主编辑区。
   */
  insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
  replaceWithSnapshot: (blocks: Json) => void;
  openTableFullScreen?: (tableId: string) => void;
}

interface EditorBridgeState {
  bridge: EditorReferenceBridge | null;
  registerBridge: (bridge: EditorReferenceBridge | null) => void;
}

export const useEditorBridgeStore = create<EditorBridgeState>((set) => ({
  bridge: null,
  registerBridge: (bridge) =>
    set((state) => {
      if (state.bridge === bridge) {
        return state;
      }
      return { bridge };
    }),
}));


"use client";

import { create } from "zustand";
import type { MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";

type OnPick = (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;

interface MoveEmbedPickerState {
  open: boolean;
  workspaceId: string | null;
  defaultMode: MoveEmbedMode;
  modes: MoveEmbedMode[];
  allowRoot: boolean;
  excludeIds: string[];
  onPick: OnPick | null;
  setWorkspaceId: (workspaceId: string | null) => void;
  openPicker: (args: {
    workspaceId: string | null;
    defaultMode: MoveEmbedMode;
    modes?: MoveEmbedMode[];
    allowRoot?: boolean;
    excludeIds?: string[];
    onPick: OnPick;
  }) => void;
  close: () => void;
}

export const useMoveEmbedPickerStore = create<MoveEmbedPickerState>((set) => ({
  open: false,
  workspaceId: null,
  defaultMode: "move",
  modes: ["move", "embed"],
  allowRoot: true,
  excludeIds: [],
  onPick: null,
  setWorkspaceId: (workspaceId) => set({ workspaceId }),
  openPicker: ({ workspaceId, defaultMode, modes, allowRoot, excludeIds, onPick }) =>
    set({
      open: true,
      workspaceId,
      defaultMode,
      modes: modes ?? ["move", "embed"],
      allowRoot: allowRoot ?? true,
      excludeIds: excludeIds ?? [],
      onPick,
    }),
  close: () =>
    set({
      open: false,
      onPick: null,
      excludeIds: [],
    }),
}));


"use client";

import { create } from "zustand";

type OnlyOfficeAiBridgeState = {
  pluginReady: boolean;
  targetOrigin: string;
  targetWindow: Window | null;
  lastReadyAt: number | null;
  captureReady: (payload: { targetOrigin: string; targetWindow: Window | null }) => void;
  reset: () => void;
};

export const useOnlyOfficeAiBridgeStore = create<OnlyOfficeAiBridgeState>((set) => ({
  pluginReady: false,
  targetOrigin: "*",
  targetWindow: null,
  lastReadyAt: null,
  captureReady: ({ targetOrigin, targetWindow }) =>
    set({
      pluginReady: Boolean(targetWindow),
      targetOrigin: targetOrigin || "*",
      targetWindow,
      lastReadyAt: Date.now(),
    }),
  reset: () =>
    set({
      pluginReady: false,
      targetOrigin: "*",
      targetWindow: null,
      lastReadyAt: null,
    }),
}));


"use client";

import { create } from "zustand";

interface PageLayoutState {
  showInspector: boolean;
  toggleInspector: () => void;
  setInspector: (visible: boolean) => void;
}

export const usePageLayoutStore = create<PageLayoutState>((set) => ({
  showInspector: false,
  toggleInspector: () =>
    set((state) => ({
      showInspector: !state.showInspector,
    })),
  setInspector: (visible) => set({ showInspector: visible }),
}));


"use client";

import { create } from "zustand";
import type {
  DocumentSearchFilters,
  DocumentSearchResult,
  DocumentSearchTimeField,
  DocumentSearchTimeRange,
} from "@/types/search";

export type SearchPaletteMode = "search" | "reference";
export type ReferenceInsertMode = "inline" | "embed";
export type FilterKey = "titleOnly" | "exact" | "onlyCurrentPage" | "includeOcr";

interface SearchPaletteState {
  open: boolean;
  activated: boolean;
  mode: SearchPaletteMode;
  query: string;
  filters: Omit<DocumentSearchFilters, "timeRange">;
  timeRange: DocumentSearchTimeRange;
  referenceMode: ReferenceInsertMode;
  alias: string;
  recent: DocumentSearchResult[];
  setRecent: (items: DocumentSearchResult[]) => void;
  openSearch: () => void;
  openReference: (options?: { referenceMode?: ReferenceInsertMode; aliasSeed?: string }) => void;
  close: () => void;
  setQuery: (value: string) => void;
  toggleFilter: (key: FilterKey) => void;
  setTimeRange: (range: DocumentSearchTimeRange) => void;
  setTimeField: (field: DocumentSearchTimeField) => void;
  setCustomRange: (range: { from?: string; to?: string } | null) => void;
  setReferenceMode: (mode: ReferenceInsertMode) => void;
  setAlias: (value: string) => void;
  rememberResult: (result: DocumentSearchResult) => void;
  resetFilters: () => void;
}

const defaultFilters: Omit<DocumentSearchFilters, "timeRange"> = {
  titleOnly: false,
  exact: false,
  onlyCurrentPage: false,
  includeOcr: false,
  timeField: "updated",
  customRange: undefined,
};

export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
  open: false,
  activated: false,
  mode: "search",
  query: "",
  filters: { ...defaultFilters },
  timeRange: "any",
  referenceMode: "inline",
  alias: "",
  recent: [],
  setRecent: (items) => set({ recent: items.slice(0, 10) }),
  openSearch: () =>


export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
  open: false,
  activated: false,
  mode: "search",
  query: "",
  filters: { ...defaultFilters },
  timeRange: "any",
  referenceMode: "inline",
  alias: "",
  recent: [],
  setRecent: (items) => set({ recent: items.slice(0, 10) }),
  openSearch: () =>
    set((state) => ({
      open: true,
      activated: true,
      mode: "search",
      query: state.query,
    })),
  openReference: (options) =>
    set({
      open: true,
      activated: true,
      mode: "reference",
      referenceMode: options?.referenceMode ?? "inline",
      alias: options?.aliasSeed ?? "",
    }),
  close: () => set({ open: false }),
  setQuery: (value) => set({ query: value }),
  toggleFilter: (key) =>
    set((state) => ({
      filters: {
        ...state.filters,
        [key]: !state.filters[key],
      },
    })),
  setTimeRange: (range) => set({ timeRange: range }),
  setTimeField: (field) =>
    set((state) => ({
      filters: {
        ...state.filters,
        timeField: field,
      },
    })),
  setCustomRange: (range) =>
    set((state) => ({
      filters: {
        ...state.filters,
        customRange: range ?? undefined,
      },
    })),
  setReferenceMode: (mode) => set({ referenceMode: mode }),
  setAlias: (value) => set({ alias: value }),
  rememberResult: (result) =>
    set((state) => {
      const existing = state.recent.filter((item) => item.id !== result.id);
      return {
        recent: [result, ...existing].slice(0, 10),
      };
    }),
  resetFilters: () =>


},
    })),
  setReferenceMode: (mode) => set({ referenceMode: mode }),
  setAlias: (value) => set({ alias: value }),
  rememberResult: (result) =>
    set((state) => {
      const existing = state.recent.filter((item) => item.id !== result.id);
      return {
        recent: [result, ...existing].slice(0, 10),
      };
    }),
  resetFilters: () =>
    set({
      filters: { ...defaultFilters },
      timeRange: "any",
    }),
}));


import { afterEach, describe, expect, it } from "vitest";
import { useSidebarStore } from "@/store/sidebar";

describe("useSidebarStore hydration", () => {
  afterEach(() => {
    localStorage.clear();
  });

  it("模块初始化时应保持默认首帧状态，避免先读持久化状态", () => {
    const state = useSidebarStore.getState();

    expect(state.open).toBe(false);
    expect(state.width).toBe(280);
    expect(state.viewMode).toBe("section");
  });

  it("手动 hydrate 后才应用持久化状态", () => {
    localStorage.setItem(
      "sidebar-ui",
      JSON.stringify({
        state: {
          open: true,
          width: 360,
          collapsedSections: {
            starred: false,
            public: false,
            shared: false,
            private: true,
            templates: false,
          },
          trashConfirm: false,
          viewMode: "filesystem",
        },
        version: 4,
      }),
    );

    const store = useSidebarStore;
    expect(store.persist.hasHydrated()).toBe(false);
    void store.persist.rehydrate();

    expect(store.getState().open).toBe(true);
    expect(store.getState().width).toBe(360);
    expect(store.getState().viewMode).toBe("filesystem");
    expect(store.persist.hasHydrated()).toBe(true);
  });
});


import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { SidebarSectionId } from "@/components/sidebar/types";

interface SidebarState {
  open: boolean;
  width: number;
  collapsedSections: Record<SidebarSectionId, boolean>;
  trashConfirm: boolean;
  viewMode: "section" | "filesystem";
  setOpen: (open: boolean) => void;
  setWidth: (width: number) => void;
  toggleSection: (section: SidebarSectionId) => void;
  setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
  setTrashConfirm: (value: boolean) => void;
  setViewMode: (mode: SidebarState["viewMode"]) => void;
  setCollapsedSections: (collapsed: Record<SidebarSectionId, boolean>) => void;
}

const sectionDefaults: Record<SidebarSectionId, boolean> = {
  starred: true,
  public: true,
  shared: true,
  private: false,
  templates: true,
};

export const useSidebarStore = create<SidebarState>()(
  persist(
    (set) => ({
      open: false,
      width: 280,
      collapsedSections: { ...sectionDefaults },
      trashConfirm: true,
      viewMode: "section",
      setOpen: (open) => set({ open }),
      setWidth: (width) => set({ width }),
      toggleSection: (section) =>
        set((state) => ({
          collapsedSections: {
            ...state.collapsedSections,
            [section]: !state.collapsedSections[section],
          },
        })),
      setSectionCollapsed: (section, collapsed) =>
        set((state) => ({
          collapsedSections: {
            ...state.collapsedSections,
            [section]: collapsed,
          },
        })),
      setTrashConfirm: (value) => set({ trashConfirm: value }),
      setViewMode: (mode) => set({ viewMode: mode }),
      setCollapsedSections: (collapsed) => set({ collapsedSections: collapsed }),
    }),
    {
      name: "sidebar-ui",
      version: 4,
      skipHydration: true,
      migrate: (persistedState, version) => {


[section]: collapsed,
          },
        })),
      setTrashConfirm: (value) => set({ trashConfirm: value }),
      setViewMode: (mode) => set({ viewMode: mode }),
      setCollapsedSections: (collapsed) => set({ collapsedSections: collapsed }),
    }),
    {
      name: "sidebar-ui",
      version: 4,
      skipHydration: true,
      migrate: (persistedState, version) => {
        const state = persistedState as
          | Partial<
              Pick<
                SidebarState,
                "width" | "collapsedSections" | "trashConfirm" | "viewMode"
              >
            >
          | undefined;
        if (!state) return state;
        if (version >= 4) return state;

        const nextCollapsedSections = {
          ...sectionDefaults,
          ...(state.collapsedSections ?? {}),
        };

        return {
          ...state,
          collapsedSections: nextCollapsedSections,
        };
      },
      partialize: (state) => ({
        width: state.width,
        collapsedSections: state.collapsedSections,
        trashConfirm: state.trashConfirm,
        viewMode: state.viewMode,
      }),
    },
  ),
);


/**
 * API 工具函数
 * 提供统一的错误处理和响应解析
 */

import { NextResponse } from "next/server";

/**
 * API 错误类
 */
export class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public details?: unknown,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

/**
 * 统一的 API 错误响应格式
 */
export interface ApiErrorResponse {
  error: string;
  details?: unknown;
}

/**
 * 创建 API 错误响应
 * @param message 错误消息
 * @param status HTTP 状态码
 * @param details 额外的错误详情
 * @returns NextResponse
 */
export function apiErrorResponse(
  message: string,
  status = 500,
  details?: unknown,
): NextResponse<ApiErrorResponse> {
  const payload: ApiErrorResponse =
    typeof details === "undefined" ? { error: message } : { error: message, details };
  return NextResponse.json(payload, { status });
}

/**
 * 常用错误响应的快捷方法
 */
export const errorResponses = {
  /** 400 - 请求参数错误 */
  badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),

  /** 401 - 未登录 */
  unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),

  /** 403 - 无权限 */
  forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),

  /** 404 - 资源不存在 */


*/
export const errorResponses = {
  /** 400 - 请求参数错误 */
  badRequest: (message: string = "请求参数错误") => apiErrorResponse(message, 400),

  /** 401 - 未登录 */
  unauthorized: (message: string = "未登录") => apiErrorResponse(message, 401),

  /** 403 - 无权限 */
  forbidden: (message: string = "无权限访问") => apiErrorResponse(message, 403),

  /** 404 - 资源不存在 */
  notFound: (message: string = "资源不存在") => apiErrorResponse(message, 404),

  /** 500 - 服务器错误 */
  internalError: (message: string = "服务器错误") => apiErrorResponse(message, 500),

  /** AI 配置错误 */
  aiConfigError: (provider: "online" | "local") =>
    apiErrorResponse(
      provider === "local"
        ? "未找到本地 AI 配置（LOCAL_AI_BASE_URL/LOCAL_AI_MODEL 或 ai.local.md / ai-local.md）"
        : "未找到在线 AI 配置（ai.md 或 ONLINE_AI_* 环境变量）",
      500,
    ),
} as const;

/**
 * 处理 fetch 响应并解析 JSON
 * 如果响应不成功，抛出 ApiError
 * @param response Fetch Response 对象
 * @param defaultErrorMessage 默认错误消息
 * @returns 解析后的 JSON 数据
 */
export async function handleApiResponse<T>(
  response: Response,
  defaultErrorMessage: string = "请求失败",
): Promise<T> {
  if (!response.ok) {
    let message = defaultErrorMessage;
    let details: unknown;

    try {
      const payload = await response.json();
      if (payload && typeof payload === "object") {
        if ("error" in payload && typeof payload.error === "string") {
          message = payload.error;
        }
        if ("details" in payload) {
          details = payload.details;
        }
      }
    } catch {
      // JSON 解析失败，使用默认消息
    }

    throw new ApiError(message, response.status, details);
  }

  return response.json() as Promise<T>;


if ("details" in payload) {
          details = payload.details;
        }
      }
    } catch {
      // JSON 解析失败，使用默认消息
    }

    throw new ApiError(message, response.status, details);
  }

  return response.json() as Promise<T>;
}

/**
 * 安全地解析 JSON，失败时返回 null
 * @param raw JSON 字符串
 * @returns 解析后的对象或 null
 */
export function safeParseJson<T = unknown>(raw: string | null | undefined): T | null {
  if (!raw) return null;
  try {
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

/**
 * 验证请求体是否包含必需字段
 * @param body 请求体对象
 * @param requiredFields 必需字段列表
 * @returns 如果验证失败，返回错误响应；否则返回 null
 */
export function validateRequestBody<T extends Record<string, unknown>>(
  body: T | null,
  requiredFields: (keyof T)[],
): NextResponse<ApiErrorResponse> | null {
  if (!body) {
    return apiErrorResponse("请求体为空", 400);
  }

  for (const field of requiredFields) {
    if (!(field in body) || body[field] === null || body[field] === undefined) {
      return apiErrorResponse(`缺少必需字段: ${String(field)}`, 400);
    }
  }

  return null;
}

/**
 * 从请求中安全地获取 JSON
 * @param request Next.js Request 对象
 * @returns 解析后的 JSON 或 null
 */
export async function safeGetJsonBody<T = unknown>(
  request: Request,
): Promise<T | null> {
  try {


return null;
}

/**
 * 从请求中安全地获取 JSON
 * @param request Next.js Request 对象
 * @returns 解析后的 JSON 或 null
 */
export async function safeGetJsonBody<T = unknown>(
  request: Request,
): Promise<T | null> {
  try {
    return (await request.json()) as T;
  } catch {
    return null;
  }
}


import type { Json } from "@/types/supabase";

type BlockLike = {
  id: string;
  type?: string;
  props?: Record<string, unknown>;
  content?: unknown;
  children?: BlockLike[];
};

const asBlockArray = (value: unknown): BlockLike[] => {
  if (!Array.isArray(value)) return [];
  return value.filter((b) => b && typeof b === "object" && typeof (b as { id?: unknown }).id === "string") as BlockLike[];
};

export const findBlockInTree = (
  blocks: BlockLike[],
  blockId: string,
): { block: BlockLike; parent: BlockLike | null; index: number } | null => {
  const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
  while (stack.length) {
    const item = stack.pop()!;
    const list = item.list;
    for (let i = 0; i < list.length; i += 1) {
      const b = list[i]!;
      if (b.id === blockId) {
        return { block: b, parent: item.parent, index: i };
      }
      if (Array.isArray(b.children) && b.children.length > 0) {
        stack.push({ list: b.children, parent: b });
      }
    }
  }
  return null;
};

const cloneBlock = (block: BlockLike): BlockLike => {
  return {
    ...block,
    props: block.props ? { ...block.props } : undefined,
    content: Array.isArray(block.content) ? [...block.content] : block.content,
    children: Array.isArray(block.children) ? block.children.map(cloneBlock) : block.children,
  };
};

export const removeBlockSubtree = (
  blocks: BlockLike[],
  blockId: string,
): { removed: BlockLike | null; nextBlocks: BlockLike[] } => {
  // 说明：这里不直接修改入参 blocks，返回新的 nextBlocks。
  const nextTop = blocks.map(cloneBlock);
  const hit = findBlockInTree(nextTop, blockId);
  if (!hit) return { removed: null, nextBlocks: nextTop };

  if (hit.parent) {
    const parent = hit.parent;
    const nextChildren = asBlockArray(parent.children).map(cloneBlock);
    const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
    parent.children = nextChildren;
    return { removed, nextBlocks: nextTop };


): { removed: BlockLike | null; nextBlocks: BlockLike[] } => {
  // 说明：这里不直接修改入参 blocks，返回新的 nextBlocks。
  const nextTop = blocks.map(cloneBlock);
  const hit = findBlockInTree(nextTop, blockId);
  if (!hit) return { removed: null, nextBlocks: nextTop };

  if (hit.parent) {
    const parent = hit.parent;
    const nextChildren = asBlockArray(parent.children).map(cloneBlock);
    const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
    parent.children = nextChildren;
    return { removed, nextBlocks: nextTop };
  }

  const removed = nextTop.splice(hit.index, 1)[0] ?? null;
  return { removed, nextBlocks: nextTop };
};

export const replaceBlockInTree = (
  blocks: BlockLike[],
  blockId: string,
  nextBlock: BlockLike,
): { ok: boolean; nextBlocks: BlockLike[] } => {
  const nextTop = blocks.map(cloneBlock);
  const hit = findBlockInTree(nextTop, blockId);
  if (!hit) return { ok: false, nextBlocks: nextTop };

  const normalized = cloneBlock({ ...nextBlock, id: blockId });
  if (hit.parent) {
    const parent = hit.parent;
    const nextChildren = asBlockArray(parent.children).map(cloneBlock);
    nextChildren[hit.index] = normalized;
    parent.children = nextChildren;
    return { ok: true, nextBlocks: nextTop };
  }

  nextTop[hit.index] = normalized;
  return { ok: true, nextBlocks: nextTop };
};

export const extractBlockText = (block: BlockLike): string => {
  const inline = Array.isArray(block.content) ? (block.content as Array<{ text?: unknown }>) : [];
  const text = inline.map((n) => (typeof n?.text === "string" ? n.text : "")).join("");
  return text.trim();
};

export const getBlocksFromDocumentContent = (content: unknown): BlockLike[] => {
  if (Array.isArray(content)) return asBlockArray(content);
  if (content && typeof content === "object") {
    const blocks = (content as { blocks?: unknown }).blocks;
    return asBlockArray(blocks);
  }
  return [];
};

export const withBlocksWrittenBack = (content: unknown, blocks: BlockLike[]): Json => {
  // 复用现有结构：数组或 {blocks: []}
  if (Array.isArray(content)) {
    return blocks as unknown as Json;
  }


if (content && typeof content === "object") {
    const blocks = (content as { blocks?: unknown }).blocks;
    return asBlockArray(blocks);
  }
  return [];
};

export const withBlocksWrittenBack = (content: unknown, blocks: BlockLike[]): Json => {
  // 复用现有结构：数组或 {blocks: []}
  if (Array.isArray(content)) {
    return blocks as unknown as Json;
  }
  if (content && typeof content === "object") {
    return { ...(content as Record<string, unknown>), blocks } as Json;
  }
  return { blocks } as Json;
};


/**
 * 全局常量定义
 * 集中管理项目中的魔法数字和硬编码值
 */

// ============================================
// AI Agent 相关常量
// ============================================

/** AI Agent 默认最大步数 */
export const DEFAULT_AGENT_MAX_STEPS = 10;

/** AI Agent 最大步数限制 */
export const MAX_AGENT_STEPS = 24;

/** AI Agent 最小步数 */
export const MIN_AGENT_STEPS = 1;

/** 客户端工具默认超时时间（毫秒） */
export const DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 60_000;

// ============================================
// 网络请求相关常量
// ============================================

/** OpenAI 兼容 API 最小超时（毫秒） */
export const MIN_API_TIMEOUT_MS = 500;

/** OpenAI 兼容 API 默认超时（毫秒） */
export const DEFAULT_API_TIMEOUT_MS = 20_000;

/** OpenAI 兼容 API 最大超时（毫秒） */
export const MAX_API_TIMEOUT_MS = 120_000;

/** OpenAI 最小输出 token 数 */
export const MIN_COMPLETION_TOKENS = 64;

/** OpenAI 最大输出 token 数 */
export const MAX_COMPLETION_TOKENS = 16_000;

// ============================================
// 编辑器相关常量
// ============================================

/** 内容加载延迟时间（毫秒） */
export const CONTENT_LOADING_DELAY_MS = 200;

/** 编辑器块标题最小级别 */
export const MIN_BLOCK_LEVEL = 1;

/** 编辑器块标题最大级别 */
export const MAX_BLOCK_LEVEL = 5;

/** 零延迟 setTimeout（用于将任务推入事件循环） */
export const ZERO_DELAY_MS = 0;

// ============================================
// 思维导图相关常量
// ============================================


export const MIN_BLOCK_LEVEL = 1;

/** 编辑器块标题最大级别 */
export const MAX_BLOCK_LEVEL = 5;

/** 零延迟 setTimeout（用于将任务推入事件循环） */
export const ZERO_DELAY_MS = 0;

// ============================================
// 思维导图相关常量
// ============================================

/** 思维导图最大附件数量 */
export const MAX_MINDMAP_ATTACHMENTS = 12;

/** 思维导图最大选中节点数 */
export const MAX_SELECTED_NODES = 6;

// ============================================
// RAG 搜索相关常量
// ============================================

/** RAG 默认搜索结果数量 */
export const DEFAULT_RAG_TOP_K = 12;

/** RAG 默认 chunk 结果数量 */
export const DEFAULT_RAG_CHUNK_TOP_K = 12;

/** 文档搜索默认结果数量 */
export const DEFAULT_DOCS_SEARCH_LIMIT = 12;

/** 文档读取默认最大字符数 */
export const DEFAULT_DOCS_READ_MAX_CHARS = 2500;

/** 文档获取默认最大块数 */
export const DEFAULT_DOC_GET_MAX_BLOCKS = 80;

/** 文档查找默认最大结果数 */
export const DEFAULT_DOC_FIND_MAX_RESULTS = 8;

// ============================================
// 资产/附件相关常量
// ============================================

/** 思维导图从资产转换最大项目数 */
export const DEFAULT_ASSET_TO_MINDMAP_MAX_ITEMS = 120;

/** 搜索默认结果数量 */
export const DEFAULT_SEARCH_COUNT = 6;

// ============================================
// UI 相关常量
// ============================================

/** 表格嵌入预览最小高度（像素） */
export const MIN_EMBED_HEIGHT = 120;

/** 表格嵌入预览最大高度（像素） */
export const MAX_EMBED_HEIGHT = 600;


export const DEFAULT_SEARCH_COUNT = 6;

// ============================================
// UI 相关常量
// ============================================

/** 表格嵌入预览最小高度（像素） */
export const MIN_EMBED_HEIGHT = 120;

/** 表格嵌入预览最大高度（像素） */
export const MAX_EMBED_HEIGHT = 600;

/** 表格嵌入预览默认高度（像素） */
export const DEFAULT_EMBED_HEIGHT = 300;

/** 上下文菜单距离窗口边缘的最小内边距（像素） */
export const CONTEXT_MENU_PADDING = 12;

// ============================================
// 工具函数
// ============================================

/**
 * 将数值限制在指定范围内
 * @param n 输入值
 * @param min 最小值
 * @param max 最大值
 * @returns 限制后的值
 */
export const clamp = (n: number, min: number, max: number): number =>
  Math.max(min, Math.min(max, n));


import type { Json } from "@/types/supabase";

/**
 * 提取文档内容中的块数组，兼容数组和 { blocks: [] } 两种结构。
 */
export const extractBlocksFromContent = (content: unknown): Json[] => {
  if (Array.isArray(content)) {
    return content as Json[];
  }
  if (content && typeof content === "object" && Array.isArray((content as { blocks?: Json[] }).blocks)) {
    return ((content as { blocks?: Json[] }).blocks ?? []) as Json[];
  }
  return [];
};

/**
 * 将新的块数组写回内容，保持原有结构额外字段。
 */
export const composeContentWithBlocks = (content: unknown, blocks: Json[]): Json => {
  if (Array.isArray(content)) {
    return blocks as Json;
  }
  if (content && typeof content === "object") {
    return {
      ...(content as Record<string, unknown>),
      blocks,
    } as Json;
  }
  return { blocks } as Json;
};


import { describe, expect, it } from "vitest";
import { collectSubtree } from "../../convex/_utils/documentTree";

describe("collectSubtree", () => {
  it("会收集根节点及其所有后代", () => {
    const rows = [
      { id: "A", parent_id: null },
      { id: "B", parent_id: "A" },
      { id: "C", parent_id: "B" },
      { id: "D", parent_id: "A" },
      { id: "E", parent_id: null },
    ] as const;

    const subtree = collectSubtree(rows, "A");
    expect(new Set(subtree.map((r) => r.id))).toEqual(new Set(["A", "B", "C", "D"]));
  });

  it("根节点不存在时返回空数组", () => {
    const rows = [{ id: "A", parent_id: null }] as const;
    expect(collectSubtree(rows, "missing")).toEqual([]);
  });

  it("存在环时不会死循环", () => {
    const rows = [
      { id: "A", parent_id: "B" },
      { id: "B", parent_id: "A" },
      { id: "C", parent_id: null },
    ] as const;

    const subtree = collectSubtree(rows, "A");
    expect(new Set(subtree.map((r) => r.id))).toEqual(new Set(["A", "B"]));
  });
});


export interface DocumentRecord {
  access_scope: "private" | "shared" | "public";
  id: string;
  workspace_id: string;
  title: string | null;
  parent_id: string | null;
  sort_order: number | null;
  is_starred: boolean | null;
  is_template: boolean;
  created_at: string;
  updated_at: string | null;
}

export interface DocumentNode extends DocumentRecord {
  children: DocumentNode[];
}

export function findBreadcrumb(records: DocumentRecord[], targetId: string): DocumentRecord[] {
  const map = new Map<string, DocumentRecord>();
  records.forEach((item) => map.set(item.id, item));
  const path: DocumentRecord[] = [];
  let current: DocumentRecord | undefined = map.get(targetId);

  while (current) {
    path.unshift(current);
    current = current.parent_id ? map.get(current.parent_id) : undefined;
  }

  return path;
}


/**
 * 侧边栏与编辑区之间的轻量事件总线
 */
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
export const ASSETS_RESTORED_EVENT = "wolai:assets-restored";

type AssetsChangedPayload = {
  docId?: string;
  asset?: unknown;
  assetIds?: string[];
  mindmapDeleted?: boolean;
  mindmapAssetIds?: string[];
};

type AssetsRestoredPayload =
  | { docId: string; kind: "media"; assetId: string; asset?: unknown }
  | { docId: string; kind: "mindmap"; mindmapId: string }
  | { docId: string; kind: "table"; tableId: string };

export function emitAssetsChanged(
  docId?: string,
  asset?: unknown,
  assetIds?: string[],
  mindmapDeleted?: boolean,
  mindmapAssetIds?: string[],
) {
  if (typeof window === "undefined") return;
  const detail: AssetsChangedPayload = { docId, asset, assetIds, mindmapDeleted, mindmapAssetIds };
  window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
}

export function emitAssetsRestored(payload: AssetsRestoredPayload) {
  if (typeof window === "undefined") return;
  window.dispatchEvent(new CustomEvent(ASSETS_RESTORED_EVENT, { detail: payload }));
}

export function emitDocumentsChanged(docId?: string) {
  if (typeof window === "undefined") return;
  window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
}


import type { DocumentRecord } from "@/lib/documents";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type {
  TreeProjectionAssetKind,
  TreeProjectionCapability,
  TreeProjectionItemBase,
  TreeProjectionNodeType,
  TreeProjectionResourceKind,
} from "@/lib/tree-protocol";
import type { MediaAsset } from "@/types/media";

export type KernelFileTreeProjectionRowKind =
  | "document"
  | "index"
  | "asset"
  | "asset_folder";

export type KernelFileTreeProjectionItem = TreeProjectionItemBase & {
  projectionKind: "file_tree";
  rowId: string;
  rowKind: KernelFileTreeProjectionRowKind;
};

export type KernelFileTreeProjectionEdge = {
  id: string;
  edgeType: "parent_of";
  workspaceId: string | null;
  fromNodeId: string;
  toNodeId: string;
};

export type KernelFileTreeIndexingVisibility = {
  schema: "mnote.file_tree.indexing_visibility";
  schemaVersion: 1;
  source: "kernel.project_view";
  status: "visible" | "stale" | "refreshing" | "unknown";
  requestKey: string | null;
  indexedResourceKinds: string[];
  visibleResourceKinds: string[];
  metrics: {
    visibleRows: number;
    visibleEdges: number;
  };
};

export type KernelFileTreeProjection = {
  projectionId: string;
  projection: "file_tree";
  rootNodeId: string | null;
  items: KernelFileTreeProjectionItem[];
  edges: KernelFileTreeProjectionEdge[];
  meta?: {
    search?: {
      query?: string | null;
      maxResults?: number | null;
      maxResultsRule?: "matches_only_before_ancestor_completion";
      ancestorCompletion?: "include_all_ancestors_after_match_truncation";
      ordering?: "kernel_file_tree_preorder";
      indexingVisibility?: KernelFileTreeIndexingVisibility;
    };


rootNodeId: string | null;
  items: KernelFileTreeProjectionItem[];
  edges: KernelFileTreeProjectionEdge[];
  meta?: {
    search?: {
      query?: string | null;
      maxResults?: number | null;
      maxResultsRule?: "matches_only_before_ancestor_completion";
      ancestorCompletion?: "include_all_ancestors_after_match_truncation";
      ordering?: "kernel_file_tree_preorder";
      indexingVisibility?: KernelFileTreeIndexingVisibility;
    };
  };
};

type BuildKernelFileTreeProjectionInput = {
  documents: DocumentRecord[];
  mediaAssets?: MediaAsset[] | null;
  mindmapAssets?: MediaAsset[] | null;
  tableAssets?: MediaAsset[] | null;
  mindmapAssetChildren?: Record<string, string[]> | null;
  rootNodeId?: string | null;
};

type NormalizedFileTreeAsset = {
  id: string;
  documentId: string;
  workspaceId: string | null;
  title: string;
  resourceKind: TreeProjectionResourceKind;
  assetKind: TreeProjectionAssetKind;
  iconHint: string;
  nodeType: TreeProjectionNodeType;
};

function readRowId(value: unknown): string | null {
  return typeof value === "string" && value.trim() ? value.trim() : null;
}

function toMillis(value: string | null | undefined): number {
  if (!value) return 0;
  const parsed = Date.parse(value);
  return Number.isFinite(parsed) ? parsed : 0;
}

function sortDocuments(records: DocumentRecord[]) {
  return [...records].sort((a, b) => {
    const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
    const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
    if (orderA !== orderB) {
      return orderA - orderB;
    }
    return toMillis(a.created_at) - toMillis(b.created_at);
  });
}

function dedupeDocuments(records: DocumentRecord[]) {
  const seen = new Set<string>();
  const unique: DocumentRecord[] = [];
  for (let index = records.length - 1; index >= 0; index -= 1) {


const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
    if (orderA !== orderB) {
      return orderA - orderB;
    }
    return toMillis(a.created_at) - toMillis(b.created_at);
  });
}

function dedupeDocuments(records: DocumentRecord[]) {
  const seen = new Set<string>();
  const unique: DocumentRecord[] = [];
  for (let index = records.length - 1; index >= 0; index -= 1) {
    const record = records[index]!;
    if (seen.has(record.id)) {
      continue;
    }
    seen.add(record.id);
    unique.push(record);
  }
  unique.reverse();
  return unique;
}

function buildDocumentChildren(records: DocumentRecord[]) {
  const childrenByParentId = new Map<string | null, DocumentRecord[]>();
  const recordIds = new Set(records.map((record) => record.id));

  for (const record of records) {
    const parentId =
      record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
    const bucket = childrenByParentId.get(parentId) ?? [];
    bucket.push(record);
    childrenByParentId.set(parentId, bucket);
  }

  for (const [parentId, bucket] of childrenByParentId.entries()) {
    childrenByParentId.set(parentId, sortDocuments(bucket));
  }

  return childrenByParentId;
}

function makeProjectionEdge(
  fromNodeId: string,
  toNodeId: string,
  workspaceId: string | null,
): KernelFileTreeProjectionEdge {
  return {
    id: `edge:${fromNodeId}:${toNodeId}:parent_of`,
    edgeType: "parent_of",
    workspaceId,
    fromNodeId,
    toNodeId,
  };
}

function buildDocumentCapabilities(childCount: number): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = [
    "open",
    "drag",


id: `edge:${fromNodeId}:${toNodeId}:parent_of`,
    edgeType: "parent_of",
    workspaceId,
    fromNodeId,
    toNodeId,
  };
}

function buildDocumentCapabilities(childCount: number): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = [
    "open",
    "drag",
    "drop",
    "select",
    "create-child",
    "rename",
    "archive",
    "restore",
    "context-menu",
    "reorder",
  ];
  if (childCount > 0) {
    capabilities.unshift("expand");
  }
  return capabilities;
}

function buildAssetFolderCapabilities(childCount: number): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = [
    "open-asset",
    "select",
    "context-menu",
  ];
  if (childCount > 0) {
    capabilities.unshift("expand");
  }
  return capabilities;
}

function buildLeafCapabilities(openAsset: boolean): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = ["select", "context-menu"];
  capabilities.unshift(openAsset ? "open-asset" : "open");
  return capabilities;
}

function classifyGenericAssetKind(asset: MediaAsset): TreeProjectionAssetKind {
  const assetType = String(asset.asset_type ?? "").trim().toLowerCase();
  const name = String(asset.file_name ?? "").trim().toLowerCase();
  const mimeType = String(asset.mime_type ?? "").trim().toLowerCase();
  const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";

  if (assetType === "mindmap") return "mindmap";
  if (assetType === "luckysheet") return "table";
  if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
  if (ext === "epub" || mimeType.includes("epub")) return "book";
  if (mimeType.startsWith("image/")) return "image";
  if (mimeType.startsWith("video/")) return "video";
  if (mimeType.startsWith("audio/")) return "audio";
  return "file";
}


const mimeType = String(asset.mime_type ?? "").trim().toLowerCase();
  const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";

  if (assetType === "mindmap") return "mindmap";
  if (assetType === "luckysheet") return "table";
  if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
  if (ext === "epub" || mimeType.includes("epub")) return "book";
  if (mimeType.startsWith("image/")) return "image";
  if (mimeType.startsWith("video/")) return "video";
  if (mimeType.startsWith("audio/")) return "audio";
  return "file";
}

function classifyResourceKind(assetKind: TreeProjectionAssetKind): TreeProjectionResourceKind {
  switch (assetKind) {
    case "mindmap":
      return "mindmap";
    case "table":
      return "table";
    case "book":
      return "book";
    case "pdf":
      return "pdf";
    default:
      return "asset";
  }
}

function classifyNodeType(assetKind: TreeProjectionAssetKind): TreeProjectionNodeType {
  switch (assetKind) {
    case "mindmap":
      return "mindmap";
    case "table":
      return "table";
    case "book":
      return "book";
    case "pdf":
      return "pdf";
    default:
      return "asset";
  }
}

function classifyIconHint(assetKind: TreeProjectionAssetKind): string {
  switch (assetKind) {
    case "mindmap":
      return "mindmap";
    case "table":
      return "table";
    case "book":
      return "book";
    case "pdf":
      return "pdf";
    case "image":
      return "image";
    case "video":
      return "video";
    case "audio":
      return "audio";
    default:


return "table";
    case "book":
      return "book";
    case "pdf":
      return "pdf";
    case "image":
      return "image";
    case "video":
      return "video";
    case "audio":
      return "audio";
    default:
      return "file";
  }
}

function normalizeAsset(asset: MediaAsset): NormalizedFileTreeAsset {
  const assetKind = classifyGenericAssetKind(asset);
  return {
    id: asset.id,
    documentId: asset.document_id,
    workspaceId: asset.workspace_id ?? null,
    title: asset.file_name?.trim() || "附件",
    resourceKind: classifyResourceKind(assetKind),
    assetKind,
    iconHint: classifyIconHint(assetKind),
    nodeType: classifyNodeType(assetKind),
  };
}

function buildAssetCollections(input: BuildKernelFileTreeProjectionInput) {
  const assetsByDoc = new Map<string, NormalizedFileTreeAsset[]>();
  const assetById = new Map<string, NormalizedFileTreeAsset>();
  const seen = new Set<string>();

  for (const asset of [
    ...(input.mediaAssets ?? []),
    ...(input.mindmapAssets ?? []),
    ...(input.tableAssets ?? []),
  ]) {
    if (!asset?.id || seen.has(asset.id)) {
      continue;
    }
    seen.add(asset.id);
    const normalized = normalizeAsset(asset);
    const bucket = assetsByDoc.get(normalized.documentId) ?? [];
    bucket.push(normalized);
    assetsByDoc.set(normalized.documentId, bucket);
    assetById.set(normalized.id, normalized);
  }

  const childAssetIdsByParentId = new Map<string, string[]>();
  Object.entries(input.mindmapAssetChildren ?? {}).forEach(([parentAssetId, childIds]) => {
    childAssetIdsByParentId.set(
      parentAssetId,
      (childIds ?? []).filter((childId) => typeof childId === "string" && childId.trim().length > 0),
    );
  });

  return {


assetById.set(normalized.id, normalized);
  }

  const childAssetIdsByParentId = new Map<string, string[]>();
  Object.entries(input.mindmapAssetChildren ?? {}).forEach(([parentAssetId, childIds]) => {
    childAssetIdsByParentId.set(
      parentAssetId,
      (childIds ?? []).filter((childId) => typeof childId === "string" && childId.trim().length > 0),
    );
  });

  return {
    assetsByDoc,
    assetById,
    childAssetIdsByParentId,
  };
}

function makeFallbackNode(item: KernelFileTreeProjectionItem): SidebarTreeNode {
  const documentId =
    item.resourceMeta.documentId ??
    (item.rowKind === "document" ? item.nodeId : item.parentNodeId ?? item.nodeId);
  return {
    access_scope: "private",
    id: documentId,
    workspace_id: item.resourceMeta.workspaceId ?? "",
    title: item.title,
    parent_id: item.rowKind === "document" ? item.parentNodeId : documentId,
    sort_order: item.position,
    is_starred: false,
    is_template: false,
    created_at: "",
    updated_at: null,
    children: [],
    kernel: {
      nodeType: "page",
      depth: item.depth,
      position: item.position,
      childCount: item.childCount,
      expandedByDefault: item.expandedByDefault,
    },
  };
}

function makeFallbackAsset(item: KernelFileTreeProjectionItem): MediaAsset {
  const assetKind = item.resourceMeta.assetKind ?? "unknown";
  const resourceKind = item.resourceMeta.resourceKind;
  const assetType =
    assetKind === "mindmap"
      ? "mindmap"
      : assetKind === "table" || resourceKind === "table"
        ? "luckysheet"
        : assetKind;
  return {
    id: item.resourceMeta.assetId ?? item.nodeId,
    workspace_id: item.resourceMeta.workspaceId ?? "",
    document_id: item.resourceMeta.documentId ?? "",
    asset_type: assetType,
    file_url: null,
    thumbnail_url: null,


assetKind === "mindmap"
      ? "mindmap"
      : assetKind === "table" || resourceKind === "table"
        ? "luckysheet"
        : assetKind;
  return {
    id: item.resourceMeta.assetId ?? item.nodeId,
    workspace_id: item.resourceMeta.workspaceId ?? "",
    document_id: item.resourceMeta.documentId ?? "",
    asset_type: assetType,
    file_url: null,
    thumbnail_url: null,
    bucket: null,
    storage_path: null,
    file_name: item.title,
    file_size: null,
    mime_type: null,
    ocr_payload: undefined,
    ocr_strategy: null,
    ocr_text: null,
    ocr_status: null,
    signed_url: null,
    created_at: "",
    updated_at: "",
  };
}

export function getDocIdFromFileTreeItem(item: KernelFileTreeProjectionItem): string {
  if (item.resourceMeta.documentId) {
    return item.resourceMeta.documentId;
  }
  if (item.rowKind === "document") {
    return item.nodeId;
  }
  const rowId = readRowId(item.rowId);
  if (rowId?.startsWith("index:")) {
    return rowId.slice("index:".length);
  }
  return item.parentNodeId ?? item.nodeId;
}

export function resolveFileTreeRowNode(
  item: KernelFileTreeProjectionItem,
  nodeById?: Map<string, SidebarTreeNode>,
): SidebarTreeNode {
  const documentId = getDocIdFromFileTreeItem(item);
  return nodeById?.get(documentId) ?? makeFallbackNode(item);
}

export function resolveFileTreeRowAsset(
  item: KernelFileTreeProjectionItem,
  assetById?: Map<string, MediaAsset>,
): MediaAsset {
  const assetId = item.resourceMeta.assetId ?? item.nodeId;
  return assetById?.get(assetId) ?? makeFallbackAsset(item);
}

export function isKernelFileTreeProjection(value: unknown): value is KernelFileTreeProjection {
  return (
    Boolean(value) &&


export function resolveFileTreeRowAsset(
  item: KernelFileTreeProjectionItem,
  assetById?: Map<string, MediaAsset>,
): MediaAsset {
  const assetId = item.resourceMeta.assetId ?? item.nodeId;
  return assetById?.get(assetId) ?? makeFallbackAsset(item);
}

export function isKernelFileTreeProjection(value: unknown): value is KernelFileTreeProjection {
  return (
    Boolean(value) &&
    typeof value === "object" &&
    (value as KernelFileTreeProjection).projection === "file_tree" &&
    Array.isArray((value as KernelFileTreeProjection).items) &&
    Array.isArray((value as KernelFileTreeProjection).edges)
  );
}

export function buildKernelFileTreeProjection(
  input: BuildKernelFileTreeProjectionInput,
): KernelFileTreeProjection {
  const uniqueDocuments = dedupeDocuments(input.documents);
  const documentById = new Map(uniqueDocuments.map((document) => [document.id, document]));
  const childrenByParentId = buildDocumentChildren(uniqueDocuments);
  const { assetsByDoc, assetById, childAssetIdsByParentId } = buildAssetCollections(input);
  const rootNodeId = input.rootNodeId?.trim() || null;
  const roots =
    rootNodeId && documentById.has(rootNodeId)
      ? [rootNodeId]
      : sortDocuments(
          uniqueDocuments.filter((document) => {
            const parentId = document.parent_id?.trim() || null;
            return !parentId || !documentById.has(parentId);
          }),
        ).map((document) => document.id);

  const items: KernelFileTreeProjectionItem[] = [];
  const edges: KernelFileTreeProjectionEdge[] = [];

  const walk = (documentId: string, depth: number) => {
    const document = documentById.get(documentId);
    if (!document) {
      return;
    }

    const childDocuments = childrenByParentId.get(documentId) ?? [];
    const documentAssets = assetsByDoc.get(documentId) ?? [];
    const nestedChildIds = new Set<string>();
    documentAssets.forEach((asset) => {
      const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
      childIds.forEach((childId) => nestedChildIds.add(childId));
    });
    const directAssets = documentAssets.filter((asset) => !nestedChildIds.has(asset.id));
    const childCount = 1 + directAssets.length + childDocuments.length;
    const parentNodeId = depth === 0 ? null : document.parent_id ?? null;
    const workspaceId = document.workspace_id ?? null;

    items.push({
      rowId: `doc:${document.id}`,


const nestedChildIds = new Set<string>();
    documentAssets.forEach((asset) => {
      const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
      childIds.forEach((childId) => nestedChildIds.add(childId));
    });
    const directAssets = documentAssets.filter((asset) => !nestedChildIds.has(asset.id));
    const childCount = 1 + directAssets.length + childDocuments.length;
    const parentNodeId = depth === 0 ? null : document.parent_id ?? null;
    const workspaceId = document.workspace_id ?? null;

    items.push({
      rowId: `doc:${document.id}`,
      rowKind: "document",
      nodeId: document.id,
      parentNodeId,
      nodeType: "page",
      projectionKind: "file_tree",
      title: document.title ?? "无标题",
      depth,
      position: document.sort_order ?? null,
      childCount,
      expandable: childCount > 0,
      expandedByDefault: true,
      capabilities: buildDocumentCapabilities(childCount),
      resourceMeta: {
        resourceKind: "document",
        documentId: document.id,
        workspaceId,
        iconHint: "page",
      },
      iconHint: "page",
    });

    const indexNodeId = `index:${document.id}`;
    items.push({
      rowId: indexNodeId,
      rowKind: "index",
      nodeId: indexNodeId,
      parentNodeId: document.id,
      nodeType: "index",
      projectionKind: "file_tree",
      title: "index.md",
      depth: depth + 1,
      position: 0,
      childCount: 0,
      expandable: false,
      expandedByDefault: false,
      capabilities: buildLeafCapabilities(false),
      resourceMeta: {
        resourceKind: "index",
        documentId: document.id,
        workspaceId,
        iconHint: "index",
      },
      iconHint: "index",
    });
    edges.push(makeProjectionEdge(document.id, indexNodeId, workspaceId));

    let assetPosition = 1;
    directAssets.forEach((asset) => {


resourceMeta: {
        resourceKind: "index",
        documentId: document.id,
        workspaceId,
        iconHint: "index",
      },
      iconHint: "index",
    });
    edges.push(makeProjectionEdge(document.id, indexNodeId, workspaceId));

    let assetPosition = 1;
    directAssets.forEach((asset) => {
      const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
      if (childIds.length > 0) {
        const folderNodeId = `asset-folder:${asset.id}`;
        items.push({
          rowId: folderNodeId,
          rowKind: "asset_folder",
          nodeId: folderNodeId,
          parentNodeId: document.id,
          nodeType: "mindmap",
          projectionKind: "file_tree",
          title: asset.title.replace(/\.json$/i, ""),
          depth: depth + 1,
          position: assetPosition,
          childCount: childIds.length,
          expandable: true,
          expandedByDefault: false,
          capabilities: buildAssetFolderCapabilities(childIds.length),
          resourceMeta: {
            resourceKind: asset.resourceKind,
            documentId: asset.documentId,
            assetId: asset.id,
            workspaceId: asset.workspaceId,
            assetKind: asset.assetKind,
            iconHint: "mindmap",
          },
          iconHint: "mindmap",
        });
        edges.push(makeProjectionEdge(document.id, folderNodeId, workspaceId));

        childIds.forEach((childAssetId, index) => {
          const childAsset = assetById.get(childAssetId);
          if (!childAsset) {
            return;
          }
          const childNodeId = `asset:${childAsset.id}`;
          items.push({
            rowId: childNodeId,
            rowKind: "asset",
            nodeId: childNodeId,
            parentNodeId: folderNodeId,
            nodeType: childAsset.nodeType,
            projectionKind: "file_tree",
            title: childAsset.title,
            depth: depth + 2,
            position: index,
            childCount: 0,
            expandable: false,
            expandedByDefault: false,


rowId: childNodeId,
            rowKind: "asset",
            nodeId: childNodeId,
            parentNodeId: folderNodeId,
            nodeType: childAsset.nodeType,
            projectionKind: "file_tree",
            title: childAsset.title,
            depth: depth + 2,
            position: index,
            childCount: 0,
            expandable: false,
            expandedByDefault: false,
            capabilities: buildLeafCapabilities(true),
            resourceMeta: {
              resourceKind: childAsset.resourceKind,
              documentId: childAsset.documentId,
              assetId: childAsset.id,
              workspaceId: childAsset.workspaceId,
              assetKind: childAsset.assetKind,
              iconHint: childAsset.iconHint,
            },
            iconHint: childAsset.iconHint,
          });
          edges.push(makeProjectionEdge(folderNodeId, childNodeId, workspaceId));
        });

        assetPosition += 1;
        return;
      }

      const assetNodeId = `asset:${asset.id}`;
      items.push({
        rowId: assetNodeId,
        rowKind: "asset",
        nodeId: assetNodeId,
        parentNodeId: document.id,
        nodeType: asset.nodeType,
        projectionKind: "file_tree",
        title: asset.title,
        depth: depth + 1,
        position: assetPosition,
        childCount: 0,
        expandable: false,
        expandedByDefault: false,
        capabilities: buildLeafCapabilities(true),
        resourceMeta: {
          resourceKind: asset.resourceKind,
          documentId: asset.documentId,
          assetId: asset.id,
          workspaceId: asset.workspaceId,
          assetKind: asset.assetKind,
          iconHint: asset.iconHint,
        },
        iconHint: asset.iconHint,
      });
      edges.push(makeProjectionEdge(document.id, assetNodeId, workspaceId));
      assetPosition += 1;
    });

    childDocuments.forEach((childDocument) => {


assetId: asset.id,
          workspaceId: asset.workspaceId,
          assetKind: asset.assetKind,
          iconHint: asset.iconHint,
        },
        iconHint: asset.iconHint,
      });
      edges.push(makeProjectionEdge(document.id, assetNodeId, workspaceId));
      assetPosition += 1;
    });

    childDocuments.forEach((childDocument) => {
      walk(childDocument.id, depth + 1);
    });
  };

  roots.forEach((documentId) => walk(documentId, 0));

  return {
    projectionId: `kernel_projection:file_tree:${rootNodeId ?? "root"}`,
    projection: "file_tree",
    rootNodeId,
    items,
    edges,
    meta: {
      search: {
        query: null,
        maxResults: null,
        maxResultsRule: "matches_only_before_ancestor_completion",
        ancestorCompletion: "include_all_ancestors_after_match_truncation",
        ordering: "kernel_file_tree_preorder",
        indexingVisibility: {
          schema: "mnote.file_tree.indexing_visibility",
          schemaVersion: 1,
          source: "kernel.project_view",
          status: "visible",
          requestKey: null,
          indexedResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
          visibleResourceKinds: ["document", "index", "asset", "asset_folder", "mindmap", "book", "pdf"],
          metrics: {
            visibleRows: items.length,
            visibleEdges: edges.length,
          },
        },
      },
    },
  };
}


import type { DocumentRecord } from "@/lib/documents";
import type {
  TreeProjectionCapability,
  TreeProjectionResourceMeta,
} from "@/lib/tree-protocol";

export type KernelSidebarProjectionEdge = {
  id: string;
  edgeType: "parent_of";
  workspaceId: string | null;
  fromNodeId: string;
  toNodeId: string;
};

export type KernelSidebarProjectionItem = {
  nodeId: string;
  parentNodeId: string | null;
  nodeType: "page";
  projectionKind: "sidebar_tree";
  title: string | null;
  depth: number;
  position: number | null;
  childCount: number;
  expandable: boolean;
  expandedByDefault: boolean;
  capabilities: TreeProjectionCapability[];
  resourceMeta: TreeProjectionResourceMeta;
  iconHint: string | null;
};

export type KernelSidebarProjection = {
  projectionId: string;
  projection: "sidebar_tree";
  rootNodeId: string | null;
  items: KernelSidebarProjectionItem[];
  edges: KernelSidebarProjectionEdge[];
};

export type SidebarTreeNode = DocumentRecord & {
  children: SidebarTreeNode[];
  kernel?: {
    nodeType: "page";
    depth: number;
    position: number | null;
    childCount: number;
    expandedByDefault: boolean;
  };
};

function sortRecords(records: DocumentRecord[]) {
  return [...records].sort((a, b) => {
    const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
    const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
    if (orderA !== orderB) {
      return orderA - orderB;
    }
    return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
  });
}


function sortRecords(records: DocumentRecord[]) {
  return [...records].sort((a, b) => {
    const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
    const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
    if (orderA !== orderB) {
      return orderA - orderB;
    }
    return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
  });
}

function dedupeRecords(records: DocumentRecord[]) {
  const seen = new Set<string>();
  const unique: DocumentRecord[] = [];
  for (let index = records.length - 1; index >= 0; index -= 1) {
    const record = records[index]!;
    if (seen.has(record.id)) {
      continue;
    }
    seen.add(record.id);
    unique.push(record);
  }
  unique.reverse();
  return unique;
}

function buildSidebarCapabilities(childCount: number): TreeProjectionCapability[] {
  const capabilities: TreeProjectionCapability[] = [
    "open",
    "drag",
    "drop",
    "select",
    "create-child",
    "rename",
    "archive",
    "restore",
    "context-menu",
    "reorder",
  ];
  if (childCount > 0) {
    capabilities.unshift("expand");
  }
  return capabilities;
}

function buildChildrenByParent(records: DocumentRecord[]) {
  const childrenByParentId = new Map<string | null, DocumentRecord[]>();
  const recordIds = new Set(records.map((record) => record.id));
  for (const record of records) {
    const parentId =
      record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
    const bucket = childrenByParentId.get(parentId) ?? [];
    bucket.push(record);
    childrenByParentId.set(parentId, bucket);
  }
  for (const [parentId, bucket] of childrenByParentId.entries()) {
    childrenByParentId.set(parentId, sortRecords(bucket));
  }
  return childrenByParentId;


const recordIds = new Set(records.map((record) => record.id));
  for (const record of records) {
    const parentId =
      record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
    const bucket = childrenByParentId.get(parentId) ?? [];
    bucket.push(record);
    childrenByParentId.set(parentId, bucket);
  }
  for (const [parentId, bucket] of childrenByParentId.entries()) {
    childrenByParentId.set(parentId, sortRecords(bucket));
  }
  return childrenByParentId;
}

export function buildKernelSidebarProjection(
  records: DocumentRecord[],
): KernelSidebarProjection {
  const uniqueRecords = dedupeRecords(records);
  const recordById = new Map(uniqueRecords.map((record) => [record.id, record]));
  const childrenByParentId = buildChildrenByParent(uniqueRecords);
  const items: KernelSidebarProjectionItem[] = [];
  const edges: KernelSidebarProjectionEdge[] = [];
  const visited = new Set<string>();

  const walk = (parentId: string | null, depth: number) => {
    const children = childrenByParentId.get(parentId) ?? [];
    for (const child of children) {
      if (visited.has(child.id)) {
        continue;
      }
      visited.add(child.id);
      const childNodes = childrenByParentId.get(child.id) ?? [];
      items.push({
        nodeId: child.id,
        parentNodeId: parentId,
        nodeType: "page",
        projectionKind: "sidebar_tree",
        title: child.title ?? "无标题",
        depth,
        position: child.sort_order ?? null,
        childCount: childNodes.length,
        expandable: childNodes.length > 0,
        expandedByDefault: true,
        capabilities: buildSidebarCapabilities(childNodes.length),
        resourceMeta: {
          resourceKind: "document",
          documentId: child.id,
          workspaceId: child.workspace_id ?? null,
          iconHint: "page",
        },
        iconHint: "page",
      });
      if (parentId && recordById.has(parentId)) {
        edges.push({
          id: `edge:${parentId}:${child.id}:parent_of`,
          edgeType: "parent_of",
          workspaceId: child.workspace_id ?? null,
          fromNodeId: parentId,
          toNodeId: child.id,
        });


iconHint: "page",
        },
        iconHint: "page",
      });
      if (parentId && recordById.has(parentId)) {
        edges.push({
          id: `edge:${parentId}:${child.id}:parent_of`,
          edgeType: "parent_of",
          workspaceId: child.workspace_id ?? null,
          fromNodeId: parentId,
          toNodeId: child.id,
        });
      }
      walk(child.id, depth + 1);
    }
  };

  walk(null, 0);

  return {
    projectionId: "kernel_projection:sidebar_tree:workspace_root",
    projection: "sidebar_tree",
    rootNodeId: null,
    items,
    edges,
  };
}

export function buildSidebarTreeFromKernelProjection(input: {
  records: DocumentRecord[];
  projection: KernelSidebarProjection;
}): SidebarTreeNode[] {
  const projectionItems = Array.isArray(input.projection?.items)
    ? input.projection.items
    : [];
  const recordById = new Map(input.records.map((record) => [record.id, record]));
  const nodeMap = new Map<string, SidebarTreeNode>();
  const itemById = new Map(projectionItems.map((item) => [item.nodeId, item]));

  for (const item of projectionItems) {
    const record = recordById.get(item.nodeId);
    nodeMap.set(item.nodeId, {
      access_scope: record?.access_scope ?? "private",
      id: item.nodeId,
      workspace_id: record?.workspace_id ?? "",
      title: record?.title ?? item.title ?? "无标题",
      parent_id: record?.parent_id ?? item.parentNodeId,
      sort_order: record?.sort_order ?? item.position,
      is_starred: record?.is_starred ?? false,
      is_template: record?.is_template ?? false,
      created_at: record?.created_at ?? "",
      updated_at: record?.updated_at ?? null,
      children: [],
      kernel: {
        nodeType: item.nodeType,
        depth: item.depth,
        position: item.position,
        childCount: item.childCount,
        expandedByDefault: item.expandedByDefault,
      },


is_starred: record?.is_starred ?? false,
      is_template: record?.is_template ?? false,
      created_at: record?.created_at ?? "",
      updated_at: record?.updated_at ?? null,
      children: [],
      kernel: {
        nodeType: item.nodeType,
        depth: item.depth,
        position: item.position,
        childCount: item.childCount,
        expandedByDefault: item.expandedByDefault,
      },
    });
  }

  const roots: SidebarTreeNode[] = [];
  for (const item of projectionItems) {
    const node = nodeMap.get(item.nodeId);
    if (!node) continue;
    const parentId = item.parentNodeId;
    if (parentId && nodeMap.has(parentId)) {
      nodeMap.get(parentId)!.children.push(node);
      continue;
    }
    roots.push(node);
  }

  const sortTree = (nodes: SidebarTreeNode[]) => {
    nodes.sort((a, b) => {
      const orderA = a.kernel?.position ?? a.sort_order ?? Number.MAX_SAFE_INTEGER;
      const orderB = b.kernel?.position ?? b.sort_order ?? Number.MAX_SAFE_INTEGER;
      if (orderA !== orderB) {
        return orderA - orderB;
      }
      return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
    });
    nodes.forEach((node) => sortTree(node.children));
  };

  sortTree(roots);

  return roots;
}


import { DocumentTable, DocumentTableSnapshot, TableRowData, TableSchema } from "@/types/online-table";

// 默认表格结构：全部为空列，交由用户/全屏自行定义
export const DEFAULT_TABLE_SCHEMA: TableSchema = {
  columns: [],
  frozenRowCount: 0,
  frozenColCount: 0,
};

export const DEFAULT_TABLE_ROWS = 50;
export const DEFAULT_TABLE_COLUMNS = 15;

export const createDefaultTableSnapshot = (schema: TableSchema): DocumentTableSnapshot => {
  const frozenRowCount = schema.frozenRowCount ?? 0;
  const frozenColCount = schema.frozenColCount ?? 0;
  return {
    rows: [],
    luckysheet: [
      {
        name: "Sheet1",
        index: 0,
        status: 1,
        order: 0,
        hide: 0,
        row: DEFAULT_TABLE_ROWS,
        column: DEFAULT_TABLE_COLUMNS,
        defaultRowHeight: 19,
        defaultColWidth: 73,
        celldata: [],
        config: {
          columnlen: {},
          rowlen: {},
        },
        frozen: {
          type: frozenRowCount > 0 || frozenColCount > 0 ? "both" : undefined,
          row_focus: frozenRowCount,
          column_focus: frozenColCount,
        },
        scrollLeft: 0,
        scrollTop: 0,
        zoomRatio: 1,
        showGridLines: true,
      },
    ],
  };
};

/**
 * 在 Supabase 中创建一个新的在线表格并返回元数据。
 * @param documentId 当前文档的 ID
 * @param title 表格的初始标题
 * @returns 新创建的 DocumentTable
 */
export async function createOnlineTable(
  documentId: string,
  title: string = "未命名表格"
): Promise<DocumentTable> {
  // 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
  const response = await fetch("/api/tables/create", {
    method: "POST",


* 在 Supabase 中创建一个新的在线表格并返回元数据。
 * @param documentId 当前文档的 ID
 * @param title 表格的初始标题
 * @returns 新创建的 DocumentTable
 */
export async function createOnlineTable(
  documentId: string,
  title: string = "未命名表格"
): Promise<DocumentTable> {
  // 假设 Next.js API 路由 /api/tables/create 负责与 Supabase 交互
  const response = await fetch("/api/tables/create", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      documentId,
      title,
      schema: DEFAULT_TABLE_SCHEMA,
      snapshot: createDefaultTableSnapshot(DEFAULT_TABLE_SCHEMA),
    }),
  });

  if (!response.ok) {
    throw new Error("Failed to create online table.");
  }

  const newTable: DocumentTable = await response.json();
  return newTable;
}

/**
 * 获取表格元数据 (用于紧凑模式渲染)
 * 实际实现中，这可能需要一个更复杂的获取逻辑，例如同时获取前 N 行数据
 */
export async function getDocumentTable(tableId: string): Promise<DocumentTable> {
  const response = await fetch(`/api/tables/${tableId}`, {
    method: "GET",
    headers: { "Content-Type": "application/json" },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch document table.");
  }

  return response.json() as Promise<DocumentTable>;
}

/**
 * 将表格的 snapshot/行数据回写 Supabase，保持内联与全屏视图一致。
 */
export async function saveOnlineTable(
  tableId: string,
  payload: {
    title?: string;
    schema?: TableSchema;
    snapshot?: DocumentTableSnapshot | null;
    rows?: TableRowData[];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    viewPreferences?: Record<string, any>;


/**
 * 将表格的 snapshot/行数据回写 Supabase，保持内联与全屏视图一致。
 */
export async function saveOnlineTable(
  tableId: string,
  payload: {
    title?: string;
    schema?: TableSchema;
    snapshot?: DocumentTableSnapshot | null;
    rows?: TableRowData[];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    viewPreferences?: Record<string, any>;
  },
): Promise<DocumentTable> {
  const response = await fetch(`/api/tables/${tableId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  if (!response.ok) {
    const message = await response.text().catch(() => "");
    throw new Error(message || "Failed to save online table.");
  }

  return response.json() as Promise<DocumentTable>;
}

/**
 * 删除表格及其行数据。
 */
export async function deleteOnlineTable(tableId: string): Promise<void> {
  const response = await fetch(`/api/tables/${tableId}`, {
    method: "DELETE",
    headers: { "Content-Type": "application/json" },
  });

  if (!response.ok) {
    throw new Error("Failed to delete online table.");
  }
}


import { afterEach, describe, expect, it, vi } from "vitest";
import {
  getMnotePublicRuntimeConfig,
  getMnoteRuntimeConfig,
} from "@/lib/runtime-config";

describe("runtime-config public projection", () => {
  it("不再暴露 legacy mnote-web runtime 字段", () => {
    const runtime = getMnotePublicRuntimeConfig();
    expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
    expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
  });

  it("允许通过新 runtime 配置显式选择树 renderer family", () => {
    window.__MNOTE_RUNTIME_CONFIG__ = {
      treeRendererFamily: "rust_family",
    };

    const runtime = getMnoteRuntimeConfig();

    expect(runtime.treeRendererFamily).toBe("rust_family");
  });

  it("树 renderer family 别名应归一到 rust_family", () => {
    window.__MNOTE_RUNTIME_CONFIG__ = {
      treeRendererFamily: "tree_shell" as "rust_family",
    };

    const runtime = getMnoteRuntimeConfig();

    expect(runtime.treeRendererFamily).toBe("rust_family");
  });

  it("树 renderer family 缺省时应回落到 rust_family", () => {
    const runtime = getMnoteRuntimeConfig();

    expect(runtime.treeRendererFamily).toBe("rust_family");
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
    delete process.env.MNOTE_WEB_BASE_URL;
    delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
    delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
    delete process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY;
    delete process.env.TREE_RENDERER_FAMILY;
    delete window.__MNOTE_RUNTIME_CONFIG__;
  });

  it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
    process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
    process.env.MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
    process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED = "1";
    process.env.MNOTE_WEB_TREE_SHELL_ENABLED = "1";

    const runtime = getMnoteRuntimeConfig();

    expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
    expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);


});

  it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
    process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
    process.env.MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
    process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED = "1";
    process.env.MNOTE_WEB_TREE_SHELL_ENABLED = "1";

    const runtime = getMnoteRuntimeConfig();

    expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
    expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
  });
});


export type MnoteRuntimeConfig = {
  /**
   * 是否启用 Convex（自部署）链路。
   * 说明：该字段由服务端在运行期注入到 window.__MNOTE_RUNTIME_CONFIG__，用于客户端按需关闭 Supabase 相关能力。
   */
  useConvex?: boolean;
  supabaseUrl?: string;
  /**
   * 服务端/本机回源用的 Supabase 地址（通常是 HTTP），用于避免 FRP/自签证书导致 Node 侧 TLS 校验失败。
   * 说明：该字段会出现在 public/mnote-env.json 中，但它主要给服务端读取使用。
   */
  supabaseInternalUrl?: string;
  supabaseAnonKey?: string;
  backendUrl?: string;
  onlyofficeBaseUrl?: string;
  onlyofficeBaseUrlWeb?: string;
  onlyofficeBaseUrlDesktop?: string;
  onlyofficeStorageHostOverride?: string;
  onlyofficeStorageHostOverrideWeb?: string;
  onlyofficeStorageHostOverrideDesktop?: string;
  cloudflareAppOrigin?: string;
  onlyofficeProxyOriginWeb?: string;
  onlyofficeCallbackOriginWeb?: string;
  onlyofficeCallbackOriginDesktop?: string;
  /**
   * 编辑器 host 选择。
   * 说明：文档页正式主链只保留 leptos_tiptap_island。
   */
  documentEditorHost?: "leptos_tiptap_island";
  /**
   * 树域 renderer family 选择。
   * 说明：默认主路径已切到 rust_family；React fallback 仍作为过渡兜底保留。
   */
  treeRendererFamily?: "react" | "rust_family";
  /**
   * 是否为桌面端（Electron）运行。
   */
  isDesktop?: boolean;
  /**
   * ONLYOFFICE 文档服务器拉取 document.url 时，如需使用 /api/onlyoffice/proxy
   * 规避 signedUrl 的 token 参数冲突，可把代理地址指向一个“文档服务器可访问”的公网 Origin。
   *
   * - 桌面端本地运行（window.location.origin）：通常可不配置
   * - 通过 Cloudflare Tunnel 使用远端 ONLYOFFICE：建议配置为 https://app.<你的域名>
   */
  onlyofficeProxyOrigin?: string;
  onlyofficeCallbackOrigin?: string;
};

export type MnotePublicRuntimeConfig = MnoteRuntimeConfig;

declare global {
  interface Window {
    __MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
  }
}

const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
  if (typeof value === "boolean") {
    return value;


export type MnotePublicRuntimeConfig = MnoteRuntimeConfig;

declare global {
  interface Window {
    __MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
  }
}

const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
  if (typeof value === "boolean") {
    return value;
  }
  if (typeof value !== "string") {
    return undefined;
  }
  const normalized = value.trim().toLowerCase();
  if (!normalized) {
    return undefined;
  }
  if (["1", "true", "yes", "on"].includes(normalized)) {
    return true;
  }
  if (["0", "false", "no", "off"].includes(normalized)) {
    return false;
  }
  return undefined;
};

const parseDocumentEditorHost = (
  value: unknown,
): MnoteRuntimeConfig["documentEditorHost"] | undefined => {
  if (typeof value !== "string") {
    return undefined;
  }
  const normalized = value.trim().toLowerCase();
  if (!normalized) {
    return undefined;
  }
  if (
    normalized === "leptos_tiptap_island" ||
    normalized === "leptos_tiptap_runtime" ||
    normalized === "leptos_tiptap_inline" ||
    normalized === "leptos_tiptap"
  ) {
    return "leptos_tiptap_island";
  }
  return undefined;
};

const parseTreeRendererFamily = (
  value: unknown,
): MnoteRuntimeConfig["treeRendererFamily"] | undefined => {
  if (typeof value !== "string") {
    return undefined;
  }
  const normalized = value.trim().toLowerCase();
  if (!normalized) {
    return undefined;
  }


};

const parseTreeRendererFamily = (
  value: unknown,
): MnoteRuntimeConfig["treeRendererFamily"] | undefined => {
  if (typeof value !== "string") {
    return undefined;
  }
  const normalized = value.trim().toLowerCase();
  if (!normalized) {
    return undefined;
  }
  if (normalized === "rust_family" || normalized === "rust" || normalized === "tree_shell") {
    return "rust_family";
  }
  if (normalized === "react") {
    return "react";
  }
  return undefined;
};

function getServerNodeBuiltin<T>(moduleName: string): T | null {
  if (typeof window !== "undefined") {
    return null;
  }

  const processWithBuiltin = process as NodeJS.Process & {
    getBuiltinModule?: (id: string) => unknown;
  };
  if (typeof processWithBuiltin.getBuiltinModule === "function") {
    const builtIn = processWithBuiltin.getBuiltinModule(moduleName);
    if (builtIn) {
      return builtIn as T;
    }
  }

  try {
    // 说明：某些 Node/打包环境仍保留 CommonJS require，这里只作为兜底。
    const runtimeRequire = new Function(
      'return typeof require === "function" ? require : null',
    )() as NodeJS.Require | null;
    if (runtimeRequire) {
      return runtimeRequire(moduleName) as T;
    }
  } catch {
    return null;
  }

  return null;
}

const readFromEnv = (): MnoteRuntimeConfig => ({
  useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
  supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
  supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
  supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
  ...(parseDocumentEditorHost(
    process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
      process.env.DOCUMENT_EDITOR_HOST,


return null;
}

const readFromEnv = (): MnoteRuntimeConfig => ({
  useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
  supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
  supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
  supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
  backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
  ...(parseDocumentEditorHost(
    process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
      process.env.DOCUMENT_EDITOR_HOST,
  ) !== undefined
    ? {
        documentEditorHost: parseDocumentEditorHost(
          process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
            process.env.DOCUMENT_EDITOR_HOST,
        ),
      }
    : {}),
  ...(parseTreeRendererFamily(
    process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
      process.env.TREE_RENDERER_FAMILY,
  ) !== undefined
    ? {
        treeRendererFamily: parseTreeRendererFamily(
          process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
            process.env.TREE_RENDERER_FAMILY,
        ),
      }
    : {}),
  onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
  onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
  onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
  onlyofficeCallbackOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_CALLBACK_ORIGIN,
  cloudflareAppOrigin: process.env.NEXT_PUBLIC_CLOUDFLARE_APP_ORIGIN,
});

const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
  if (typeof window !== "undefined") return {};
  try {
    // 说明：桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
    // 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
    // 网页端运行时 process.cwd() 通常为 wolai-frontend。

    // 说明：这里不能直接静态引入 `node:fs` / `node:path`，
    // 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
    // Node 22 优先走 `process.getBuiltinModule`，其余环境再兜底到 runtime require。
    const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
    const path = getServerNodeBuiltin<typeof import("path")>("node:path");
    if (!fs || !path) {
      return {};
    }

    // 说明：Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`，
    // 导致 process.cwd() 变成 `.next/standalone`，此时 public/mnote-env.json 位于上层目录。
    // 这里向上查找多级目录，确保能读取到真正的 public/mnote-env.json。
    const candidates: string[] = [];
    let dir = process.cwd();
    for (let i = 0; i < 6; i += 1) {


const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
    const path = getServerNodeBuiltin<typeof import("path")>("node:path");
    if (!fs || !path) {
      return {};
    }

    // 说明：Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`，
    // 导致 process.cwd() 变成 `.next/standalone`，此时 public/mnote-env.json 位于上层目录。
    // 这里向上查找多级目录，确保能读取到真正的 public/mnote-env.json。
    const candidates: string[] = [];
    let dir = process.cwd();
    for (let i = 0; i < 6; i += 1) {
      candidates.push(path.join(dir, "public", "mnote-env.json"));
      const next = path.dirname(dir);
      if (next === dir) break;
      dir = next;
    }
    const existing = candidates.filter((p) => fs.existsSync(p));
    if (existing.length === 0) return {};

    // 说明：standalone 产物可能包含 `.next/standalone/public/mnote-env.json`，但它通常是构建时拷贝，
    // 用户更希望修改“项目目录下”的 public/mnote-env.json 即可生效。
    // 因此这里优先选择不在 `.next` 目录下的配置文件。
    const isInNextDir = (p: string) => p.split(path.sep).includes(".next");
    const filePath = existing.find((p) => !isInNextDir(p)) ?? existing[0];

    const raw = fs.readFileSync(filePath, { encoding: "utf8" });
    const parsed = JSON.parse(raw) as unknown;
    if (!parsed || typeof parsed !== "object") return {};
    return parsed as Partial<MnoteRuntimeConfig>;
  } catch {
    return {};
  }
};

const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig => {
  const isDesktop =
    cfg.isDesktop ??
    (typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);

  const onlyofficeBaseUrl = isDesktop
    ? cfg.onlyofficeBaseUrlDesktop ||
      cfg.onlyofficeBaseUrl ||
      cfg.onlyofficeBaseUrlWeb ||
      ""
    : cfg.onlyofficeBaseUrlWeb ||
      cfg.onlyofficeBaseUrl ||
      cfg.onlyofficeBaseUrlDesktop ||
      "";

  const onlyofficeStorageHostOverride = isDesktop
    ? (cfg.onlyofficeStorageHostOverrideDesktop ??
        cfg.onlyofficeStorageHostOverride ??
        cfg.onlyofficeStorageHostOverrideWeb ??
        "")
    : (cfg.onlyofficeStorageHostOverrideWeb ??
        cfg.onlyofficeStorageHostOverride ??
        cfg.onlyofficeStorageHostOverrideDesktop ??
        "");


"";

  const onlyofficeStorageHostOverride = isDesktop
    ? (cfg.onlyofficeStorageHostOverrideDesktop ??
        cfg.onlyofficeStorageHostOverride ??
        cfg.onlyofficeStorageHostOverrideWeb ??
        "")
    : (cfg.onlyofficeStorageHostOverrideWeb ??
        cfg.onlyofficeStorageHostOverride ??
        cfg.onlyofficeStorageHostOverrideDesktop ??
        "");

  const onlyofficeProxyOrigin = isDesktop
    ? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeProxyOriginWeb)
    : (cfg.onlyofficeProxyOriginWeb ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeCallbackOriginDesktop);

  const onlyofficeCallbackOrigin = isDesktop
    ? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
    : (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);

  const documentEditorHost =
    parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
  const treeRendererFamily =
    parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";

  return {
    ...cfg,
    isDesktop,
    documentEditorHost,
    treeRendererFamily,
    onlyofficeBaseUrl,
    onlyofficeStorageHostOverride,
    onlyofficeProxyOrigin,
    onlyofficeCallbackOrigin,
  };
};

export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
  if (typeof window !== "undefined") {
    // 说明：浏览器侧禁止再兜底读取 NEXT_PUBLIC_MNOTE_WEB_BASE_URL / MNOTE_WEB_BASE_URL，
    // 否则会把 internal-only 的 mnote-web 边界重新泄漏回客户端。
    // 客户端只信任服务端注入的 public runtime 配置。
    return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? {});
  }
  const isDesktop = process.env.MNOTE_DESKTOP === "1";
  const publicRuntime = readFromPublicJson();
  const envRuntime = readFromEnv();
  // 说明：桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
  // Web 端开发时则应优先使用环境变量（例如本机 http://127.0.0.1:18000），避免被
  // public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
  const merged: MnoteRuntimeConfig = isDesktop
    ? {
        ...envRuntime,
        ...publicRuntime,
        isDesktop,
      }
    : {
        ...publicRuntime,
        ...envRuntime,
      };


// Web 端开发时则应优先使用环境变量（例如本机 http://127.0.0.1:18000），避免被
  // public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
  const merged: MnoteRuntimeConfig = isDesktop
    ? {
        ...envRuntime,
        ...publicRuntime,
        isDesktop,
      }
    : {
        ...publicRuntime,
        ...envRuntime,
      };
  return normalizeRuntimeConfig(merged);
}

export function getMnotePublicRuntimeConfig(): MnotePublicRuntimeConfig {
  return getMnoteRuntimeConfig();
}


import { describe, expect, it } from "vitest";
import { RUST_WEB_MAIN_EXECUTION_BOUNDARY } from "./rust-web-main-execution-boundary";

describe("RUST_WEB_MAIN_EXECUTION_BOUNDARY", () => {
  it("固定 3000 公开入口由 mnote-web 拥有，Next 只能作为 legacy compat", () => {
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.publicEntry).toBe("3000");
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.mainWebOwner).toBe("mnote-web");
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.legacyCompatOwner).toBe("next-app-router");
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).not.toContain("main_web_owner");
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.forbiddenNextSemantics).toContain("主 Web gateway owner");
  });

  it("把主 Web 执行面和允许保留的 React island / legacy bundle 明确分层", () => {
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.rustOwnedPlanes).toEqual(
      expect.arrayContaining([
        "web_gateway",
        "document_shell",
        "page_aggregate_transport",
        "tree_realtime_stream",
        "search_shell",
        "ai_bridge_transport",
        "mindmap_object_shell",
      ]),
    );
    expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).toEqual([
      "legacy_react_bundle",
      "legacy_app_router_compat",
      "island_asset_source_until_migrated",
    ]);
  });
});


export const RUST_WEB_MAIN_EXECUTION_BOUNDARY = {
  schema: "mnote.rust_web.main_execution_boundary.v1",
  publicEntry: "3000",
  mainWebOwner: "mnote-web",
  legacyCompatOwner: "next-app-router",
  rustOwnedPlanes: [
    "web_gateway",
    "runtime_config",
    "auth_session_handoff",
    "document_shell",
    "page_aggregate_transport",
    "tree_projection_transport",
    "tree_command_transport",
    "tree_realtime_stream",
    "search_shell",
    "ai_bridge_transport",
    "mindmap_object_shell",
  ],
  nextAllowedDuties: [
    "legacy_react_bundle",
    "legacy_app_router_compat",
    "island_asset_source_until_migrated",
  ],
  forbiddenNextSemantics: [
    "主 Web gateway owner",
    "页面事实源",
    "树排序 canonical plan",
    "Page Aggregate command family owner",
    "tree realtime event contract owner",
    "搜索事实源",
    "AI tool protocol owner",
  ],
} as const;

export type RustWebMainExecutionBoundary = typeof RUST_WEB_MAIN_EXECUTION_BOUNDARY;


"use server";

import { cookies } from "next/headers";

type RequestCookies = Awaited<ReturnType<typeof cookies>>;

const decodeValue = (value?: string) => {
  if (!value) return value;
  let v = value;
  // 说明：部分环境下 cookies() 读取到的值仍是 URL 编码（例如 %5B%22...%22%5D），
  // Supabase Auth Helpers 期望拿到可直接 JSON.parse 的字符串，因此这里做一次解码。
  // 若不是合法的 URL 编码字符串，decodeURIComponent 会抛错，我们直接兜底返回原值。
  if (v.includes("%")) {
    try {
      v = decodeURIComponent(v);
    } catch {
      // ignore
    }
  }
  return v.startsWith("base64-") ? Buffer.from(v.slice(7), "base64").toString("utf8") : v;
};

const encodeValue = (value: string) => {
  if (!value) return value;
  // Next.js 会自动 base64 编码，我们只需处理已有 base64 前缀的情况
  if (value.startsWith("base64-")) {
    return value;
  }
  return value;
};

const wrapCookies = (store: RequestCookies) => {
  return {
    get: (name: string) => {
      const cookie = store.get(name);
      if (!cookie) return cookie;
      return { ...cookie, value: decodeValue(cookie.value) };
    },
    getAll: (...args: Parameters<RequestCookies["getAll"]>) =>
      store.getAll(...args).map((cookie) => ({ ...cookie, value: decodeValue(cookie.value) })),
    set: (...args: Parameters<RequestCookies["set"]>) => {
      const [name, value, options] = args as unknown as [
        unknown,
        unknown,
        unknown,
      ];
      if (typeof name === "string" && typeof value === "string") {
        (store as any).set(name, encodeValue(value), options as any);
        return;
      }
      (store as any).set(...(args as any));
    },
    delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
    has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
  };
};

export const getDecodedCookies = async () => {
  const store = await cookies();
  return wrapCookies(store) as RequestCookies;


return;
      }
      (store as any).set(...(args as any));
    },
    delete: (...args: Parameters<RequestCookies["delete"]>) => store.delete(...args),
    has: (...args: Parameters<RequestCookies["has"]>) => store.has(...args),
  };
};

export const getDecodedCookies = async () => {
  const store = await cookies();
  return wrapCookies(store) as RequestCookies;
};


import { describe, expect, it } from "vitest";
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { SidebarInitialData } from "@/components/sidebar/types";
import {
  buildSidebarDatasetListQueryPayload,
  buildSidebarDatasetListQueryResult,
  buildSidebarInitialData,
  extractMindmapImageAssetIdsFromData,
  mapSidebarDatasetListQueryResultToInitialData,
} from "@/lib/sidebar-data";

describe("extractMindmapImageAssetIdsFromData", () => {
  it("提取导图节点里的 asset 图片引用并去重", () => {
    const ids = extractMindmapImageAssetIdsFromData({
      root: {
        data: { image: "asset:img_1" },
        children: [
          { image: { url: "asset:img_2" } },
          { data: { image: { url: "asset:img_1" } } },
        ],
      },
    });

    expect(ids).toEqual(["img_1", "img_2"]);
  });
});

describe("buildSidebarInitialData", () => {
  it("统一组装 mindmap/table/media 相关侧边栏数据", () => {
    const workspaces: WorkspaceSummary[] = [
      {
        id: "ws_1",
        name: "工作区",
        type: "personal",
        iconUrl: null,
        memberCount: 1,
        isDefault: true,
      },
    ];

    const documents: DocumentRecord[] = [
      {
        id: "doc_1",
        workspace_id: "ws_1",
        title: "页面 1",
        parent_id: null,
        sort_order: 1,
        is_starred: false,
        access_scope: "private",
        is_template: false,
        created_at: "2026-04-14T00:00:00Z",
        updated_at: null,
      },
    ];

    const trashedDocuments: SidebarInitialData["trashedDocuments"] = [];

    const payload = buildSidebarInitialData({
      activeWorkspaceId: "ws_1",


is_starred: false,
        access_scope: "private",
        is_template: false,
        created_at: "2026-04-14T00:00:00Z",
        updated_at: null,
      },
    ];

    const trashedDocuments: SidebarInitialData["trashedDocuments"] = [];

    const payload = buildSidebarInitialData({
      activeWorkspaceId: "ws_1",
      workspaces,
      documents,
      trashedDocuments,
      mediaAssets: [
        {
          id: "asset_file_1",
          workspace_id: "ws_1",
          document_id: "doc_1",
          asset_type: "file",
          file_url: "/file/1",
          thumbnail_url: null,
          bucket: null,
          storage_path: null,
          file_name: "a.txt",
          file_size: null,
          mime_type: "text/plain",
          ocr_payload: undefined,
          ocr_strategy: null,
          ocr_text: null,
          ocr_status: null,
          signed_url: null,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
        },
      ],
      trashedMediaAssets: [],
      mindmaps: [
        {
          mindmap_id: "mind_1",
          workspace_id: "ws_1",
          document_id: "doc_1",
          data: {
            root: {
              data: { image: "asset:img_a" },
              children: [{ image: { url: "asset:img_b" } }],
            },
          },
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
          deleted_at: null,
        },
        {
          mindmap_id: "mind_2",
          workspace_id: "ws_1",
          document_id: "doc_1",
          data: null,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",


},
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
          deleted_at: null,
        },
        {
          mindmap_id: "mind_2",
          workspace_id: "ws_1",
          document_id: "doc_1",
          data: null,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
          deleted_at: "2026-04-14T01:00:00Z",
          deleted_by: "user_1",
        },
      ],
      tables: [
        {
          id: "table_1",
          workspace_id: "ws_1",
          document_id: "doc_1",
          title: "预算",
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
          is_archived: false,
        },
        {
          id: "table_2",
          workspace_id: "ws_1",
          document_id: "doc_1",
          title: "归档表格",
          created_at: "2026-04-14T00:00:00Z",
          updated_at: "2026-04-14T00:00:00Z",
          deleted_at: "2026-04-14T01:00:00Z",
          deleted_by: "user_1",
          purged_at: null,
          is_archived: true,
        },
      ],
    });

    expect(payload.activeWorkspaceId).toBe("ws_1");
    expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
    expect(payload.kernelFileTreeProjection?.projection).toBe("file_tree");
    expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
    expect(payload.mindmapDocs).toEqual(["doc_1"]);
    expect(payload.mindmapAssetChildren).toEqual({
      mind_1: ["img_a", "img_b"],
    });
    expect(payload.mindmapAssets?.map((item) => item.id)).toEqual(["mind_1"]);
    expect(payload.trashedMindmapAssets?.map((item) => item.id)).toEqual(["mind_2"]);
    expect(payload.tableAssets?.map((item) => item.file_name)).toEqual(["预算.luckysheet"]);
    expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]);
    expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]);
  });

  it("冻结 sidebar.dataset.list 的 Rust query 契约字段", () => {
    const queryPayload = buildSidebarDatasetListQueryPayload("  ws_1  ");
    expect(queryPayload).toEqual({ workspace_id: "ws_1" });


});
    expect(payload.mindmapAssets?.map((item) => item.id)).toEqual(["mind_1"]);
    expect(payload.trashedMindmapAssets?.map((item) => item.id)).toEqual(["mind_2"]);
    expect(payload.tableAssets?.map((item) => item.file_name)).toEqual(["预算.luckysheet"]);
    expect(payload.trashedTableAssets?.map((item) => item.id)).toEqual(["table_2"]);
    expect(payload.mediaAssets?.map((item) => item.id)).toEqual(["asset_file_1"]);
  });

  it("冻结 sidebar.dataset.list 的 Rust query 契约字段", () => {
    const queryPayload = buildSidebarDatasetListQueryPayload("  ws_1  ");
    expect(queryPayload).toEqual({ workspace_id: "ws_1" });

    const queryResult = buildSidebarDatasetListQueryResult({
      activeWorkspaceId: "ws_1",
      workspaces: [
        {
          id: "ws_1",
          name: "工作区",
          type: "personal",
          iconUrl: null,
          memberCount: 1,
          isDefault: true,
        },
      ],
      documents: [
        {
          id: "doc_1",
          workspace_id: "ws_1",
          title: "页面 1",
          parent_id: null,
          sort_order: 1,
          is_starred: false,
          access_scope: "private",
          is_template: false,
          created_at: "2026-04-14T00:00:00Z",
          updated_at: null,
        },
      ],
      trashedDocuments: [],
      mindmaps: [],
      mediaAssets: [],
      trashedMediaAssets: [],
      tables: [],
    });

    expect(queryResult).toMatchObject({
      active_workspace_id: "ws_1",
      workspaces: [
        {
          id: "ws_1",
          name: "工作区",
          type: "personal",
          iconUrl: null,
          memberCount: 1,
          isDefault: true,
        },
      ],
      documents: [
        {
          id: "doc_1",