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:
@@ -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