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

613 lines
20 KiB
TypeScript
Raw Normal View History

2026-01-18 19:01:31 +08:00
import { internalQuery, mutation, query } from "./_generated/server";
2026-01-17 10:12:53 +08:00
import { v } from "convex/values";
2026-01-18 19:01:31 +08:00
import { getAuthUserId } from "@convex-dev/auth/server";
2026-01-17 10:12:53 +08:00
import { nowIso } from "./_utils/time";
2026-01-18 05:13:53 +08:00
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
2026-01-17 10:12:53 +08:00
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
2026-01-18 19:01:31 +08:00
async function requireUserId(ctx: any): Promise<string> {
const userId = await getAuthUserId(ctx);
if (userId === null) {
throw new Error("未登录");
}
return userId;
}
2026-01-17 10:12:53 +08:00
export const getMeta = query({
2026-01-18 19:01:31 +08:00
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({
2026-01-17 10:12:53 +08:00
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({
2026-01-18 19:01:31 +08:00
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({
2026-01-17 10:12:53 +08:00
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({
2026-01-18 19:01:31 +08:00
args: { workspaceId: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
return docs
2026-01-18 19:01:31 +08:00
.filter((d) => d.user_id === userId)
2026-01-17 10:12:53 +08:00
.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({
2026-01-18 19:01:31 +08:00
args: { workspaceId: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
2026-01-18 19:01:31 +08:00
.filter((d) => d.user_id === userId)
2026-01-17 10:12:53 +08:00
.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) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
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,
2026-01-18 19:01:31 +08:00
user_id: userId,
2026-01-17 10:12:53 +08:00
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({
2026-01-18 19:01:31 +08:00
args: { id: v.string(), content: v.any() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
2026-01-18 05:13:53 +08:00
2026-01-18 19:01:31 +08:00
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
2026-01-18 05:13:53 +08:00
// 采用 debounce,避免频繁保存时触发过多任务。
2026-01-18 19:01:31 +08:00
await enqueueIngestDocumentJob(ctx, { userId, documentId: args.id, debounceMs: 1500 });
2026-01-17 10:12:53 +08:00
return { ok: true, updated_at: ts };
},
});
export const updateTitle = mutation({
2026-01-18 19:01:31 +08:00
args: { id: v.string(), title: v.union(v.string(), v.null()) },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
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) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-20 07:22:50 +08:00
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
2026-01-17 10:12:53 +08:00
const ts = nowIso();
2026-01-20 07:22:50 +08:00
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) {
// eslint-disable-next-line no-await-in-loop
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);
2026-01-17 10:12:53 +08:00
return { ok: true, updated_at: ts };
},
});
export const softDelete = mutation({
2026-01-18 19:01:31 +08:00
args: { id: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
const ts = nowIso();
2026-01-18 19:01:31 +08:00
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
2026-01-17 10:12:53 +08:00
return { ok: true };
},
});
export const restore = mutation({
2026-01-18 19:01:31 +08:00
args: { id: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
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({
2026-01-18 19:01:31 +08:00
args: { id: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
await ctx.db.delete(doc._id);
return { ok: true };
},
});
export const emptyTrashByWorkspace = mutation({
2026-01-18 19:01:31 +08:00
args: { workspaceId: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
// 说明:阶段 4/5 先用"membership 存在即可"的规则,避免引入复杂权限模型。
2026-01-17 10:12:53 +08:00
const membership = await ctx.db
.query("workspace_members")
2026-01-18 19:01:31 +08:00
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
2026-01-17 10:12:53 +08:00
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
2026-01-18 19:01:31 +08:00
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
2026-01-17 10:12:53 +08:00
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) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
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) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) throw new Error("页面不存在");
2026-01-18 19:01:31 +08:00
if (doc.user_id !== userId) throw new Error("无权限");
2026-01-17 10:12:53 +08:00
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) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const source = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
.first();
if (!source) throw new Error("页面不存在或无权限访问");
2026-01-18 19:01:31 +08:00
if (source.user_id !== userId) throw new Error("页面不存在或无权限访问");
2026-01-17 10:12:53 +08:00
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,
2026-01-18 19:01:31 +08:00
user_id: userId,
2026-01-17 10:12:53 +08:00
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({
2026-01-18 19:01:31 +08:00
args: { workspaceId: v.string() },
2026-01-17 10:12:53 +08:00
handler: async (ctx, args) => {
2026-01-18 19:01:31 +08:00
const userId = await requireUserId(ctx);
2026-01-17 10:12:53 +08:00
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
return docs
2026-01-18 19:01:31 +08:00
.filter((d) => d.user_id === userId)
2026-01-17 10:12:53 +08:00
.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,
}));
},
});