feat: continue tree rust family cutover
- add rust renderer/state-family scaffolds and inline compat host thinning for page tree, file tree, and picker - route tree/filetree preflight, file projection, resource artifact, and stream delta contracts through rust plans - preserve canonical move-order validation, file-tree search projection, and related frontend/runtime regression coverage
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
export type DocumentMoveOrderDocument = {
|
||||
id: string;
|
||||
parent_id?: string | null;
|
||||
sort_order?: number | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPatch = {
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
moved: boolean;
|
||||
};
|
||||
|
||||
export type DocumentMoveOrderPlan = {
|
||||
documentId: string;
|
||||
fromParentId: string | null;
|
||||
toParentId: string | null;
|
||||
requestedSortOrder: number;
|
||||
normalizedSortOrder: number;
|
||||
patches: DocumentMoveOrderPatch[];
|
||||
};
|
||||
|
||||
function normalizeParentId(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(value: number | null | undefined): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number {
|
||||
const orderA = normalizeSortOrder(a.sort_order);
|
||||
const orderB = normalizeSortOrder(b.sort_order);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
const createdA = String(a.created_at ?? "");
|
||||
const createdB = String(b.created_at ?? "");
|
||||
if (createdA !== createdB) return createdA.localeCompare(createdB);
|
||||
return a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
function clampMoveIndex(raw: number, max: number): number {
|
||||
const value = Number.isFinite(raw) ? Math.floor(raw) : 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendOrderPatches(
|
||||
patches: DocumentMoveOrderPatch[],
|
||||
ordered: DocumentMoveOrderDocument[],
|
||||
parentId: string | null,
|
||||
movedDocumentId: string,
|
||||
) {
|
||||
ordered.forEach((document, index) => {
|
||||
const moved = document.id === movedDocumentId;
|
||||
if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
patches.push({
|
||||
documentId: document.id,
|
||||
parentId,
|
||||
sortOrder: index,
|
||||
moved,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDocumentMoveOrderPlanFromDocuments(input: {
|
||||
documents: readonly DocumentMoveOrderDocument[];
|
||||
documentId: string;
|
||||
parentId: string | null;
|
||||
sortOrder: number;
|
||||
}): DocumentMoveOrderPlan {
|
||||
const source = input.documents.find((document) => document.id === input.documentId);
|
||||
if (!source) {
|
||||
throw new Error("源页面不存在或无权限");
|
||||
}
|
||||
|
||||
const fromParentId = normalizeParentId(source.parent_id);
|
||||
const toParentId = normalizeParentId(input.parentId);
|
||||
const siblingsByParent = new Map<string | null, DocumentMoveOrderDocument[]>();
|
||||
input.documents.forEach((document) => {
|
||||
const parentId = normalizeParentId(document.parent_id);
|
||||
const bucket = siblingsByParent.get(parentId);
|
||||
if (bucket) bucket.push(document);
|
||||
else siblingsByParent.set(parentId, [document]);
|
||||
});
|
||||
siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder));
|
||||
|
||||
const patches: DocumentMoveOrderPatch[] = [];
|
||||
let normalizedSortOrder = 0;
|
||||
|
||||
if (fromParentId === toParentId) {
|
||||
const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length);
|
||||
siblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, siblings, toParentId, input.documentId);
|
||||
} else {
|
||||
const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId);
|
||||
|
||||
const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId);
|
||||
normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length);
|
||||
newSiblings.splice(normalizedSortOrder, 0, source);
|
||||
appendOrderPatches(patches, newSiblings, toParentId, input.documentId);
|
||||
}
|
||||
|
||||
return {
|
||||
documentId: input.documentId,
|
||||
fromParentId,
|
||||
toParentId,
|
||||
requestedSortOrder: input.sortOrder,
|
||||
normalizedSortOrder,
|
||||
patches,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlan(value: unknown): DocumentMoveOrderPlan | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Partial<DocumentMoveOrderPlan>;
|
||||
if (
|
||||
typeof record.documentId !== "string" ||
|
||||
typeof record.requestedSortOrder !== "number" ||
|
||||
typeof record.normalizedSortOrder !== "number" ||
|
||||
!Array.isArray(record.patches)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
documentId: record.documentId,
|
||||
fromParentId: normalizeParentId(record.fromParentId),
|
||||
toParentId: normalizeParentId(record.toParentId),
|
||||
requestedSortOrder: record.requestedSortOrder,
|
||||
normalizedSortOrder: record.normalizedSortOrder,
|
||||
patches: record.patches.map((patch) => {
|
||||
const item = patch as Partial<DocumentMoveOrderPatch>;
|
||||
return {
|
||||
documentId: String(item.documentId ?? ""),
|
||||
parentId: normalizeParentId(item.parentId),
|
||||
sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1,
|
||||
moved: Boolean(item.moved),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDocumentMoveOrderPlanMatches(expected: unknown, actual: DocumentMoveOrderPlan) {
|
||||
const normalizedExpected = normalizePlan(expected);
|
||||
if (!normalizedExpected) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
|
||||
if (JSON.stringify(normalizedExpected) !== JSON.stringify(actual)) {
|
||||
throw new Error("Rust move plan 与 Convex 当前排序状态不一致");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { v } from "convex/values";
|
||||
import { requireUserId } from "./_utils/auth";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { buildParentById, collectSubtree, isAncestorOf } from "./_utils/documentTree";
|
||||
import {
|
||||
assertDocumentMoveOrderPlanMatches,
|
||||
buildDocumentMoveOrderPlanFromDocuments,
|
||||
} from "./_utils/documentMoveOrder";
|
||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||
import { extractTextFromDocumentContent } from "./_utils/text";
|
||||
import {
|
||||
@@ -216,6 +220,21 @@ async function createDocumentRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function toDocumentDeltaRecord(doc: any) {
|
||||
return {
|
||||
id: doc.id,
|
||||
workspace_id: doc.workspace_id,
|
||||
title: doc.title ?? null,
|
||||
parent_id: doc.parent_id ?? null,
|
||||
sort_order: doc.sort_order ?? null,
|
||||
is_starred: doc.is_starred ?? false,
|
||||
access_scope: (doc.access_scope ?? "private") as "private" | "shared" | "public",
|
||||
is_template: Boolean(doc.is_template),
|
||||
created_at: doc.created_at ?? nowIso(),
|
||||
updated_at: doc.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateDocumentContentRecord(
|
||||
ctx: any,
|
||||
args: {
|
||||
@@ -1198,7 +1217,7 @@ export const create = mutation({
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
return {
|
||||
const document = {
|
||||
id: args.id,
|
||||
title,
|
||||
parent_id: args.parentId,
|
||||
@@ -1210,6 +1229,10 @@ export const create = mutation({
|
||||
access_scope: args.accessScope,
|
||||
is_template: false,
|
||||
};
|
||||
return {
|
||||
...document,
|
||||
document: toDocumentDeltaRecord(document),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1376,6 +1399,7 @@ export const move = mutation({
|
||||
id: v.string(),
|
||||
parentId: v.union(v.string(), v.null()),
|
||||
sortOrder: v.number(),
|
||||
normalizedMove: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -1386,6 +1410,14 @@ export const move = mutation({
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
|
||||
const toParentId = args.parentId;
|
||||
const workspaceDocs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
|
||||
.collect();
|
||||
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
|
||||
.filter((row) => row.user_id === userId)
|
||||
.filter((row) => row.deleted_at == null);
|
||||
|
||||
if (toParentId === doc.id) {
|
||||
throw new Error("不能把页面移动到自身下面");
|
||||
}
|
||||
@@ -1398,19 +1430,24 @@ export const move = mutation({
|
||||
throw new Error("暂不支持跨工作空间移动页面");
|
||||
}
|
||||
|
||||
const workspaceDocs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id))
|
||||
.collect();
|
||||
const canonicalWorkspaceDocs = pickCanonicalDocumentRecordsByBusinessId(workspaceDocs)
|
||||
.filter((row) => row.user_id === userId)
|
||||
.filter((row) => row.deleted_at == null);
|
||||
const parentById = buildParentById(canonicalWorkspaceDocs);
|
||||
if (isAncestorOf(doc.id, toParentId, parentById)) {
|
||||
throw new Error("不能把页面移动到自己的后代下面");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.normalizedMove != null) {
|
||||
assertDocumentMoveOrderPlanMatches(
|
||||
args.normalizedMove,
|
||||
buildDocumentMoveOrderPlanFromDocuments({
|
||||
documents: canonicalWorkspaceDocs,
|
||||
documentId: doc.id,
|
||||
parentId: toParentId,
|
||||
sortOrder: args.sortOrder,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 说明:仅更新当前节点的 sort_order 会导致兄弟节点出现重复 sort_order,
|
||||
// 前端会按 sort_order + created_at 排序,结果常常表现为“拖拽无效/拖完又回弹”。
|
||||
// 这里统一对目标父节点(以及跨父移动时的原父节点)做重新编号,保证 sort_order 唯一且连续。
|
||||
@@ -1565,7 +1602,18 @@ export const restore = mutation({
|
||||
await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts });
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
return {
|
||||
ok: true,
|
||||
updated_at: ts,
|
||||
document: toDocumentDeltaRecord({
|
||||
...doc,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
parent_id: null,
|
||||
access_scope: "private",
|
||||
updated_at: ts,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1801,8 +1849,10 @@ export const duplicate = mutation({
|
||||
title,
|
||||
parent_id: source.parent_id ?? null,
|
||||
sort_order: sortOrder,
|
||||
is_starred: false,
|
||||
workspace_id: source.workspace_id,
|
||||
access_scope: source.access_scope,
|
||||
is_template: false,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
};
|
||||
@@ -1921,7 +1971,12 @@ export const copyTree = mutation({
|
||||
throw new Error("没有可复制的页面");
|
||||
}
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string; title: string }> = [];
|
||||
const insertedDocs: Array<{
|
||||
oldId: string;
|
||||
newId: string;
|
||||
title: string;
|
||||
document: ReturnType<typeof toDocumentDeltaRecord>;
|
||||
}> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
@@ -1935,7 +1990,7 @@ export const copyTree = mutation({
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
|
||||
await createDocumentRecord(ctx, {
|
||||
const created = await createDocumentRecord(ctx, {
|
||||
id: newId,
|
||||
workspaceId,
|
||||
parentId,
|
||||
@@ -1945,7 +2000,12 @@ export const copyTree = mutation({
|
||||
});
|
||||
|
||||
await copyMindmapsForDocument(ctx, item.old.id, newId);
|
||||
insertedDocs.push({ oldId: item.old.id, newId, title: newTitle });
|
||||
insertedDocs.push({
|
||||
oldId: item.old.id,
|
||||
newId,
|
||||
title: newTitle,
|
||||
document: toDocumentDeltaRecord(created),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -72,6 +72,201 @@ function shouldExtractAttachmentText(args: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function splitExtension(fileName: string): { base: string; ext: string } {
|
||||
const safe = fileName.trim();
|
||||
const lastDot = safe.lastIndexOf(".");
|
||||
if (lastDot <= 0 || lastDot === safe.length - 1) {
|
||||
return { base: safe, ext: "" };
|
||||
}
|
||||
return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) };
|
||||
}
|
||||
|
||||
function makeUniqueFileName(fileName: string, existing: Set<string>): string {
|
||||
const safe = (fileName.trim() || "附件").replace(/[\\/]/g, "_");
|
||||
if (!existing.has(safe)) {
|
||||
existing.add(safe);
|
||||
return safe;
|
||||
}
|
||||
const { base, ext } = splitExtension(safe);
|
||||
const first = `${base} 副本${ext}`;
|
||||
if (!existing.has(first)) {
|
||||
existing.add(first);
|
||||
return first;
|
||||
}
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const candidate = `${base} 副本 ${i}${ext}`;
|
||||
if (!existing.has(candidate)) {
|
||||
existing.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const fallback = `${base} 副本 ${Date.now()}${ext}`;
|
||||
existing.add(fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized || out.includes(normalized)) {
|
||||
continue;
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validateResourceTransferPlan(args: {
|
||||
action: "copy" | "move";
|
||||
assetIds: string[];
|
||||
targetDocumentId: string;
|
||||
targetSubPath?: string | null;
|
||||
resourceTransferPlan?: any;
|
||||
}) {
|
||||
const plan = args.resourceTransferPlan;
|
||||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.action !== args.action) {
|
||||
throw new Error("资源操作计划不一致");
|
||||
}
|
||||
if (String(plan.targetDocumentId ?? "").trim() !== args.targetDocumentId) {
|
||||
throw new Error("资源目标页面计划不一致");
|
||||
}
|
||||
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
|
||||
const actualSubPath = String(args.targetSubPath ?? "").trim();
|
||||
if (plannedSubPath !== actualSubPath) {
|
||||
throw new Error("资源目标子路径计划不一致");
|
||||
}
|
||||
const plannedAssetIds = Array.isArray(plan.assetIds)
|
||||
? uniqueStrings(plan.assetIds.map((value: unknown) => String(value ?? "")))
|
||||
: [];
|
||||
if (plannedAssetIds.length !== args.assetIds.length) {
|
||||
throw new Error("资源列表计划不一致");
|
||||
}
|
||||
for (let i = 0; i < args.assetIds.length; i += 1) {
|
||||
if (plannedAssetIds[i] !== args.assetIds[i]) {
|
||||
throw new Error("资源列表计划不一致");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateResourceUploadPlan(args: {
|
||||
asset: {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
asset_type: string;
|
||||
file_name?: string | null;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
};
|
||||
targetSubPath?: string | null;
|
||||
resourceUploadPlan?: any;
|
||||
}) {
|
||||
const plan = args.resourceUploadPlan;
|
||||
if (!plan || typeof plan !== "object") {
|
||||
return;
|
||||
}
|
||||
if (plan.action !== "upload") {
|
||||
throw new Error("Rust resource upload plan action 不一致");
|
||||
}
|
||||
if (String(plan.assetId ?? "").trim() !== args.asset.id) {
|
||||
throw new Error("Rust resource upload plan assetId 不一致");
|
||||
}
|
||||
if (String(plan.workspaceId ?? "").trim() !== args.asset.workspace_id) {
|
||||
throw new Error("Rust resource upload plan workspaceId 不一致");
|
||||
}
|
||||
if (String(plan.targetDocumentId ?? "").trim() !== args.asset.document_id) {
|
||||
throw new Error("Rust resource upload plan targetDocumentId 不一致");
|
||||
}
|
||||
const plannedSubPath = String(plan.targetSubPath ?? "").trim();
|
||||
const actualSubPath = String(args.targetSubPath ?? "").trim();
|
||||
if (plannedSubPath !== actualSubPath) {
|
||||
throw new Error("Rust resource upload plan targetSubPath 不一致");
|
||||
}
|
||||
if (String(plan.assetType ?? "").trim() !== args.asset.asset_type) {
|
||||
throw new Error("Rust resource upload plan assetType 不一致");
|
||||
}
|
||||
const plannedName = String(plan.fileName ?? "").trim();
|
||||
const actualName = String(args.asset.file_name ?? "").trim();
|
||||
if (plannedName !== actualName) {
|
||||
throw new Error("Rust resource upload plan fileName 不一致");
|
||||
}
|
||||
if (typeof plan.fileSize === "number" && plan.fileSize !== args.asset.file_size) {
|
||||
throw new Error("Rust resource upload plan fileSize 不一致");
|
||||
}
|
||||
const plannedMime = String(plan.mimeType ?? "").trim();
|
||||
const actualMime = String(args.asset.mime_type ?? "").trim();
|
||||
if (plannedMime !== actualMime) {
|
||||
throw new Error("Rust resource upload plan mimeType 不一致");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransferAssets(ctx: MutationCtx, userId: string, assetIds: string[]) {
|
||||
const assets: any[] = [];
|
||||
for (const assetId of assetIds) {
|
||||
const row = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_asset_id", (q) => q.eq("id", assetId))
|
||||
.first();
|
||||
if (!row || row.deleted_at || row.purged_at) {
|
||||
continue;
|
||||
}
|
||||
await assertWorkspaceMember(ctx, userId, row.workspace_id);
|
||||
assets.push(row);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
async function loadExistingNames(ctx: MutationCtx, documentId: string) {
|
||||
const existingRows = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_document", (q) => q.eq("document_id", documentId))
|
||||
.collect();
|
||||
return new Set<string>(
|
||||
existingRows.map((row) => String(row.file_name ?? "")).filter((name) => name.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveTransferTarget(ctx: MutationCtx, userId: string, targetDocumentId: string) {
|
||||
const targetDoc = await getCanonicalDocumentByBusinessId<any>(ctx, targetDocumentId);
|
||||
if (!targetDoc) {
|
||||
throw new Error("目标页面不存在");
|
||||
}
|
||||
await assertWorkspaceMember(ctx, userId, targetDoc.workspace_id);
|
||||
return targetDoc;
|
||||
}
|
||||
|
||||
function buildTransferredAssetResult(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id,
|
||||
document_id: row.document_id,
|
||||
asset_type: row.asset_type,
|
||||
file_url: row.file_url ?? null,
|
||||
thumbnail_url: row.thumbnail_url ?? row.file_url ?? null,
|
||||
storage_id: row.storage_id ?? null,
|
||||
bucket: row.bucket ?? null,
|
||||
storage_path: row.storage_path ?? null,
|
||||
file_name: row.file_name ?? null,
|
||||
file_size: row.file_size ?? null,
|
||||
mime_type: row.mime_type ?? null,
|
||||
ocr_text: row.ocr_text ?? null,
|
||||
ocr_status: row.ocr_status ?? null,
|
||||
ocr_payload: row.ocr_payload,
|
||||
ocr_strategy: row.ocr_strategy ?? null,
|
||||
deleted_at: row.deleted_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: row.signed_url ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export const getById = query({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -290,6 +485,8 @@ export const createWithStorage = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
storageId: v.id("_storage"),
|
||||
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||
resourceUploadPlan: v.optional(v.any()),
|
||||
asset: v.object({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
@@ -302,6 +499,11 @@ export const createWithStorage = mutation({
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await assertWorkspaceMember(ctx, args.userId, args.asset.workspace_id);
|
||||
validateResourceUploadPlan({
|
||||
asset: args.asset,
|
||||
targetSubPath: args.targetSubPath,
|
||||
resourceUploadPlan: args.resourceUploadPlan,
|
||||
});
|
||||
|
||||
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, args.asset.document_id);
|
||||
|
||||
@@ -324,7 +526,7 @@ export const createWithStorage = mutation({
|
||||
thumbnail_url: url,
|
||||
storage_id: args.storageId,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
storage_path: args.targetSubPath ? `${args.targetSubPath}/${args.asset.file_name ?? args.asset.id}` : null,
|
||||
file_name: args.asset.file_name,
|
||||
file_size: args.asset.file_size,
|
||||
mime_type: args.asset.mime_type,
|
||||
@@ -360,6 +562,128 @@ export const createWithStorage = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const batchCopy = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
assetIds: v.array(v.string()),
|
||||
targetDocumentId: v.string(),
|
||||
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||
resourceTransferPlan: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const assetIds = uniqueStrings(args.assetIds);
|
||||
if (assetIds.length === 0) {
|
||||
throw new Error("缺少附件");
|
||||
}
|
||||
|
||||
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
|
||||
validateResourceTransferPlan({
|
||||
action: "copy",
|
||||
assetIds,
|
||||
targetDocumentId: args.targetDocumentId,
|
||||
targetSubPath: args.targetSubPath,
|
||||
resourceTransferPlan: args.resourceTransferPlan,
|
||||
});
|
||||
|
||||
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
|
||||
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
|
||||
const items: any[] = [];
|
||||
const ts = nowIso();
|
||||
|
||||
for (const asset of assets) {
|
||||
const storageId = (asset.storage_id as any) ?? null;
|
||||
if (!storageId) {
|
||||
continue;
|
||||
}
|
||||
const id =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
|
||||
const row = {
|
||||
id,
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(args.targetDocumentId),
|
||||
asset_type: String(asset.asset_type ?? "file"),
|
||||
file_url: asset.file_url ?? null,
|
||||
thumbnail_url: asset.thumbnail_url ?? asset.file_url ?? null,
|
||||
storage_id: storageId,
|
||||
bucket: asset.bucket ?? null,
|
||||
storage_path: asset.storage_path ?? null,
|
||||
file_name: fileName,
|
||||
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
||||
mime_type: (asset.mime_type ?? null) as any,
|
||||
ocr_text: null,
|
||||
ocr_status: shouldExtractAttachmentText({
|
||||
assetType: asset.asset_type,
|
||||
mimeType: asset.mime_type,
|
||||
fileName,
|
||||
})
|
||||
? "queued"
|
||||
: null,
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
created_by: args.userId,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
};
|
||||
await ctx.db.insert("media_assets", row);
|
||||
if (row.ocr_status === "queued") {
|
||||
await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: id, debounceMs: 800 });
|
||||
}
|
||||
items.push(buildTransferredAssetResult(row));
|
||||
}
|
||||
|
||||
return { items };
|
||||
},
|
||||
});
|
||||
|
||||
export const batchMove = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
assetIds: v.array(v.string()),
|
||||
targetDocumentId: v.string(),
|
||||
targetSubPath: v.optional(v.union(v.string(), v.null())),
|
||||
resourceTransferPlan: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const assetIds = uniqueStrings(args.assetIds);
|
||||
if (assetIds.length === 0) {
|
||||
throw new Error("缺少附件");
|
||||
}
|
||||
|
||||
const targetDoc = await resolveTransferTarget(ctx, args.userId, args.targetDocumentId);
|
||||
validateResourceTransferPlan({
|
||||
action: "move",
|
||||
assetIds,
|
||||
targetDocumentId: args.targetDocumentId,
|
||||
targetSubPath: args.targetSubPath,
|
||||
resourceTransferPlan: args.resourceTransferPlan,
|
||||
});
|
||||
|
||||
const assets = await loadTransferAssets(ctx, args.userId, assetIds);
|
||||
const existingNames = await loadExistingNames(ctx, args.targetDocumentId);
|
||||
const items: any[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const fileName = makeUniqueFileName(String(asset.file_name ?? "附件"), existingNames);
|
||||
const patch = {
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(args.targetDocumentId),
|
||||
file_name: fileName,
|
||||
updated_at: nowIso(),
|
||||
};
|
||||
await ctx.db.patch(asset._id, patch);
|
||||
items.push(buildTransferredAssetResult({ ...asset, ...patch }));
|
||||
}
|
||||
|
||||
return { items };
|
||||
},
|
||||
});
|
||||
|
||||
export const patchById = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
|
||||
Reference in New Issue
Block a user