feat: 完成 rust cutover phase 8 收口
This commit is contained in:
@@ -14,6 +14,61 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
|
||||
return member;
|
||||
}
|
||||
|
||||
function sortByNewest<T extends Record<string, any>>(rows: T[]) {
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftTime = String(left.created_at ?? left.finished_at ?? "");
|
||||
const rightTime = String(right.created_at ?? right.finished_at ?? "");
|
||||
return rightTime.localeCompare(leftTime);
|
||||
});
|
||||
}
|
||||
|
||||
function decodeCursor(raw: string | null | undefined) {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const decoded = JSON.parse(raw) as {
|
||||
createdAt?: string | null;
|
||||
id?: string | null;
|
||||
};
|
||||
const createdAt = typeof decoded.createdAt === "string" ? decoded.createdAt : "";
|
||||
const id = typeof decoded.id === "string" ? decoded.id : "";
|
||||
if (!createdAt || !id) return null;
|
||||
return { createdAt, id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(row: { created_at?: string | null; id?: string | null } | null) {
|
||||
if (!row?.created_at || !row?.id) return null;
|
||||
return JSON.stringify({
|
||||
createdAt: row.created_at,
|
||||
id: row.id,
|
||||
});
|
||||
}
|
||||
|
||||
function matchesCursor<T extends Record<string, any>>(
|
||||
row: T,
|
||||
cursor: { createdAt: string; id: string } | null,
|
||||
) {
|
||||
if (!cursor) return true;
|
||||
const createdAt = String(row.created_at ?? row.finished_at ?? "");
|
||||
const id = String(row.id ?? "");
|
||||
if (!createdAt || !id) return false;
|
||||
if (createdAt < cursor.createdAt) return true;
|
||||
if (createdAt > cursor.createdAt) return false;
|
||||
return id < cursor.id;
|
||||
}
|
||||
|
||||
function normalizeStatusFilter(raw: string | null | undefined) {
|
||||
const normalized = String(raw ?? "").trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeObjectFilter(raw: string | null | undefined) {
|
||||
const normalized = String(raw ?? "").trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export const recordCommandLog = mutation({
|
||||
args: {
|
||||
workspaceId: v.string(),
|
||||
@@ -171,3 +226,107 @@ export const listByRequest = query({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listByCommand = query({
|
||||
args: { workspaceId: v.string(), commandId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
|
||||
const commandLogs = await ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_command", (q) =>
|
||||
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
|
||||
)
|
||||
.collect();
|
||||
const domainEvents = await ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_command", (q) =>
|
||||
q.eq("workspace_id", args.workspaceId).eq("command_id", args.commandId),
|
||||
)
|
||||
.collect();
|
||||
|
||||
return {
|
||||
command_id: args.commandId,
|
||||
command_logs: commandLogs,
|
||||
domain_events: domainEvents,
|
||||
generated_at: nowIso(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const listWorkspaceOverview = query({
|
||||
args: {
|
||||
workspaceId: v.string(),
|
||||
limit: v.optional(v.number()),
|
||||
cursor: v.optional(v.union(v.string(), v.null())),
|
||||
commandStatus: v.optional(v.union(v.string(), v.null())),
|
||||
eventStatus: v.optional(v.union(v.string(), v.null())),
|
||||
targetPageId: v.optional(v.union(v.string(), v.null())),
|
||||
targetBlockId: v.optional(v.union(v.string(), v.null())),
|
||||
aggregateType: v.optional(v.union(v.string(), v.null())),
|
||||
aggregateId: v.optional(v.union(v.string(), v.null())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||
|
||||
const limit = Math.max(1, Math.min(100, Math.floor(args.limit ?? 50)));
|
||||
const cursor = decodeCursor(args.cursor ?? null);
|
||||
const commandStatus = normalizeStatusFilter(args.commandStatus);
|
||||
const eventStatus = normalizeStatusFilter(args.eventStatus);
|
||||
const targetPageId = normalizeObjectFilter(args.targetPageId);
|
||||
const targetBlockId = normalizeObjectFilter(args.targetBlockId);
|
||||
const aggregateType = normalizeObjectFilter(args.aggregateType);
|
||||
const aggregateId = normalizeObjectFilter(args.aggregateId);
|
||||
|
||||
const allCommandLogs = await ctx.db
|
||||
.query("command_logs")
|
||||
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
const allDomainEvents = await ctx.db
|
||||
.query("domain_events")
|
||||
.withIndex("by_workspace_request", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
const filteredCommandLogs = sortByNewest(
|
||||
allCommandLogs.filter((row: any) => {
|
||||
if (commandStatus && row.status !== commandStatus) return false;
|
||||
if (targetPageId && String(row.target_page_id ?? "") !== targetPageId) return false;
|
||||
if (targetBlockId && String(row.target_block_id ?? "") !== targetBlockId) return false;
|
||||
return matchesCursor(row, cursor);
|
||||
}),
|
||||
);
|
||||
|
||||
const pageCommandLogs = filteredCommandLogs.slice(0, limit);
|
||||
const nextCursor = encodeCursor(pageCommandLogs[pageCommandLogs.length - 1] ?? null);
|
||||
const commandIds = new Set(pageCommandLogs.map((row: any) => String(row.command_id)));
|
||||
|
||||
const domainEvents = sortByNewest(
|
||||
allDomainEvents.filter((row: any) => {
|
||||
if (commandIds.size > 0 && !commandIds.has(String(row.command_id ?? ""))) return false;
|
||||
if (eventStatus && row.status !== eventStatus) return false;
|
||||
if (aggregateType && String(row.aggregate_type ?? "") !== aggregateType) return false;
|
||||
if (aggregateId && String(row.aggregate_id ?? "") !== aggregateId) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
workspace_id: args.workspaceId,
|
||||
command_logs: pageCommandLogs,
|
||||
domain_events: domainEvents,
|
||||
next_cursor: nextCursor,
|
||||
has_more: filteredCommandLogs.length > pageCommandLogs.length,
|
||||
filters: {
|
||||
command_status: commandStatus,
|
||||
event_status: eventStatus,
|
||||
target_page_id: targetPageId,
|
||||
target_block_id: targetBlockId,
|
||||
aggregate_type: aggregateType,
|
||||
aggregate_id: aggregateId,
|
||||
},
|
||||
generated_at: nowIso(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -13,6 +14,352 @@ 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")
|
||||
@@ -862,6 +1209,55 @@ export const create = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
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(),
|
||||
@@ -1325,6 +1721,8 @@ export const duplicate = mutation({
|
||||
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,
|
||||
@@ -1369,6 +1767,151 @@ export const duplicate = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user