import { internalQuery, mutation, query } from "./_generated/server"; import { api } from "./_generated/api"; import { v } from "convex/values"; import { requireUserId } from "./_utils/auth"; import { nowIso } from "./_utils/time"; import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree"; import { assertDocumentMoveOrderPlanMatches, assertDocumentMoveWriteOperationMatches, buildDocumentMoveOrderPlanFromDocuments, } from "./_utils/documentMoveOrder"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; import { extractTextFromDocumentContent } from "./_utils/text"; import { getCanonicalDocumentByBusinessId, getCanonicalParentDocumentId, pickCanonicalDocumentRecordsByBusinessId, } from "./_utils/documentRecord"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); type SharePermission = "read" | "edit"; type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean }; type CopyTreeDocument = { _id: unknown; id: string; title: string | null; parent_id: string | null; workspace_id: string; access_scope: "private" | "shared" | "public" | null; sort_order: number | null; created_at: string | null; content: unknown; user_id: string; deleted_at: string | null; }; function normalizeTitle(title: string | null | undefined): string { const safe = String(title ?? "").trim(); return safe.length > 0 ? safe : "无标题"; } function makeUniqueTitle(baseTitle: string, existing: Set): string { const base = normalizeTitle(baseTitle); if (!existing.has(base)) { existing.add(base); return base; } const first = `${base} 副本`; if (!existing.has(first)) { existing.add(first); return first; } for (let i = 2; i < 1000; i += 1) { const candidate = `${base} 副本 ${i}`; if (!existing.has(candidate)) { existing.add(candidate); return candidate; } } const fallback = `${base} 副本 ${Date.now()}`; existing.add(fallback); return fallback; } function extractBlocksFromContent(content: unknown): unknown[] { if (Array.isArray(content)) { return content as unknown[]; } if (content && typeof content === "object" && Array.isArray((content as { blocks?: unknown[] }).blocks)) { return ((content as { blocks?: unknown[] }).blocks ?? []) as unknown[]; } return []; } function composeContentWithBlocks(content: unknown, blocks: unknown[]): unknown { if (Array.isArray(content)) { return blocks as unknown[]; } if (content && typeof content === "object") { return { ...(content as Record), blocks, }; } return { blocks }; } function appendPageReferenceBlock(content: unknown, pageId: string, title: string): unknown { const blocks = extractBlocksFromContent(content); const pageReferenceBlock = { id: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`, type: "pageReference", props: { pageId, title: normalizeTitle(title), }, }; return composeContentWithBlocks(content, [...blocks, pageReferenceBlock]); } function getChildrenSorted(childrenByParent: Map, parentId: string | null): CopyTreeDocument[] { const list = childrenByParent.get(parentId) ?? []; return [...list].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; const timeA = new Date(a.created_at ?? 0).getTime(); const timeB = new Date(b.created_at ?? 0).getTime(); return timeA - timeB; }); } async function copyMindmapsForDocument(ctx: any, sourceDocId: string, targetDocId: string) { return await ctx.runMutation(api.mindmaps.copyByDocument, { sourceDocId, targetDocId, }); } async function assertDocumentVisibleToUser(ctx: any, doc: any, userId: string, errorMessage = "无权限") { await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id === userId) { return; } if (doc.access_scope === "public") { return; } const policy = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (!policy) { throw new Error(errorMessage); } } async function createDocumentRecord( ctx: any, args: { id: string; workspaceId: string; parentId: string | null; title?: string | null; accessScope: "private" | "shared" | "public"; content?: unknown; }, ) { const userId = await requireUserId(ctx); const existing = await getCanonicalDocumentByBusinessId(ctx, args.id); if (existing && existing.deleted_at == null) { throw new Error("页面已存在"); } const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q: any) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId)) .collect(); const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length; const ts = nowIso(); const title = (args.title ?? "无标题") || "无标题"; const content = typeof args.content === "undefined" ? [] : args.content; const rawText = extractTextFromDocumentContent(content); await ctx.db.insert("documents", { id: args.id, user_id: userId, workspace_id: args.workspaceId, parent_id: args.parentId, title, content, content_revision: 0, content_conflict_key: `${args.id}:0`, raw_text: rawText, access_scope: args.accessScope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: false, use_small_text: false, show_heading_numbers: true, show_toc: false, show_structure: false, protect_editing: false, show_word_count: true, collapse_backlinks: false, page_font: "default", layout_density: "normal", hide_child_pages: false, show_block_ref_count: false, embed_default_block_id: null, word_count: 0, character_count: 0, block_count: 0, todo_total_count: 0, todo_done_count: 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); return { id: args.id, title, parent_id: args.parentId, sort_order: sortOrder, is_starred: false, created_at: ts, updated_at: ts, workspace_id: args.workspaceId, access_scope: args.accessScope, is_template: false, }; } function toDocumentDeltaRecord(doc: any) { return { id: doc.id, workspace_id: doc.workspace_id, title: doc.title ?? null, parent_id: doc.parent_id ?? null, sort_order: doc.sort_order ?? null, is_starred: doc.is_starred ?? false, access_scope: (doc.access_scope ?? "private") as "private" | "shared" | "public", is_template: Boolean(doc.is_template), created_at: doc.created_at ?? nowIso(), updated_at: doc.updated_at ?? null, }; } async function updateDocumentContentRecord( ctx: any, args: { id: string; content: unknown; expectedRevision?: number | null; conflictDetectionKey?: string | null; }, ) { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const currentRevision = doc.content_revision ?? 0; const currentConflictDetectionKey = doc.content_conflict_key ?? `${args.id}:${currentRevision}`; if ( typeof args.expectedRevision === "number" && Number.isInteger(args.expectedRevision) && args.expectedRevision >= 0 && args.expectedRevision !== currentRevision ) { throw new Error("正文内容已变更,请刷新后重试"); } if ( typeof args.conflictDetectionKey === "string" && args.conflictDetectionKey.trim() && args.conflictDetectionKey.trim() !== currentConflictDetectionKey ) { throw new Error("正文冲突检测失败,请刷新后重试"); } const ts = nowIso(); const rawText = extractTextFromDocumentContent(args.content); const nextRevision = currentRevision + 1; const nextConflictDetectionKey = `${args.id}:${nextRevision}`; await ctx.db.patch(doc._id, { content: args.content, content_revision: nextRevision, content_conflict_key: nextConflictDetectionKey, raw_text: rawText, updated_at: ts, }); await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); return { ok: true, updated_at: ts, revision: nextRevision, conflict_detection_key: nextConflictDetectionKey, }; } async function duplicateDocumentRecord( ctx: any, args: { sourceId: string; newId: string; title?: string | null; }, ) { const userId = await requireUserId(ctx); const source = await getCanonicalDocumentByBusinessId(ctx, args.sourceId); if (!source) throw new Error("页面不存在或无权限访问"); if (source.user_id !== userId) throw new Error("页面不存在或无权限访问"); const existingTarget = await getCanonicalDocumentByBusinessId(ctx, args.newId); if (existingTarget && existingTarget.deleted_at == null) { throw new Error("目标页面已存在"); } const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q: any) => q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id), ) .collect(); const sortOrder = siblings.filter((d: any) => d.deleted_at == null).length; const ts = nowIso(); const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题"; const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`; const rawText = extractTextFromDocumentContent(source.content ?? []); await ctx.db.insert("documents", { id: args.newId, user_id: userId, workspace_id: source.workspace_id, parent_id: source.parent_id, title, content: source.content ?? [], content_revision: 0, content_conflict_key: `${args.newId}:0`, raw_text: rawText, access_scope: source.access_scope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: source.wide_layout ?? false, use_small_text: source.use_small_text ?? false, show_heading_numbers: source.show_heading_numbers ?? true, show_toc: source.show_toc ?? false, show_structure: source.show_structure ?? false, protect_editing: source.protect_editing ?? false, show_word_count: source.show_word_count ?? true, collapse_backlinks: (source as any).collapse_backlinks ?? false, page_font: (source as any).page_font ?? "default", layout_density: (source as any).layout_density ?? "normal", hide_child_pages: (source as any).hide_child_pages ?? false, show_block_ref_count: (source as any).show_block_ref_count ?? false, embed_default_block_id: (source as any).embed_default_block_id ?? null, word_count: source.word_count ?? 0, character_count: source.character_count ?? 0, block_count: source.block_count ?? 0, todo_total_count: (source as any).todo_total_count ?? 0, todo_done_count: (source as any).todo_done_count ?? 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); return { id: args.newId, title, parent_id: source.parent_id ?? null, sort_order: sortOrder, workspace_id: source.workspace_id, access_scope: source.access_scope, created_at: ts, updated_at: ts, }; } async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { const member = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) .first(); if (!member) { throw new Error("无权限"); } return member; } async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise { const direct = await ctx.db .query("document_shares") .withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId)) .first(); if (direct) { return { permission: direct.permission as SharePermission, disableDownload: Boolean((direct as any).disable_download), disableCopy: Boolean((direct as any).disable_copy), }; } let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = await ctx.db .query("document_shares") .withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId)) .first(); if (parentShare && parentShare.include_descendants) { return { permission: parentShare.permission as SharePermission, disableDownload: Boolean((parentShare as any).disable_download), disableCopy: Boolean((parentShare as any).disable_copy), }; } parentId = await getCanonicalParentDocumentId(ctx, parentId); } return null; } async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { const policy = await resolveSharePolicy(ctx, doc, userId); return policy?.permission ?? null; } async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise { const memberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId)) .collect(); const groupIds = new Set(memberships.map((m: any) => String(m.group_id))); if (groupIds.size === 0) return null; const permissionFromGroup = async (documentId: string, groupId: string): Promise => { const override = await ctx.db .query("document_group_user_permissions") .withIndex("by_doc_group_user", (q: any) => q.eq("document_id", documentId).eq("group_id", groupId).eq("user_id", userId), ) .first(); return override?.permission === "edit" ? "edit" : "read"; }; let best: SharePermission | null = null; let disableDownload = false; let disableCopy = false; const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => { const shares = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); for (const s of shares) { const gid = String(s.group_id); if (!groupIds.has(gid)) continue; if (requireIncludeDesc && !s.include_descendants) continue; disableDownload = disableDownload || Boolean((s as any).disable_download); disableCopy = disableCopy || Boolean((s as any).disable_copy); const perm = await permissionFromGroup(documentId, gid); if (perm === "edit") { best = "edit"; } else { best = best ?? "read"; } } }; // 当前页面是否被群组公开 await checkDocId(doc.id, false); // 沿父链查找“包含子页面”的群组公开 let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { await checkDocId(parentId, true); parentId = await getCanonicalParentDocumentId(ctx, parentId); } if (!best) return null; return { permission: best, disableDownload, disableCopy }; } async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { const policy = await resolveGroupSharePolicy(ctx, doc, userId); return policy?.permission ?? null; } async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) { // 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系; // 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。 const directShares = await ctx.db .query("document_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); for (const row of directShares) { await ctx.db.delete(row._id); } const groupShares = await ctx.db .query("document_group_shares") .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) .collect(); const touchedGroupIds = new Set(); for (const row of groupShares) { touchedGroupIds.add(String(row.group_id)); await ctx.db.delete(row._id); } // 删除该文档在相关群组下的“用户可编辑覆盖权限” for (const groupId of touchedGroupIds) { const perms = await ctx.db .query("document_group_user_permissions") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId)) .collect(); for (const p of perms) { if (String(p.document_id) !== documentId) continue; await ctx.db.delete(p._id); } } } async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentIds: string[]) { const uniqueDocIds = Array.from(new Set(documentIds.filter(Boolean))); if (uniqueDocIds.length === 0) return; // 1) 思维导图 for (const docId of uniqueDocIds) { const rows = await ctx.db .query("mindmaps") .withIndex("by_doc_mindmap", (q: any) => q.eq("document_id", docId)) .collect(); for (const r of rows) { await ctx.db.delete(r._id); } } // 2) 附件(含底层 storage 文件) const assets: any[] = []; for (const docId of uniqueDocIds) { const rows = await ctx.db .query("media_assets") .withIndex("by_document", (q: any) => q.eq("document_id", docId)) .collect(); assets.push(...rows); } if (assets.length) { const assetIdsToDelete = new Set(assets.map((a) => String(a.id))); const byStorage = new Map(); for (const a of assets) { const sid = (a.storage_id as any) ?? null; if (!sid) continue; const list = byStorage.get(String(sid)) ?? []; list.push(String(a.id)); byStorage.set(String(sid), list); } for (const [sid] of byStorage.entries()) { const refs = await ctx.db .query("media_assets") .withIndex("by_storage_id", (q: any) => q.eq("storage_id", sid as any)) .collect(); // 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。 const otherAlive = refs.some((r: any) => !assetIdsToDelete.has(String(r.id)) && !r.purged_at); if (!otherAlive) { try { await ctx.storage.delete(sid as any); } catch { // ignore } } } for (const a of assets) { await ctx.db.delete(a._id); } } // 3) 在线表格(含行数据) for (const docId of uniqueDocIds) { const tables = await ctx.db .query("document_tables") .withIndex("by_document", (q: any) => q.eq("document_id", docId)) .collect(); for (const table of tables) { const rows = await ctx.db .query("document_table_rows") .withIndex("by_table", (q: any) => q.eq("table_id", table.id)) .collect(); for (const r of rows) { await ctx.db.delete(r._id); } await ctx.db.delete(table._id); } } // 4) 评论 for (const docId of uniqueDocIds) { const msgs = await ctx.db .query("comment_messages") .withIndex("by_document", (q: any) => q.eq("document_id", docId)) .collect(); for (const m of msgs) { await ctx.db.delete(m._id); } const threads = await ctx.db .query("comment_threads") .withIndex("by_document", (q: any) => q.eq("document_id", docId)) .collect(); for (const t of threads) { await ctx.db.delete(t._id); } } // 5) 页面引用/反链(source/target 任一命中即删除) const deletedRefIds = new Set(); for (const docId of uniqueDocIds) { const bySource = await ctx.db .query("page_references") .withIndex("by_workspace_source", (q: any) => q.eq("workspace_id", workspaceId).eq("source_page_id", docId)) .collect(); for (const r of bySource) { const id = String(r._id); if (deletedRefIds.has(id)) continue; deletedRefIds.add(id); await ctx.db.delete(r._id); } const byTarget = await ctx.db .query("page_references") .withIndex("by_workspace_target", (q: any) => q.eq("workspace_id", workspaceId).eq("target_page_id", docId)) .collect(); for (const r of byTarget) { const id = String(r._id); if (deletedRefIds.has(id)) continue; deletedRefIds.add(id); await ctx.db.delete(r._id); } } // 6) 收藏(避免残留用户数据) for (const docId of uniqueDocIds) { const stars = await ctx.db .query("document_stars") .withIndex("by_workspace_document_user", (q: any) => q.eq("workspace_id", workspaceId).eq("document_id", docId)) .collect(); for (const s of stars) { await ctx.db.delete(s._id); } } // 7) 最近访问(避免“最近/历史”里出现幽灵页面) // 说明:user_recent_pages 当前缺少按 document_id 的索引,这里先全表扫描再过滤。 // 若未来数据量变大,再考虑加 index("by_document", ["document_id"]) 或按 workspace/user 拆分索引。 const recents = await ctx.db.query("user_recent_pages").collect(); if (recents.length) { const docIdSet = new Set(uniqueDocIds); for (const r of recents) { if (!docIdSet.has(String((r as any).document_id ?? ""))) continue; await ctx.db.delete(r._id); } } // 兜底:如果未来新增了其它 “document_id 外键表”,这里可以继续补充; // 当前先把最容易产生垃圾、且已出现历史堆积的表(media_assets/mindmaps/表格/评论/引用/收藏)清理掉。 // 同时,避免误删“非 document_id 维度”的用户行为表(如 jobs 等),后续如确认需要可再补齐。 } export const getMeta = query({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.deleted_at != null) return null; try { await requireWorkspaceMember(ctx, doc.workspace_id, userId); } catch { return null; } let canEdit = false; let disableDownload = false; let disableCopy = false; if (doc.user_id === userId) { canEdit = true; } else if (doc.access_scope === "public") { canEdit = false; } else { const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId)); if (!policy) return null; canEdit = policy.permission === "edit"; disableDownload = policy.disableDownload; disableCopy = policy.disableCopy; } return { id: doc.id, user_id: doc.user_id, workspace_id: doc.workspace_id, access_scope: doc.access_scope, can_edit: canEdit, disable_download: disableDownload, disable_copy: disableCopy, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, updated_at: doc.updated_at ?? null, wide_layout: doc.wide_layout ?? null, use_small_text: doc.use_small_text ?? null, show_heading_numbers: doc.show_heading_numbers ?? null, show_toc: doc.show_toc ?? null, show_structure: doc.show_structure ?? null, protect_editing: doc.protect_editing ?? null, show_word_count: doc.show_word_count ?? null, collapse_backlinks: (doc as any).collapse_backlinks ?? null, page_font: (doc as any).page_font ?? null, layout_density: (doc as any).layout_density ?? null, hide_child_pages: (doc as any).hide_child_pages ?? null, show_block_ref_count: (doc as any).show_block_ref_count ?? null, embed_default_block_id: (doc as any).embed_default_block_id ?? null, word_count: doc.word_count ?? null, character_count: doc.character_count ?? null, block_count: doc.block_count ?? null, todo_total_count: (doc as any).todo_total_count ?? null, todo_done_count: (doc as any).todo_done_count ?? null, }; }, }); export const getPermissionForUser = query({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.deleted_at != null) return null; // workspace 权限 await requireWorkspaceMember(ctx, doc.workspace_id, args.userId); if (doc.user_id === args.userId) { return { permission: "edit" as const, disableDownload: false, disableCopy: false }; } if (doc.access_scope === "public") { return { permission: "read" as const, disableDownload: false, disableCopy: false }; } const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId)); if (!policy) return null; return { permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const), disableDownload: policy.disableDownload, disableCopy: policy.disableCopy, }; }, }); export const getMetaForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { id: doc.id, user_id: doc.user_id, workspace_id: doc.workspace_id, access_scope: doc.access_scope, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, updated_at: doc.updated_at ?? null, wide_layout: doc.wide_layout ?? null, use_small_text: doc.use_small_text ?? null, show_heading_numbers: doc.show_heading_numbers ?? null, show_toc: doc.show_toc ?? null, show_structure: doc.show_structure ?? null, protect_editing: doc.protect_editing ?? null, show_word_count: doc.show_word_count ?? null, word_count: doc.word_count ?? null, character_count: doc.character_count ?? null, block_count: doc.block_count ?? null, }; }, }); export const getContent = query({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.deleted_at != null) return null; try { await requireWorkspaceMember(ctx, doc.workspace_id, userId); } catch { return null; } if (doc.user_id === userId) { return { content: doc.content ?? null, revision: doc.content_revision ?? 0, conflict_detection_key: doc.content_conflict_key ?? `${args.id}:${doc.content_revision ?? 0}`, }; } if (doc.access_scope === "public") { return { content: doc.content ?? null, revision: doc.content_revision ?? 0, conflict_detection_key: doc.content_conflict_key ?? `${args.id}:${doc.content_revision ?? 0}`, }; } const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (!perm) return null; return { content: doc.content ?? null, revision: doc.content_revision ?? 0, conflict_detection_key: doc.content_conflict_key ?? `${args.id}:${doc.content_revision ?? 0}`, }; }, }); export const getContentForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) return null; if (doc.user_id !== args.userId) return null; return { content: doc.content ?? null, revision: doc.content_revision ?? 0, conflict_detection_key: doc.content_conflict_key ?? `${args.id}:${doc.content_revision ?? 0}`, }; }, }); export const listByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // workspace 权限 try { await requireWorkspaceMember(ctx, args.workspaceId, userId); } catch { return []; } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); // 说明:这里仅用于侧边栏数据(不返回垃圾桶 deleted_at != null)。 const alive = docs.filter((d) => d.deleted_at == null); const shares = await ctx.db .query("document_shares") .withIndex("by_workspace_shared_with", (q: any) => q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), ) .collect(); const directShares = new Map< string, { permission: SharePermission; includeDescendants: boolean } >(); for (const s of shares) { directShares.set(s.document_id, { permission: s.permission as SharePermission, includeDescendants: Boolean(s.include_descendants), }); } const groupMemberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); const directGroupShares = new Map(); if (groupIds.size > 0) { for (const gid of groupIds) { const rows = await ctx.db .query("document_group_shares") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) .collect(); for (const r of rows) { const existing = directGroupShares.get(r.document_id); if (!existing) { directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); } else { existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); } } } } const parentById = new Map(); for (const d of alive) { parentById.set(d.id, d.parent_id ?? null); } const shareAccessCache = new Map(); const canAccessByShare = (docId: string): boolean => { const cached = shareAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directShares.has(docId)) { shareAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directShares.get(parentId); if (parentShare && parentShare.includeDescendants) { shareAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } shareAccessCache.set(docId, false); return false; }; const groupAccessCache = new Map(); const canAccessByGroupShare = (docId: string): boolean => { const cached = groupAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directGroupShares.has(docId)) { groupAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directGroupShares.get(parentId); if (parentShare && parentShare.includeDescendants) { groupAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } groupAccessCache.set(docId, false); return false; }; const stars = await ctx.db .query("document_stars") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const starredDocIds = new Set(stars.map((s: any) => String(s.document_id))); return alive .filter((d) => { if (d.user_id === userId) return true; if (d.access_scope === "public") return true; return canAccessByShare(d.id) || canAccessByGroupShare(d.id); }) .map((d) => ({ access_scope: d.user_id === userId ? d.access_scope : d.access_scope === "public" ? "public" : "shared", id: d.id, workspace_id: d.workspace_id, title: d.title ?? "无标题", parent_id: d.parent_id ?? null, sort_order: d.sort_order ?? null, is_starred: Boolean(starredDocIds.has(d.id) || d.is_starred), is_template: d.is_template ?? false, created_at: d.created_at, updated_at: d.updated_at ?? null, })); }, }); export const listSearchDataByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // workspace 权限 try { await requireWorkspaceMember(ctx, args.workspaceId, userId); } catch { return []; } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const alive = docs.filter((d) => d.deleted_at == null); const shares = await ctx.db .query("document_shares") .withIndex("by_workspace_shared_with", (q: any) => q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), ) .collect(); const directShares = new Map< string, { permission: SharePermission; includeDescendants: boolean } >(); for (const s of shares) { directShares.set(s.document_id, { permission: s.permission as SharePermission, includeDescendants: Boolean(s.include_descendants), }); } const groupMemberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .collect(); const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); const directGroupShares = new Map(); if (groupIds.size > 0) { for (const gid of groupIds) { const rows = await ctx.db .query("document_group_shares") .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) .collect(); for (const r of rows) { const existing = directGroupShares.get(r.document_id); if (!existing) { directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); } else { existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); } } } } const parentById = new Map(); for (const d of alive) { parentById.set(d.id, d.parent_id ?? null); } const shareAccessCache = new Map(); const canAccessByShare = (docId: string): boolean => { const cached = shareAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directShares.has(docId)) { shareAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directShares.get(parentId); if (parentShare && parentShare.includeDescendants) { shareAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } shareAccessCache.set(docId, false); return false; }; const groupAccessCache = new Map(); const canAccessByGroupShare = (docId: string): boolean => { const cached = groupAccessCache.get(docId); if (typeof cached === "boolean") return cached; if (directGroupShares.has(docId)) { groupAccessCache.set(docId, true); return true; } let parentId = parentById.get(docId) ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { const parentShare = directGroupShares.get(parentId); if (parentShare && parentShare.includeDescendants) { groupAccessCache.set(docId, true); return true; } parentId = parentById.get(parentId) ?? null; } groupAccessCache.set(docId, false); return false; }; return alive .filter((d) => { if (d.user_id === userId) return true; if (d.access_scope === "public") return true; return canAccessByShare(d.id) || canAccessByGroupShare(d.id); }) .map((d) => ({ id: d.id, workspace_id: d.workspace_id, title: d.title ?? null, created_at: d.created_at ?? null, updated_at: d.updated_at ?? null, raw_text: typeof d.raw_text === "string" && d.raw_text.trim() ? d.raw_text : extractTextFromDocumentContent(d.content ?? null), })); }, }); export const listTrashedByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); return docs .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at != null) .sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? "")) .slice(0, 100) .map((d) => ({ id: d.id, title: d.title ?? null, parent_id: d.parent_id ?? null, deleted_at: d.deleted_at!, access_scope: d.access_scope, })); }, }); export const create = mutation({ args: { id: v.string(), workspaceId: v.string(), parentId: v.union(v.string(), v.null()), title: v.optional(v.union(v.string(), v.null())), accessScope, content: v.optional(v.any()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const existing = await getCanonicalDocumentByBusinessId(ctx, args.id); if (existing && existing.deleted_at == null) { throw new Error("页面已存在"); } const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId)) .collect(); const sortOrder = siblings.filter((d) => d.deleted_at == null).length; const ts = nowIso(); const title = (args.title ?? "无标题") || "无标题"; const content = typeof args.content === "undefined" ? [] : args.content; const rawText = extractTextFromDocumentContent(content); await ctx.db.insert("documents", { id: args.id, user_id: userId, workspace_id: args.workspaceId, parent_id: args.parentId, title, content, content_revision: 0, content_conflict_key: `${args.id}:0`, raw_text: rawText, access_scope: args.accessScope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: false, use_small_text: false, show_heading_numbers: true, show_toc: false, show_structure: false, protect_editing: false, show_word_count: true, collapse_backlinks: false, page_font: "default", layout_density: "normal", hide_child_pages: false, show_block_ref_count: false, embed_default_block_id: null, word_count: 0, character_count: 0, block_count: 0, todo_total_count: 0, todo_done_count: 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); const document = { id: args.id, title, parent_id: args.parentId, sort_order: sortOrder, is_starred: false, created_at: ts, updated_at: ts, workspace_id: args.workspaceId, access_scope: args.accessScope, is_template: false, }; return { ...document, document: toDocumentDeltaRecord(document), }; }, }); export const createWithParentReference = mutation({ args: { id: v.string(), workspaceId: v.string(), parentId: v.union(v.string(), v.null()), title: v.optional(v.union(v.string(), v.null())), accessScope, content: v.optional(v.any()), }, handler: async (ctx, args) => { if (!args.parentId) { return await createDocumentRecord(ctx, { id: args.id, workspaceId: args.workspaceId, parentId: args.parentId, title: args.title ?? null, accessScope: args.accessScope, content: args.content, }); } const userId = await requireUserId(ctx); const parentDoc = await getCanonicalDocumentByBusinessId(ctx, args.parentId); if (!parentDoc) { throw new Error("父页面不存在或无权限"); } if (parentDoc.deleted_at != null) { throw new Error("父页面不存在或无权限"); } await assertDocumentVisibleToUser(ctx, parentDoc, userId, "父页面不存在或无权限"); const created = await createDocumentRecord(ctx, { ...args, workspaceId: parentDoc.workspace_id, parentId: args.parentId, accessScope: (parentDoc.access_scope ?? "private") as "private" | "shared" | "public", content: args.content ?? [], }); const updatedContent = appendPageReferenceBlock(parentDoc.content ?? null, created.id, created.title ?? "无标题"); await updateDocumentContentRecord(ctx, { id: args.parentId, content: updatedContent, }); return created; }, }); export const updateContent = mutation({ args: { id: v.string(), content: v.any(), expectedRevision: v.optional(v.union(v.number(), v.null())), conflictDetectionKey: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const currentRevision = doc.content_revision ?? 0; const currentConflictDetectionKey = doc.content_conflict_key ?? `${args.id}:${currentRevision}`; if ( typeof args.expectedRevision === "number" && Number.isInteger(args.expectedRevision) && args.expectedRevision >= 0 && args.expectedRevision !== currentRevision ) { throw new Error("正文内容已变更,请刷新后重试"); } if ( typeof args.conflictDetectionKey === "string" && args.conflictDetectionKey.trim() && args.conflictDetectionKey.trim() !== currentConflictDetectionKey ) { throw new Error("正文冲突检测失败,请刷新后重试"); } const ts = nowIso(); const rawText = extractTextFromDocumentContent(args.content); const nextRevision = currentRevision + 1; const nextConflictDetectionKey = `${args.id}:${nextRevision}`; await ctx.db.patch(doc._id, { content: args.content, content_revision: nextRevision, content_conflict_key: nextConflictDetectionKey, raw_text: rawText, updated_at: ts, }); // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。 // 采用 debounce,避免频繁保存时触发过多任务。 await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); return { ok: true, updated_at: ts, revision: nextRevision, conflict_detection_key: nextConflictDetectionKey, }; }, }); export const updateTitle = mutation({ args: { id: v.string(), title: v.union(v.string(), v.null()) }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); await ctx.db.patch(doc._id, { title: args.title, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const setTemplate = mutation({ args: { id: v.string(), isTemplate: v.boolean() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); await ctx.db.patch(doc._id, { is_template: args.isTemplate, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const move = mutation({ args: { id: v.string(), parentId: v.union(v.string(), v.null()), sortOrder: v.number(), normalizedMove: v.optional(v.any()), treeWriteOperation: v.optional(v.any()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const toParentId = args.parentId; const workspaceDocs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs) .filter((row) => row.user_id === userId) .filter((row) => row.deleted_at == null); if (toParentId === doc.id) { throw new Error("不能把页面移动到自身下面"); } if (toParentId) { const targetParentDoc = await getCanonicalDocumentByBusinessId(ctx, toParentId); if (!targetParentDoc || targetParentDoc.deleted_at != null || targetParentDoc.user_id !== userId) { throw new Error("目标父页面不存在或无权限"); } if (targetParentDoc.workspace_id !== doc.workspace_id) { throw new Error("暂不支持跨工作空间移动页面"); } const parentById = buildParentById(canonicalWorkspaceDocs); if (isAncestorOf(doc.id, toParentId, parentById)) { throw new Error("不能把页面移动到自己的后代下面"); } } const currentMoveOrderPlan = buildDocumentMoveOrderPlanFromDocuments({ documents: canonicalWorkspaceDocs, documentId: doc.id, parentId: toParentId, sortOrder: args.sortOrder, }); const rustMoveOrderPlan = args.treeWriteOperation != null ? assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan) : args.normalizedMove != null ? assertDocumentMoveOrderPlanMatches(args.normalizedMove, currentMoveOrderPlan) : null; // 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order, // 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。 // 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。 const ts = nowIso(); if (rustMoveOrderPlan != null) { const documentsById = new Map(canonicalWorkspaceDocs.map((item) => [item.id, item])); for (const item of rustMoveOrderPlan.patches) { const target = documentsById.get(item.documentId); if (!target) { throw new Error("Rust move plan 与 Convex 当前排序状态不一致"); } const patch: Record = {}; if ((target.parent_id ?? null) !== item.parentId) patch.parent_id = item.parentId; if ((target.sort_order ?? null) !== item.sortOrder) patch.sort_order = item.sortOrder; // Rust plan 决定被移动节点;Convex 只负责把同一结果持久化。 if (item.moved) patch.updated_at = ts; if (Object.keys(patch).length > 0) { await ctx.db.patch(target._id, patch); } } return { ok: true, parent_id: rustMoveOrderPlan.toParentId, sort_order: rustMoveOrderPlan.normalizedSortOrder, workspace_id: doc.workspace_id, updated_at: ts, }; } const compareDocOrder = (a: any, b: any) => { const orderA = typeof a.sort_order === "number" ? a.sort_order : Number.MAX_SAFE_INTEGER; const orderB = typeof b.sort_order === "number" ? b.sort_order : Number.MAX_SAFE_INTEGER; if (orderA !== orderB) return orderA - orderB; // created_at 为 ISO 字符串,按字典序比较即可。 return String(a.created_at ?? "").localeCompare(String(b.created_at ?? "")); }; const clampIndex = (raw: number, max: number) => { const n = Number.isFinite(raw) ? Math.floor(raw) : 0; if (n < 0) return 0; if (n > max) return max; return n; }; const fetchSiblings = async (parentId: string | null) => { const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", doc.workspace_id).eq("parent_id", parentId)) .collect(); return siblings .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at == null) .sort(compareDocOrder); }; const applyOrder = async (ordered: any[], parentId: string | null, movedId?: any) => { for (let i = 0; i < ordered.length; i += 1) { const item = ordered[i]; const nextSortOrder = i; const nextParentId = parentId; const patch: Record = {}; if ((item.parent_id ?? null) !== nextParentId) patch.parent_id = nextParentId; if ((item.sort_order ?? null) !== nextSortOrder) patch.sort_order = nextSortOrder; // 说明:只强制更新被移动节点的 updated_at,避免拖拽一次导致大量节点更新时间变化。 if (movedId && item._id === movedId) patch.updated_at = ts; if (Object.keys(patch).length > 0) { await ctx.db.patch(item._id, patch); } } }; const fromParentId = (doc.parent_id ?? null) as string | null; if (fromParentId === toParentId) { const siblings = await fetchSiblings(toParentId); const list = siblings.filter((d) => d.id !== doc.id); const position = clampIndex(args.sortOrder, list.length); list.splice(position, 0, doc); await applyOrder(list, toParentId, doc._id); return { ok: true, parent_id: toParentId, sort_order: position, workspace_id: doc.workspace_id, updated_at: ts, }; } // 先重排原父节点,确保移动后原列表连续。 const oldSiblings = (await fetchSiblings(fromParentId)).filter((d) => d.id !== doc.id); await applyOrder(oldSiblings, fromParentId); // 再重排目标父节点,把节点插入到目标位置。 const newSiblings = (await fetchSiblings(toParentId)).filter((d) => d.id !== doc.id); const position = clampIndex(args.sortOrder, newSiblings.length); newSiblings.splice(position, 0, doc); await applyOrder(newSiblings, toParentId, doc._id); return { ok: true, parent_id: toParentId, sort_order: position, workspace_id: doc.workspace_id, updated_at: ts, }; }, }); export const softDelete = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); // 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。 const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); let moved = 0; for (const item of subtree) { if (item.deleted_at != null) continue; await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); // 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。 // 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。 await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); moved += 1; } return { ok: true, moved, deleted_at: ts }; }, }); export const restore = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); const restoreDeletedAt = doc.deleted_at; await ctx.db.patch(doc._id, { deleted_at: null, deleted_by: null, parent_id: null, access_scope: "private", updated_at: ts, }); // 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。 if (restoreDeletedAt != null) { const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); for (const item of subtree) { if (item.id === doc.id) continue; if (item.deleted_at !== restoreDeletedAt) continue; await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts }); } } return { ok: true, updated_at: ts, document: toDocumentDeltaRecord({ ...doc, deleted_at: null, deleted_by: null, parent_id: null, access_scope: "private", updated_at: ts, }), }; }, }); export const purge = mutation({ args: { id: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); // 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。 const allInWorkspace = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) .collect(); const owned = allInWorkspace.filter((d) => d.user_id === userId); const subtree = collectSubtree(owned, doc.id); // 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。 await purgeDocumentRelatedData(ctx, doc.workspace_id, subtree.map((d) => d.id)); for (const item of subtree) { await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); await ctx.db.delete(item._id); } return { ok: true, deletedCount: subtree.length }; }, }); export const emptyTrashByWorkspace = mutation({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); // 说明:阶段 4/5 先用"membership 存在即可"的规则,避免引入复杂权限模型。 const membership = await ctx.db .query("workspace_members") .withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) .first(); if (!membership) { throw new Error("无权操作该工作空间"); } const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null); await purgeDocumentRelatedData(ctx, args.workspaceId, toDelete.map((d) => d.id)); for (const d of toDelete) { await purgeDocumentShareRelations(ctx, args.workspaceId, d.id); await ctx.db.delete(d._id); } return { ok: true, deletedCount: toDelete.length }; }, }); export const updateOptions = mutation({ args: { id: v.string(), options: v.object({ wideLayout: v.optional(v.boolean()), smallText: v.optional(v.boolean()), showHeadingNumbers: v.optional(v.boolean()), showToc: v.optional(v.boolean()), showStructure: v.optional(v.boolean()), protectEditing: v.optional(v.boolean()), showWordCount: v.optional(v.boolean()), collapseBacklinks: v.optional(v.boolean()), pageFont: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"))), layoutDensity: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"))), hideChildPages: v.optional(v.boolean()), showBlockRefCount: v.optional(v.boolean()), embedDefaultBlockId: v.optional(v.union(v.string(), v.null())), }), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const patch: Record = {}; if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout; if (typeof args.options.smallText === "boolean") patch.use_small_text = args.options.smallText; if (typeof args.options.showHeadingNumbers === "boolean") patch.show_heading_numbers = args.options.showHeadingNumbers; if (typeof args.options.showToc === "boolean") patch.show_toc = args.options.showToc; if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure; if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing; if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount; if (typeof args.options.collapseBacklinks === "boolean") patch.collapse_backlinks = args.options.collapseBacklinks; if (typeof (args.options as any).pageFont === "string") patch.page_font = (args.options as any).pageFont; if (typeof (args.options as any).layoutDensity === "string") patch.layout_density = (args.options as any).layoutDensity; if (typeof (args.options as any).hideChildPages === "boolean") patch.hide_child_pages = (args.options as any).hideChildPages; if (typeof (args.options as any).showBlockRefCount === "boolean") patch.show_block_ref_count = (args.options as any).showBlockRefCount; if (typeof (args.options as any).embedDefaultBlockId === "string" || (args.options as any).embedDefaultBlockId === null) { patch.embed_default_block_id = (args.options as any).embedDefaultBlockId; } if (Object.keys(patch).length === 0) { throw new Error("缺少可更新的选项"); } const ts = nowIso(); await ctx.db.patch(doc._id, { ...patch, updated_at: ts }); return { ok: true, updated_at: ts }; }, }); export const updateStats = mutation({ args: { id: v.string(), wordCount: v.number(), characterCount: v.number(), blockCount: v.number(), todoTotal: v.optional(v.number()), todoDone: v.optional(v.number()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await getCanonicalDocumentByBusinessId(ctx, args.id); if (!doc) throw new Error("页面不存在"); if (doc.deleted_at != null) throw new Error("页面不存在"); await requireWorkspaceMember(ctx, doc.workspace_id, userId); if (doc.user_id !== userId) { if (doc.access_scope === "public") throw new Error("无权限"); const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); await ctx.db.patch(doc._id, { word_count: args.wordCount, character_count: args.characterCount, block_count: args.blockCount, todo_total_count: typeof args.todoTotal === "number" ? args.todoTotal : (doc as any).todo_total_count ?? 0, todo_done_count: typeof args.todoDone === "number" ? args.todoDone : (doc as any).todo_done_count ?? 0, updated_at: ts, }); return { ok: true, updated_at: ts }; }, }); export const duplicate = mutation({ args: { sourceId: v.string(), newId: v.string(), title: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const source = await getCanonicalDocumentByBusinessId(ctx, args.sourceId); if (!source) throw new Error("页面不存在或无权限访问"); if (source.user_id !== userId) throw new Error("页面不存在或无权限访问"); const existingTarget = await getCanonicalDocumentByBusinessId(ctx, args.newId); if (existingTarget && existingTarget.deleted_at == null) { throw new Error("目标页面已存在"); } const siblings = await ctx.db .query("documents") .withIndex("by_workspace_parent", (q) => q.eq("workspace_id", source.workspace_id).eq("parent_id", source.parent_id), ) .collect(); const sortOrder = siblings.filter((d) => d.deleted_at == null).length; const ts = nowIso(); const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题"; const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`; const rawText = extractTextFromDocumentContent(source.content ?? []); await ctx.db.insert("documents", { id: args.newId, user_id: userId, workspace_id: source.workspace_id, parent_id: source.parent_id, title, content: source.content ?? [], content_revision: 0, content_conflict_key: `${args.newId}:0`, raw_text: rawText, access_scope: source.access_scope, sort_order: sortOrder, is_starred: false, is_template: false, wide_layout: source.wide_layout ?? false, use_small_text: source.use_small_text ?? false, show_heading_numbers: source.show_heading_numbers ?? true, show_toc: source.show_toc ?? false, show_structure: source.show_structure ?? false, protect_editing: source.protect_editing ?? false, show_word_count: source.show_word_count ?? true, collapse_backlinks: (source as any).collapse_backlinks ?? false, page_font: (source as any).page_font ?? "default", layout_density: (source as any).layout_density ?? "normal", hide_child_pages: (source as any).hide_child_pages ?? false, show_block_ref_count: (source as any).show_block_ref_count ?? false, embed_default_block_id: (source as any).embed_default_block_id ?? null, word_count: source.word_count ?? 0, character_count: source.character_count ?? 0, block_count: source.block_count ?? 0, todo_total_count: (source as any).todo_total_count ?? 0, todo_done_count: (source as any).todo_done_count ?? 0, created_at: ts, updated_at: ts, deleted_at: null, deleted_by: null, }); return { id: args.newId, title, parent_id: source.parent_id ?? null, sort_order: sortOrder, is_starred: false, workspace_id: source.workspace_id, access_scope: source.access_scope, is_template: false, created_at: ts, updated_at: ts, }; }, }); export const duplicateWithMindmaps = mutation({ args: { sourceId: v.string(), newId: v.string(), title: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { const duplicated = await duplicateDocumentRecord(ctx, args); await copyMindmapsForDocument(ctx, args.sourceId, duplicated.id); return duplicated; }, }); export const copyTree = mutation({ args: { items: v.array( v.object({ documentId: v.string(), recursive: v.boolean(), }), ), targetParentId: v.union(v.string(), v.null()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const normalizedItems = args.items.filter((item) => Boolean(item?.documentId)); if (normalizedItems.length === 0) { throw new Error("items 为空"); } let workspaceId: string | null = null; if (args.targetParentId) { const targetDoc = await getCanonicalDocumentByBusinessId(ctx, args.targetParentId); if (!targetDoc) { throw new Error("目标页面不存在或无权限"); } if (targetDoc.deleted_at != null) { throw new Error("目标页面不存在或无权限"); } await assertDocumentVisibleToUser(ctx, targetDoc, userId, "目标页面不存在或无权限"); workspaceId = targetDoc.workspace_id; } const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId))); const allDocs: CopyTreeDocument[] = []; for (const sourceId of sourceIds) { const doc = await getCanonicalDocumentByBusinessId(ctx, sourceId) as CopyTreeDocument | null; if (!doc || doc.deleted_at != null || doc.user_id !== userId) { throw new Error("源页面不存在或无权限"); } allDocs.push(doc); if (!workspaceId) { workspaceId = doc.workspace_id; } else if (workspaceId !== doc.workspace_id) { throw new Error("源页面不在同一工作空间"); } } if (!workspaceId) { throw new Error("缺少目标工作空间"); } const workspaceDocs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", workspaceId)) .collect(); const ownedDocs = workspaceDocs.filter((d) => d.user_id === userId && d.deleted_at == null); const childrenByParent = new Map(); for (const doc of ownedDocs as CopyTreeDocument[]) { const list = childrenByParent.get(doc.parent_id ?? null) ?? []; list.push(doc); childrenByParent.set(doc.parent_id ?? null, list); } const existingTitleSetByParent = new Map>(); const seedTitleSet = (parent: string | null) => { if (existingTitleSetByParent.has(parent)) return; const titles = new Set(); (childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title))); existingTitleSetByParent.set(parent, titles); }; seedTitleSet(args.targetParentId); const newIdByOldId = new Map(); const copyQueue: Array<{ old: CopyTreeDocument; newParentId: string | null }> = []; const enqueueTree = (root: CopyTreeDocument, newParent: string | null, recursive: boolean) => { const visit = (node: CopyTreeDocument, parentNewId: string | null) => { const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`; newIdByOldId.set(node.id, newId); copyQueue.push({ old: node, newParentId: parentNewId }); if (!recursive) return; const children = getChildrenSorted(childrenByParent, node.id); children.forEach((child) => visit(child, newId)); }; visit(root, newParent); }; for (const item of normalizedItems) { const doc = allDocs.find((d) => d.id === item.documentId) ?? null; if (doc) { enqueueTree(doc, args.targetParentId, Boolean(item.recursive)); } } if (copyQueue.length === 0) { throw new Error("没有可复制的页面"); } const insertedDocs: Array<{ oldId: string; newId: string; title: string; document: ReturnType; }> = []; for (const item of copyQueue) { const newId = newIdByOldId.get(item.old.id)!; const parentId = item.newParentId; if (!existingTitleSetByParent.has(parentId)) { seedTitleSet(parentId); } const titleSet = existingTitleSetByParent.get(parentId) ?? new Set(); existingTitleSetByParent.set(parentId, titleSet); const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet); const created = await createDocumentRecord(ctx, { id: newId, workspaceId, parentId, title: newTitle, accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public", content: item.old.content ?? [], }); await copyMindmapsForDocument(ctx, item.old.id, newId); insertedDocs.push({ oldId: item.old.id, newId, title: newTitle, document: toDocumentDeltaRecord(created), }); } return { items: insertedDocs, }; }, }); export const listAllForCopy = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); return docs .filter((d) => d.user_id === userId) .filter((d) => d.deleted_at == null) .map((d) => ({ id: d.id, title: d.title ?? null, parent_id: d.parent_id ?? null, workspace_id: d.workspace_id, access_scope: d.access_scope, sort_order: d.sort_order ?? null, created_at: d.created_at ?? null, content: d.content ?? null, })); }, });