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-22 18:53:20 +08:00
|
|
|
|
import { collectSubtree } from "./_utils/documentTree";
|
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-22 18:53:20 +08:00
|
|
|
|
type SharePermission = "read" | "edit";
|
|
|
|
|
|
|
|
|
|
|
|
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 resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | 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 direct.permission as SharePermission;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 parentShare.permission as SharePermission;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const parent = await ctx.db
|
|
|
|
|
|
.query("documents")
|
|
|
|
|
|
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
|
|
|
|
|
|
.first();
|
|
|
|
|
|
parentId = parent?.parent_id ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | 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;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
const perm = await permissionFromGroup(documentId, gid);
|
|
|
|
|
|
if (perm === "edit") {
|
|
|
|
|
|
best = "edit";
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
best = best ?? "read";
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 当前页面是否被群组公开
|
|
|
|
|
|
if (await checkDocId(doc.id, false)) return "edit";
|
|
|
|
|
|
|
|
|
|
|
|
// 沿父链查找“包含子页面”的群组公开
|
|
|
|
|
|
let parentId: string | null = doc.parent_id ?? null;
|
|
|
|
|
|
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
|
|
|
|
|
if (await checkDocId(parentId, true)) return "edit";
|
|
|
|
|
|
|
|
|
|
|
|
const parent = await ctx.db
|
|
|
|
|
|
.query("documents")
|
|
|
|
|
|
.withIndex("by_document_id", (q: any) => q.eq("id", parentId))
|
|
|
|
|
|
.first();
|
|
|
|
|
|
parentId = parent?.parent_id ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return best;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
if (doc.deleted_at != null) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let canEdit = false;
|
|
|
|
|
|
if (doc.user_id === userId) {
|
|
|
|
|
|
canEdit = true;
|
|
|
|
|
|
} else if (doc.access_scope === "public") {
|
|
|
|
|
|
canEdit = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
|
|
|
|
|
if (!perm) return null;
|
|
|
|
|
|
canEdit = perm === "edit";
|
|
|
|
|
|
}
|
2026-01-18 19:01:31 +08:00
|
|
|
|
return {
|
|
|
|
|
|
id: doc.id,
|
|
|
|
|
|
user_id: doc.user_id,
|
|
|
|
|
|
workspace_id: doc.workspace_id,
|
|
|
|
|
|
access_scope: doc.access_scope,
|
2026-01-22 18:53:20 +08:00
|
|
|
|
can_edit: canEdit,
|
2026-01-18 19:01:31 +08:00
|
|
|
|
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;
|
2026-01-22 18:53:20 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (doc.access_scope === "public") {
|
|
|
|
|
|
return { content: doc.content ?? null };
|
|
|
|
|
|
}
|
|
|
|
|
|
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
|
|
|
|
|
if (!perm) return null;
|
2026-01-18 19:01:31 +08:00
|
|
|
|
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-22 18:53:20 +08:00
|
|
|
|
// workspace 权限
|
|
|
|
|
|
try {
|
|
|
|
|
|
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2026-01-22 18:53:20 +08:00
|
|
|
|
// 说明:这里仅用于侧边栏数据(不返回垃圾桶 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);
|
|
|
|
|
|
})
|
2026-01-17 10:12:53 +08:00
|
|
|
|
.map((d) => ({
|
2026-01-22 18:53:20 +08:00
|
|
|
|
access_scope:
|
|
|
|
|
|
d.user_id === userId ? d.access_scope : d.access_scope === "public" ? "public" : "shared",
|
2026-01-17 10:12:53 +08:00
|
|
|
|
id: d.id,
|
|
|
|
|
|
workspace_id: d.workspace_id,
|
|
|
|
|
|
title: d.title ?? "无标题",
|
|
|
|
|
|
parent_id: d.parent_id ?? null,
|
|
|
|
|
|
sort_order: d.sort_order ?? null,
|
2026-01-22 18:53:20 +08:00
|
|
|
|
is_starred: Boolean(starredDocIds.has(d.id) || d.is_starred),
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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-22 18:53:20 +08:00
|
|
|
|
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("无权限");
|
|
|
|
|
|
}
|
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-22 18:53:20 +08:00
|
|
|
|
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, 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-22 18:53:20 +08:00
|
|
|
|
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("无权限");
|
|
|
|
|
|
}
|
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) {
|
2026-01-21 18:21:10 +08:00
|
|
|
|
|
2026-01-20 07:22:50 +08:00
|
|
|
|
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-22 18:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。
|
|
|
|
|
|
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 });
|
|
|
|
|
|
moved += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { ok: true, moved, deleted_at: ts };
|
2026-01-17 10:12:53 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-01-22 18:53:20 +08:00
|
|
|
|
const restoreDeletedAt = doc.deleted_at;
|
2026-01-17 10:12:53 +08:00
|
|
|
|
await ctx.db.patch(doc._id, {
|
|
|
|
|
|
deleted_at: null,
|
|
|
|
|
|
deleted_by: null,
|
|
|
|
|
|
parent_id: null,
|
|
|
|
|
|
access_scope: "private",
|
|
|
|
|
|
updated_at: ts,
|
|
|
|
|
|
});
|
2026-01-22 18:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-17 10:12:53 +08:00
|
|
|
|
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-22 18:53:20 +08:00
|
|
|
|
|
|
|
|
|
|
// 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。
|
|
|
|
|
|
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) {
|
|
|
|
|
|
await ctx.db.delete(item._id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { ok: true, deletedCount: subtree.length };
|
2026-01-17 10:12:53 +08:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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-22 18:53:20 +08:00
|
|
|
|
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("无权限");
|
|
|
|
|
|
}
|
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-22 18:53:20 +08:00
|
|
|
|
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("无权限");
|
|
|
|
|
|
}
|
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,
|
|
|
|
|
|
}));
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|