Files
mnote/wolai-frontend/convex/documents.ts
T
2026-01-21 18:21:10 +08:00

613 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new Error("未登录");
}
return userId;
}
export const getMeta = query({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== 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 getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== userId) return null;
return { content: doc.content ?? null };
},
});
export const getContentForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.user_id !== args.userId) return null;
return { content: doc.content ?? null };
},
});
export const listByWorkspace = 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();
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
return docs
.filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null)
.map((d) => ({
access_scope: d.access_scope,
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: d.is_starred ?? null,
is_template: d.is_template ?? false,
created_at: d.created_at,
updated_at: d.updated_at ?? 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 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;
await ctx.db.insert("documents", {
id: args.id,
user_id: userId,
workspace_id: args.workspaceId,
parent_id: args.parentId,
title,
content,
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,
word_count: 0,
character_count: 0,
block_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 updateContent = mutation({
args: { id: v.string(), content: v.any() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
await enqueueIngestDocumentJob(ctx, { userId, documentId: args.id, debounceMs: 1500 });
return { ok: true, updated_at: ts };
},
});
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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 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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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;
const toParentId = args.parentId;
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, 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, updated_at: ts };
},
});
export const softDelete = mutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
return { ok: true };
},
});
export const restore = mutation({
args: { id: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, {
deleted_at: null,
deleted_by: null,
parent_id: null,
access_scope: "private",
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
await ctx.db.delete(doc._id);
return { ok: true };
},
});
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);
for (const d of toDelete) {
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()),
}),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) 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 (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(),
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
if (doc.user_id !== userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, {
word_count: args.wordCount,
character_count: args.characterCount,
block_count: args.blockCount,
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 ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
.first();
if (!source) throw new Error("页面不存在或无权限访问");
if (source.user_id !== userId) 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} 副本`;
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 ?? [],
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,
word_count: source.word_count ?? 0,
character_count: source.character_count ?? 0,
block_count: source.block_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 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,
}));
},
});