import type { SupabaseClient } from "@supabase/supabase-js"; import type { DocumentRecord, DocumentNode } from "@/lib/documents"; import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types"; import type { Database } from "@/types/supabase"; import type { MediaAsset } from "@/types/media"; import { buildDocumentTree } from "@/lib/documents"; type TypedClient = SupabaseClient; export type SidebarTableRow = { id: string; workspace_id: string | null; document_id: string; title: string | null; created_at: string | null; updated_at: string | null; }; export interface SidebarDataset { documents: DocumentRecord[]; trashedDocuments: TrashRecord[]; trashedMediaAssets: MediaAsset[]; /** * 仅依据远端 mindmap_data 判定的页面 id 列表;本地文件检测由服务器侧辅助完成 */ mindmapDocs: string[]; mediaAssets?: MediaAsset[]; tables?: SidebarTableRow[]; } export async function fetchSidebarDataset( client: TypedClient, workspaceId: string, ): Promise { const { data: documentRows, error } = await client .from("documents") .select( "id,title,parent_id,sort_order,is_starred,access_scope,is_template,created_at,updated_at,workspace_id,mindmap_data", ) .eq("workspace_id", workspaceId) .is("deleted_at", null) .order("sort_order", { ascending: true, nullsFirst: false }) .order("created_at", { ascending: true }); if (error) { throw new Error(`获取工作空间文档列表失败:${error.message}`); } const documentRowsAny = (documentRows ?? []) as any[]; const documents: DocumentRecord[] = documentRowsAny.map((row) => ({ access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"], id: row.id, workspace_id: row.workspace_id, title: row.title ?? "无标题", parent_id: row.parent_id, sort_order: row.sort_order, is_starred: row.is_starred, is_template: row.is_template ?? false, created_at: row.created_at ?? "", updated_at: row.updated_at ?? null, })); const { data: trashRows, error: trashError } = await client .from("documents") .select("id,title,parent_id,deleted_at,access_scope") .eq("workspace_id", workspaceId) .not("deleted_at", "is", null) .order("deleted_at", { ascending: false }) .limit(100); if (trashError) { throw new Error(`获取垃圾桶内容失败:${trashError.message}`); } const trashRowsAny = (trashRows ?? []) as any[]; const trashedDocuments: TrashRecord[] = trashRowsAny.map((row) => ({ id: row.id, title: row.title, parent_id: row.parent_id, deleted_at: row.deleted_at!, access_scope: (row.access_scope ?? "private") as DocumentRecord["access_scope"], })); const mindmapDocs = documentRowsAny.filter((row) => row.mindmap_data != null).map((row) => row.id); const { data: assetRows, error: assetError } = await client .from("media_assets") .select("*") .eq("workspace_id", workspaceId) .is("deleted_at", null) .order("created_at", { ascending: false }); if (assetError) { throw new Error(`获取附件列表失败:${assetError.message}`); } const mediaAssets: MediaAsset[] = (assetRows ?? []) as MediaAsset[]; const { data: trashedAssetRows, error: trashedAssetError } = await client .from("media_assets") .select("*") .eq("workspace_id", workspaceId) .not("deleted_at", "is", null) .is("purged_at", null) .order("deleted_at", { ascending: false }) .limit(200); if (trashedAssetError) { throw new Error(`获取附件垃圾桶失败:${trashedAssetError.message}`); } const trashedMediaAssets: MediaAsset[] = (trashedAssetRows ?? []) as MediaAsset[]; // 说明:当前 supabase types 可能未包含 document_tables;这里用 any 兜底, // 避免类型缺失阻塞侧边栏功能。 const { data: tableRows, error: tableError } = await (client as any) .from("document_tables") .select("id,workspace_id,document_id,title,created_at,updated_at,is_archived") .eq("workspace_id", workspaceId) .eq("is_archived", false) .order("created_at", { ascending: true }); if (tableError) { throw new Error(`获取在线表格列表失败:${tableError.message}`); } const tables: SidebarTableRow[] = (tableRows ?? []) as unknown as SidebarTableRow[]; return { documents, trashedDocuments, trashedMediaAssets, mindmapDocs, mediaAssets, tables, }; } type NodePredicate = (node: DocumentNode) => boolean; function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] { const result: DocumentNode[] = []; nodes.forEach((node) => { const projectedChildren = projectTree(node.children, predicate); if (predicate(node)) { result.push({ ...node, children: projectedChildren, }); } else { result.push(...projectedChildren); } }); return result; } export function flattenDocumentTree( nodes: DocumentNode[], expanded: Set, depth = 0, parentId: string | null = null, ): Array<{ node: DocumentNode; depth: number; parentId: string | null }> { const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = []; nodes.forEach((node) => { list.push({ node, depth, parentId }); if (node.children.length > 0 && expanded.has(node.id)) { list.push(...flattenDocumentTree(node.children, expanded, depth + 1, node.id)); } }); return list; } const SECTION_META: Record = { starred: { title: "星标置顶", icon: "star" }, public: { title: "公共页面", icon: "globe" }, shared: { title: "共享页面", icon: "users" }, private: { title: "私有 / 我的页面", icon: "lock" }, templates: { title: "模板中心", icon: "grid" }, }; type SidebarSectionSnapshot = { id: SidebarSectionId; title: string; icon?: string; nodes: DocumentNode[]; }; export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] { const tree = buildDocumentTree(records); return buildSidebarSectionsFromTree(tree); } export function buildSidebarSectionsFromTree( tree: DocumentNode[], ): SidebarSectionSnapshot[] { const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [ { id: "starred", predicate: (node) => Boolean(node.is_starred) }, { id: "public", predicate: (node) => node.access_scope === "public" }, { id: "shared", predicate: (node) => node.access_scope === "shared" }, { id: "private", predicate: (node) => node.access_scope === "private" }, { id: "templates", predicate: (node) => node.is_template }, ]; return sections.map(({ id, predicate }) => ({ id, title: SECTION_META[id].title, icon: SECTION_META[id].icon, nodes: projectTree(tree, predicate), })); }