1982 lines
68 KiB
TypeScript
1982 lines
68 KiB
TypeScript
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 { 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,
|
||
};
|
||
}
|
||
|
||
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,
|
||
});
|
||
|
||
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,
|
||
};
|
||
},
|
||
});
|
||
|
||
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(),
|
||
},
|
||
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;
|
||
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 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);
|
||
const parentById = buildParentById(canonicalWorkspaceDocs);
|
||
if (isAncestorOf(doc.id, toParentId, parentById)) {
|
||
throw new Error("不能把页面移动到自己的后代下面");
|
||
}
|
||
}
|
||
|
||
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
||
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
||
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
||
const ts = nowIso();
|
||
|
||
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 };
|
||
},
|
||
});
|
||
|
||
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,
|
||
workspace_id: source.workspace_id,
|
||
access_scope: source.access_scope,
|
||
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 }> = [];
|
||
|
||
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);
|
||
|
||
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 });
|
||
}
|
||
|
||
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,
|
||
}));
|
||
},
|
||
});
|