Files
mnote/wolai-frontend/convex/documents.ts
T

2066 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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>): 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<string, unknown>),
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<string | null, CopyTreeDocument[]>, 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<any>(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<any>(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<any>(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<SharePolicy | null> {
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<SharePermission | null> {
const policy = await resolveSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
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<string>(memberships.map((m: any) => String(m.group_id)));
if (groupIds.size === 0) return null;
const permissionFromGroup = async (documentId: string, groupId: string): Promise<SharePermission> => {
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<SharePermission | null> {
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<string>();
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<string>(assets.map((a) => String(a.id)));
const byStorage = new Map<string, string[]>();
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<string>();
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<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
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<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
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<string, boolean>();
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<string>(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<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
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<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
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<string, boolean>();
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<any>(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(),
treeWriteOperation: 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<any>(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 = assertDocumentMoveWriteOperationMatches(args.treeWriteOperation, currentMoveOrderPlan);
// 说明:仅更新当前节点的 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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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<any>(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<any>(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<string | null, CopyTreeDocument[]>();
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<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(args.targetParentId);
const newIdByOldId = new Map<string, string>();
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<typeof toDocumentDeltaRecord>;
}> = [];
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<string>();
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,
}));
},
});