strapi-plugin-bulk-updater 1.4.10 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,6 +108,22 @@ const routes = {
108
108
  policies: []
109
109
  }
110
110
  },
111
+ {
112
+ method: "GET",
113
+ path: "/batches/:batchId/diffs",
114
+ handler: "bulkUpdater.getPendingBatchDiffs",
115
+ config: {
116
+ policies: []
117
+ }
118
+ },
119
+ {
120
+ method: "GET",
121
+ path: "/batches/:batchId/diffs/export",
122
+ handler: "bulkUpdater.exportPendingBatchDiffs",
123
+ config: {
124
+ policies: []
125
+ }
126
+ },
111
127
  {
112
128
  method: "GET",
113
129
  path: "/audit/history",
@@ -471,6 +487,28 @@ const bulkUpdaterController = ({ strapi }) => ({
471
487
  ctx.body = { success: false, error: err.message };
472
488
  }
473
489
  },
490
+ async getPendingBatchDiffs(ctx) {
491
+ try {
492
+ const { batchId } = ctx.params;
493
+ const service = strapi.plugin("bulk-updater").service("bulkUpdater");
494
+ const result = await service.getPendingBatchDiffs(batchId);
495
+ ctx.body = result;
496
+ } catch (err) {
497
+ ctx.status = err.message?.includes("not found") ? 404 : 500;
498
+ ctx.body = { success: false, error: err.message };
499
+ }
500
+ },
501
+ async exportPendingBatchDiffs(ctx) {
502
+ try {
503
+ const { batchId } = ctx.params;
504
+ const service = strapi.plugin("bulk-updater").service("bulkUpdater");
505
+ const result = await service.getPendingBatchDiffsCsv(batchId);
506
+ ctx.body = result;
507
+ } catch (err) {
508
+ ctx.status = err.message?.includes("not found") ? 404 : 500;
509
+ ctx.body = { success: false, error: err.message };
510
+ }
511
+ },
474
512
  async getRecentBatches(ctx) {
475
513
  try {
476
514
  const service = strapi.plugin("bulk-updater").service("bulkUpdater");
@@ -1075,14 +1113,15 @@ function discoverComponentAttributes(strapi, compUid, prefix, currentDepth, maxD
1075
1113
  }
1076
1114
  function parseDynamicZonePath(path) {
1077
1115
  if (!path) return { cleanPath: "", rootKey: "", subPath: "" };
1078
- const bracketMatch = path.match(/^([^.[]+)\[([^\]]+)\]\.(.+)$/);
1116
+ const bracketMatch = path.match(/^([^.[]+)\[([^\]]+)\](?:\.(.+))?$/);
1079
1117
  if (bracketMatch) {
1080
1118
  const rootKey = bracketMatch[1];
1081
- const selector = bracketMatch[2];
1082
- const subPath = bracketMatch[3];
1083
- const cleanPath = `${rootKey}.${subPath}`;
1084
- if (/^\d+$/.test(selector)) {
1085
- return { cleanPath, rootKey, subPath, targetIndex: parseInt(selector, 10) };
1119
+ const selector = bracketMatch[2].trim();
1120
+ const subPath = bracketMatch[3] || "";
1121
+ const cleanPath = subPath ? `${rootKey}.${subPath}` : rootKey;
1122
+ const numMatch = selector.match(/^#?(\d+)/);
1123
+ if (numMatch) {
1124
+ return { cleanPath, rootKey, subPath, targetIndex: parseInt(numMatch[1], 10) };
1086
1125
  }
1087
1126
  return { cleanPath, rootKey, subPath, targetComponentUid: selector };
1088
1127
  }
@@ -2371,7 +2410,7 @@ const bulkUpdaterService = ({ strapi }) => {
2371
2410
  } else if (normOp === "endswith") {
2372
2411
  orConditions.push({ [k]: { $endsWithi: q } });
2373
2412
  } else if (normOp === "notequals") {
2374
- orConditions.push({ [k]: { $ne: q } });
2413
+ orConditions.push({ [k]: { $nei: q } });
2375
2414
  } else if (normOp === "notcontains") {
2376
2415
  orConditions.push({ [k]: { $notContainsi: q } });
2377
2416
  } else {
@@ -2389,12 +2428,21 @@ const bulkUpdaterService = ({ strapi }) => {
2389
2428
  } else if (normOp === "notempty") {
2390
2429
  condition = isStringLike ? { $and: [{ $notNull: true }, { $ne: "" }] } : { $notNull: true };
2391
2430
  } else if (q !== "") {
2431
+ let typedVal = q;
2432
+ const isNumeric = ["integer", "biginteger", "float", "decimal", "number"].includes(targetAttrSchema?.type);
2433
+ const isBoolean = targetAttrSchema?.type === "boolean";
2434
+ if (isNumeric && /^-?\d+(\.\d+)?$/.test(q)) {
2435
+ typedVal = Number(q);
2436
+ } else if (isBoolean) {
2437
+ if (q.toLowerCase() === "true") typedVal = true;
2438
+ else if (q.toLowerCase() === "false") typedVal = false;
2439
+ }
2392
2440
  switch (normOp) {
2393
2441
  case "equals":
2394
- condition = { $containsi: q };
2442
+ condition = isStringLike ? { $eqi: q } : { $eq: typedVal };
2395
2443
  break;
2396
2444
  case "notequals":
2397
- condition = { $notContainsi: q };
2445
+ condition = isStringLike ? { $nei: q } : { $ne: typedVal };
2398
2446
  break;
2399
2447
  case "contains":
2400
2448
  condition = { $containsi: q };
@@ -2403,10 +2451,10 @@ const bulkUpdaterService = ({ strapi }) => {
2403
2451
  condition = { $notContainsi: q };
2404
2452
  break;
2405
2453
  case "startswith":
2406
- condition = { $startsWith: q };
2454
+ condition = { $startsWithi: q };
2407
2455
  break;
2408
2456
  case "endswith":
2409
- condition = { $endsWith: q };
2457
+ condition = { $endsWithi: q };
2410
2458
  break;
2411
2459
  }
2412
2460
  }
@@ -2532,7 +2580,7 @@ const bulkUpdaterService = ({ strapi }) => {
2532
2580
  if (typeof v === "object") {
2533
2581
  return testSingleValue(v.name) || testSingleValue(v.title) || testSingleValue(v.email) || testSingleValue(v.username) || testSingleValue(v.documentId) || testSingleValue(v.id) || testSingleValue(v.slug);
2534
2582
  }
2535
- const valStr = String(v).toLowerCase();
2583
+ const valStr = String(v).trim().toLowerCase();
2536
2584
  switch (normOp) {
2537
2585
  case "equals":
2538
2586
  return valStr === qVal;
@@ -3616,6 +3664,198 @@ const bulkUpdaterService = ({ strapi }) => {
3616
3664
  batch
3617
3665
  };
3618
3666
  },
3667
+ /**
3668
+ * Inspect batch changes item-by-item for Maker-Checker 4-Eyes review
3669
+ */
3670
+ async getPendingBatchDiffs(batchId) {
3671
+ if (!batchId) throw new Error("batchId is required");
3672
+ const batch = pendingBatches.get(batchId);
3673
+ if (!batch) {
3674
+ throw new Error(`Pending batch "${batchId}" not found.`);
3675
+ }
3676
+ let previewRes = null;
3677
+ if (batch.payload?.action === "csv_import" || batch.id.startsWith("csv_")) {
3678
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
3679
+ previewRes = await csvService2.previewCsvBatch(
3680
+ batch.payload.uid,
3681
+ batch.payload.rows || [],
3682
+ batch.payload.options || {}
3683
+ );
3684
+ } else if (batch.payload?.action === "publish") {
3685
+ const selectedEntries = batch.payload.selectedEntries || [];
3686
+ const selectedCollections = batch.payload.selectedCollections || [];
3687
+ const targetsMap = /* @__PURE__ */ new Map();
3688
+ for (const colUid of selectedCollections) {
3689
+ if (!strapi.contentTypes[colUid]) continue;
3690
+ const entries = await strapi.documents(colUid).findMany();
3691
+ for (const entry of entries || []) {
3692
+ targetsMap.set(`${colUid}@@@${entry.documentId}`, { uid: colUid, documentId: entry.documentId });
3693
+ }
3694
+ }
3695
+ for (const item of selectedEntries) {
3696
+ if (!item.uid || !item.documentId) continue;
3697
+ targetsMap.set(`${item.uid}@@@${item.documentId}`, { uid: item.uid, documentId: item.documentId });
3698
+ }
3699
+ const diffs = [];
3700
+ let willUpdate = 0;
3701
+ let unchanged = 0;
3702
+ for (const target of targetsMap.values()) {
3703
+ const ct = strapi.contentTypes[target.uid];
3704
+ const displayName = ct?.info?.displayName || target.uid;
3705
+ try {
3706
+ const doc = await strapi.documents(target.uid).findOne({
3707
+ documentId: target.documentId
3708
+ });
3709
+ const label = doc ? doc.title || doc.name || doc.slug || doc.documentId : target.documentId;
3710
+ const isLive = Boolean(doc?.publishedAt) && doc?.status !== "modified";
3711
+ const currentStatus = isLive ? "published" : doc?.status === "modified" ? "modified draft" : "draft";
3712
+ const hasChange = !isLive;
3713
+ if (hasChange) willUpdate++;
3714
+ else unchanged++;
3715
+ diffs.push({
3716
+ uid: target.uid,
3717
+ collectionName: displayName,
3718
+ documentId: target.documentId,
3719
+ label,
3720
+ attribute: "status",
3721
+ oldValue: currentStatus,
3722
+ newValue: "published",
3723
+ oldDisplay: currentStatus,
3724
+ newDisplay: "published live",
3725
+ hasChange,
3726
+ status: hasChange ? "will_update" : "unchanged",
3727
+ action: "publish",
3728
+ message: hasChange ? "Record will be published live" : "Already published live"
3729
+ });
3730
+ } catch (err) {
3731
+ diffs.push({
3732
+ uid: target.uid,
3733
+ collectionName: displayName,
3734
+ documentId: target.documentId,
3735
+ label: target.documentId,
3736
+ attribute: "status",
3737
+ oldValue: null,
3738
+ newValue: null,
3739
+ oldDisplay: "—",
3740
+ newDisplay: "—",
3741
+ hasChange: false,
3742
+ status: "will_skip",
3743
+ action: "skip",
3744
+ message: `Error inspecting document: ${err.message}`
3745
+ });
3746
+ }
3747
+ }
3748
+ previewRes = {
3749
+ success: true,
3750
+ summary: {
3751
+ total: targetsMap.size,
3752
+ willUpdate,
3753
+ willCreate: 0,
3754
+ unchanged,
3755
+ skipped: 0
3756
+ },
3757
+ diffs
3758
+ };
3759
+ } else {
3760
+ const preview = await this.previewBatch({
3761
+ attribute: batch.payload.attribute,
3762
+ value: batch.payload.value,
3763
+ selectedEntries: batch.payload.selectedEntries,
3764
+ selectedCollections: batch.payload.selectedCollections,
3765
+ isRegex: batch.payload.isRegex,
3766
+ regexPattern: batch.payload.regexPattern
3767
+ });
3768
+ previewRes = {
3769
+ success: true,
3770
+ summary: {
3771
+ total: preview.summary?.total ?? 0,
3772
+ willUpdate: preview.summary?.willUpdate ?? 0,
3773
+ willCreate: 0,
3774
+ unchanged: preview.summary?.unchanged ?? 0,
3775
+ skipped: preview.summary?.skipped ?? 0
3776
+ },
3777
+ diffs: (preview.diffs || []).map((d) => ({
3778
+ ...d,
3779
+ action: "update"
3780
+ }))
3781
+ };
3782
+ }
3783
+ return {
3784
+ success: true,
3785
+ batchId: batch.id,
3786
+ createdAt: batch.createdAt,
3787
+ createdBy: batch.createdBy,
3788
+ makerNotes: batch.makerNotes,
3789
+ batchSummary: batch.summary,
3790
+ payload: {
3791
+ action: batch.payload?.action || "bulk_update",
3792
+ attribute: batch.payload?.attribute,
3793
+ value: batch.payload?.value,
3794
+ publishOnApproval: batch.summary?.publishOnApproval,
3795
+ publishStrategy: batch.summary?.publishStrategy
3796
+ },
3797
+ summary: previewRes.summary,
3798
+ diffs: previewRes.diffs
3799
+ };
3800
+ },
3801
+ /**
3802
+ * Export batch diffs to CSV for offline audit review
3803
+ */
3804
+ async getPendingBatchDiffsCsv(batchId) {
3805
+ const data = await this.getPendingBatchDiffs(batchId);
3806
+ function escapeVal(val) {
3807
+ if (val === null || val === void 0) return "";
3808
+ let str = typeof val === "object" ? JSON.stringify(val) : String(val);
3809
+ str = str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3810
+ if (/^[=+@-]$/.test(str.charAt(0))) {
3811
+ str = `'${str}`;
3812
+ }
3813
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
3814
+ return `"${str.replace(/"/g, '""')}"`;
3815
+ }
3816
+ return str;
3817
+ }
3818
+ const headers = [
3819
+ "Batch ID",
3820
+ "Action",
3821
+ "Collection UID",
3822
+ "Collection Name",
3823
+ "Document ID",
3824
+ "Record Identifier",
3825
+ "Attribute Name",
3826
+ "Original Value (Before)",
3827
+ "Proposed Value (After)",
3828
+ "Change Status",
3829
+ "Details / Message"
3830
+ ];
3831
+ const rows = [headers.join(",")];
3832
+ for (const diff of data.diffs || []) {
3833
+ const row = [
3834
+ escapeVal(data.batchId),
3835
+ escapeVal(diff.action || "update"),
3836
+ escapeVal(diff.uid),
3837
+ escapeVal(diff.collectionName),
3838
+ escapeVal(diff.documentId),
3839
+ escapeVal(diff.label),
3840
+ escapeVal(diff.attribute),
3841
+ escapeVal(diff.oldDisplay ?? diff.oldValue),
3842
+ escapeVal(diff.newDisplay ?? diff.newValue),
3843
+ escapeVal(
3844
+ diff.status === "will_update" ? "Will Update" : diff.status === "will_create" ? "Will Create" : diff.status === "unchanged" ? "Unchanged" : "Skipped"
3845
+ ),
3846
+ escapeVal(diff.message)
3847
+ ];
3848
+ rows.push(row.join(","));
3849
+ }
3850
+ const safeBatchId = batchId.replace(/[^a-zA-Z0-9_-]/g, "_");
3851
+ const filename = `pending_batch_review_${safeBatchId}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3852
+ return {
3853
+ success: true,
3854
+ filename,
3855
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
3856
+ count: data.diffs?.length || 0
3857
+ };
3858
+ },
3619
3859
  /**
3620
3860
  * Get recent batch jobs for the processing dashboard
3621
3861
  */
@@ -4034,42 +4274,74 @@ const auditService = ({ strapi }) => ({
4034
4274
  for (const item of diffs) {
4035
4275
  if (item.status === "updated" && item.uid && item.documentId && item.attribute) {
4036
4276
  try {
4277
+ const ct = strapi.contentTypes[item.uid];
4278
+ if (!ct) {
4279
+ throw new Error(`Content type "${item.uid}" does not exist in schema.`);
4280
+ }
4037
4281
  const isRel = item.oldValue && typeof item.oldValue === "object" && item.oldValue.isRelation;
4038
4282
  let rollbackPayload;
4039
4283
  if (isRel) {
4040
4284
  const oldDocIds = Array.isArray(item.oldValue.documentIds) ? item.oldValue.documentIds : [];
4041
4285
  rollbackPayload = { set: oldDocIds };
4042
4286
  } else {
4043
- rollbackPayload = item.oldValue;
4287
+ rollbackPayload = item.oldValue ?? null;
4044
4288
  }
4045
- if (item.attribute.includes(".")) {
4046
- const [parentKey, childKey] = item.attribute.split(".");
4047
- const ct2 = strapi.contentTypes[item.uid];
4048
- if (ct2?.attributes?.[parentKey]?.type === "component") {
4049
- const currentDoc = await strapi.documents(item.uid).findOne({
4050
- documentId: item.documentId,
4051
- populate: [parentKey]
4052
- });
4053
- const currentParent = currentDoc?.[parentKey] || {};
4054
- const restoredParent = {
4055
- ...currentParent,
4056
- [childKey]: rollbackPayload
4057
- };
4058
- await strapi.documents(item.uid).update({
4059
- documentId: item.documentId,
4060
- data: {
4061
- [parentKey]: restoredParent
4062
- }
4063
- });
4289
+ const parsed = parseDynamicZonePath(item.attribute);
4290
+ const rootKey = parsed.rootKey;
4291
+ const rootAttrDef = ct.attributes?.[rootKey];
4292
+ const isComponent = rootAttrDef?.type === "component";
4293
+ const isDynamicZone = rootAttrDef?.type === "dynamiczone";
4294
+ const isNested = item.attribute.includes(".") || item.attribute.includes("[") || isComponent || isDynamicZone || isDeepComponentField(ct, strapi.components, item.attribute);
4295
+ let wasPublished = false;
4296
+ if (isNested && (isComponent || isDynamicZone || rootAttrDef)) {
4297
+ const populateOption = buildDeepPopulate$1(ct.attributes, strapi.components);
4298
+ const findOneArgs = { documentId: item.documentId };
4299
+ if (populateOption) {
4300
+ findOneArgs.populate = populateOption;
4301
+ }
4302
+ const currentDoc = await strapi.documents(item.uid).findOne(findOneArgs);
4303
+ if (!currentDoc) {
4304
+ throw new Error(`Document "${item.documentId}" not found in collection "${item.uid}".`);
4305
+ }
4306
+ wasPublished = Boolean(currentDoc?.publishedAt);
4307
+ const isRootDz = isDynamicZone;
4308
+ const existingRoot = currentDoc?.[rootKey] || (isRootDz ? [] : {});
4309
+ let restoredRoot;
4310
+ if (Array.isArray(item.dynamicZoneDiffs) && item.dynamicZoneDiffs.length > 0) {
4311
+ let workingRoot = existingRoot;
4312
+ for (const dzDiff of item.dynamicZoneDiffs) {
4313
+ const parsedDz = parseDynamicZonePath(dzDiff.attribute || item.attribute);
4314
+ const dzParts = parsedDz.cleanPath.split(".").slice(1);
4315
+ const targetIndexes = dzDiff.index !== void 0 ? [dzDiff.index] : parsedDz.targetIndex !== void 0 ? [parsedDz.targetIndex] : void 0;
4316
+ workingRoot = setDeepProperty(workingRoot, dzParts, dzDiff.oldValue ?? null, {
4317
+ targetIndexes,
4318
+ targetComponentUid: dzDiff.componentUid || parsedDz.targetComponentUid,
4319
+ strapi
4320
+ });
4321
+ }
4322
+ restoredRoot = workingRoot;
4064
4323
  } else {
4065
- await strapi.documents(item.uid).update({
4066
- documentId: item.documentId,
4067
- data: {
4068
- [item.attribute]: rollbackPayload
4069
- }
4324
+ const parts = parsed.cleanPath.split(".");
4325
+ const pathParts = parts.slice(1);
4326
+ const targetIndexes = parsed.targetIndex !== void 0 ? [parsed.targetIndex] : void 0;
4327
+ const targetComponentUid = parsed.targetComponentUid;
4328
+ restoredRoot = setDeepProperty(existingRoot, pathParts, rollbackPayload, {
4329
+ targetIndexes,
4330
+ targetComponentUid,
4331
+ strapi
4070
4332
  });
4071
4333
  }
4334
+ await strapi.documents(item.uid).update({
4335
+ documentId: item.documentId,
4336
+ data: {
4337
+ [rootKey]: restoredRoot
4338
+ }
4339
+ });
4072
4340
  } else {
4341
+ const currentDoc = await strapi.documents(item.uid).findOne({
4342
+ documentId: item.documentId
4343
+ });
4344
+ wasPublished = Boolean(currentDoc?.publishedAt);
4073
4345
  await strapi.documents(item.uid).update({
4074
4346
  documentId: item.documentId,
4075
4347
  data: {
@@ -4077,13 +4349,13 @@ const auditService = ({ strapi }) => ({
4077
4349
  }
4078
4350
  });
4079
4351
  }
4080
- const ct = strapi.contentTypes[item.uid];
4081
- if (ct?.options?.draftAndPublish) {
4352
+ if (ct?.options?.draftAndPublish && wasPublished) {
4082
4353
  try {
4083
4354
  await strapi.documents(item.uid).publish({
4084
4355
  documentId: item.documentId
4085
4356
  });
4086
- } catch {
4357
+ } catch (publishErr) {
4358
+ strapi.log.warn(`[bulk-updater] Failed to auto-publish rollback for ${item.uid}/${item.documentId}:`, publishErr?.message);
4087
4359
  }
4088
4360
  }
4089
4361
  try {
@@ -4091,7 +4363,7 @@ const auditService = ({ strapi }) => ({
4091
4363
  uid: item.uid,
4092
4364
  documentId: item.documentId,
4093
4365
  userId,
4094
- statusOverride: ct?.options?.draftAndPublish ? "published" : void 0
4366
+ statusOverride: ct?.options?.draftAndPublish ? wasPublished ? "published" : "draft" : void 0
4095
4367
  });
4096
4368
  } catch {
4097
4369
  }
@@ -5004,230 +5276,246 @@ function deepMergeComponent(existingVal, newVal) {
5004
5276
  }
5005
5277
  return merged;
5006
5278
  }
5007
- async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5008
- const attrs = ct.attributes || {};
5009
- const { mode = "auto", publish = true, publishStrategy = "safe", user } = options;
5010
- const isDraftAndPublish = Boolean(ct.options?.draftAndPublish);
5011
- const stats = {
5012
- total: rows.length,
5013
- created: 0,
5014
- updated: 0,
5015
- skipped: 0,
5016
- newWithoutDocId: 0,
5017
- errors: [],
5018
- diffs: []
5019
- };
5020
- for (let i = 0; i < rows.length; i++) {
5021
- const rowNumber = startRowNumber + i;
5022
- const row = rows[i];
5023
- let docId = extractDocId(row);
5024
- const rowLocale = extractLocale(row);
5025
- if (ct.kind === "singleType" && !docId) {
5026
- try {
5027
- const findSingleArgs = {};
5028
- if (rowLocale) findSingleArgs.locale = rowLocale;
5029
- const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
5030
- if (singleDoc?.documentId) {
5031
- docId = singleDoc.documentId;
5032
- }
5033
- } catch {
5034
- }
5035
- }
5036
- const payload = {};
5037
- for (const [key, rawVal] of Object.entries(row)) {
5038
- const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
5039
- if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5040
- if (key.includes(".")) {
5041
- const parts = key.split(".");
5042
- let currentAttrs = attrs;
5043
- let currentTarget = payload;
5044
- for (let pIdx = 0; pIdx < parts.length; pIdx++) {
5045
- const part = parts[pIdx];
5046
- const isLeaf = pIdx === parts.length - 1;
5047
- if (isLeaf) {
5048
- let leafDef = currentAttrs[part];
5049
- if (!leafDef) {
5050
- const match = Object.keys(currentAttrs).find(
5051
- (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5052
- );
5053
- if (match) leafDef = currentAttrs[match];
5279
+ function parseRowToPayload(strapi, attrs, row) {
5280
+ const payload = {};
5281
+ for (const [key, rawVal] of Object.entries(row)) {
5282
+ const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
5283
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5284
+ if (key.includes(".")) {
5285
+ const parts = key.split(".");
5286
+ let currentAttrs = attrs;
5287
+ let currentTarget = payload;
5288
+ for (let pIdx = 0; pIdx < parts.length; pIdx++) {
5289
+ const part = parts[pIdx];
5290
+ const isLeaf = pIdx === parts.length - 1;
5291
+ if (isLeaf) {
5292
+ let leafDef = currentAttrs[part];
5293
+ if (!leafDef) {
5294
+ const match = Object.keys(currentAttrs).find(
5295
+ (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5296
+ );
5297
+ if (match) leafDef = currentAttrs[match];
5298
+ }
5299
+ if (!leafDef) {
5300
+ break;
5301
+ }
5302
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
5303
+ if (leafDef.type !== "dynamiczone" && !(leafDef.type === "component" && leafDef.repeatable) && leafDef.type !== "relation") {
5304
+ currentTarget[part] = null;
5054
5305
  }
5055
- if (!leafDef) {
5056
- break;
5306
+ } else if (leafDef.type === "integer" || leafDef.type === "biginteger") {
5307
+ const parsedInt = parseInt(rawVal, 10);
5308
+ currentTarget[part] = isNaN(parsedInt) ? null : parsedInt;
5309
+ } else if (leafDef.type === "float" || leafDef.type === "decimal") {
5310
+ const parsedFloat = parseFloat(rawVal);
5311
+ currentTarget[part] = isNaN(parsedFloat) ? null : parsedFloat;
5312
+ } else if (leafDef.type === "boolean") {
5313
+ currentTarget[part] = rawVal.toLowerCase() === "true" || rawVal === "1";
5314
+ } else if (leafDef.type === "json") {
5315
+ try {
5316
+ currentTarget[part] = JSON.parse(rawVal);
5317
+ } catch {
5318
+ currentTarget[part] = rawVal;
5057
5319
  }
5058
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5059
- if (leafDef.type !== "dynamiczone" && !(leafDef.type === "component" && leafDef.repeatable) && leafDef.type !== "relation") {
5060
- currentTarget[part] = null;
5320
+ } else if (leafDef.type === "component" && leafDef.repeatable) {
5321
+ try {
5322
+ const parsed = JSON.parse(rawVal);
5323
+ if (Array.isArray(parsed)) {
5324
+ currentTarget[part] = parsed;
5061
5325
  }
5062
- } else if (leafDef.type === "integer" || leafDef.type === "biginteger") {
5063
- const parsedInt = parseInt(rawVal, 10);
5064
- currentTarget[part] = isNaN(parsedInt) ? null : parsedInt;
5065
- } else if (leafDef.type === "float" || leafDef.type === "decimal") {
5066
- const parsedFloat = parseFloat(rawVal);
5067
- currentTarget[part] = isNaN(parsedFloat) ? null : parsedFloat;
5068
- } else if (leafDef.type === "boolean") {
5069
- currentTarget[part] = rawVal.toLowerCase() === "true" || rawVal === "1";
5070
- } else if (leafDef.type === "json") {
5071
- try {
5072
- currentTarget[part] = JSON.parse(rawVal);
5073
- } catch {
5326
+ } catch {
5327
+ }
5328
+ } else if (leafDef.type === "relation") {
5329
+ try {
5330
+ const parsed = JSON.parse(rawVal);
5331
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5332
+ currentTarget[part] = parsed;
5333
+ } else if (isValidDocId(rawVal)) {
5074
5334
  currentTarget[part] = rawVal;
5075
5335
  }
5076
- } else if (leafDef.type === "component" && leafDef.repeatable) {
5077
- try {
5078
- const parsed = JSON.parse(rawVal);
5079
- if (Array.isArray(parsed)) {
5080
- currentTarget[part] = parsed;
5081
- }
5082
- } catch {
5083
- }
5084
- } else if (leafDef.type === "relation") {
5085
- try {
5086
- const parsed = JSON.parse(rawVal);
5087
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5088
- currentTarget[part] = parsed;
5089
- } else if (isValidDocId(rawVal)) {
5090
- currentTarget[part] = rawVal;
5091
- }
5092
- } catch {
5093
- if (isValidDocId(rawVal)) {
5094
- currentTarget[part] = rawVal;
5095
- }
5336
+ } catch {
5337
+ if (isValidDocId(rawVal)) {
5338
+ currentTarget[part] = rawVal;
5096
5339
  }
5097
- } else {
5098
- currentTarget[part] = rawVal;
5099
5340
  }
5100
5341
  } else {
5101
- let compDef = currentAttrs[part];
5102
- if (!compDef) {
5103
- const match = Object.keys(currentAttrs).find(
5104
- (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5105
- );
5106
- if (match) compDef = currentAttrs[match];
5107
- }
5108
- if (compDef?.type === "component" && !compDef.repeatable && compDef.component) {
5109
- const comp = strapi.components[compDef.component];
5110
- if (!comp?.attributes) {
5111
- break;
5112
- }
5113
- if (!currentTarget[part] || typeof currentTarget[part] !== "object" || Array.isArray(currentTarget[part])) {
5114
- currentTarget[part] = {};
5115
- }
5116
- currentTarget = currentTarget[part];
5117
- currentAttrs = comp.attributes;
5118
- } else {
5342
+ currentTarget[part] = rawVal;
5343
+ }
5344
+ } else {
5345
+ let compDef = currentAttrs[part];
5346
+ if (!compDef) {
5347
+ const match = Object.keys(currentAttrs).find(
5348
+ (k) => k.toLowerCase() === part.toLowerCase() || k.toLowerCase() === part.toLowerCase().replace(/\s+/g, "")
5349
+ );
5350
+ if (match) compDef = currentAttrs[match];
5351
+ }
5352
+ if (compDef?.type === "component" && !compDef.repeatable && compDef.component) {
5353
+ const comp = strapi.components[compDef.component];
5354
+ if (!comp?.attributes) {
5119
5355
  break;
5120
5356
  }
5357
+ if (!currentTarget[part] || typeof currentTarget[part] !== "object" || Array.isArray(currentTarget[part])) {
5358
+ currentTarget[part] = {};
5359
+ }
5360
+ currentTarget = currentTarget[part];
5361
+ currentAttrs = comp.attributes;
5362
+ } else {
5363
+ break;
5121
5364
  }
5122
5365
  }
5123
- continue;
5124
5366
  }
5125
- let attrDef = attrs[key];
5126
- if (!attrDef) {
5127
- const matchKey = Object.keys(attrs).find(
5128
- (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
5129
- );
5130
- if (matchKey) {
5131
- attrDef = attrs[matchKey];
5132
- }
5367
+ continue;
5368
+ }
5369
+ let attrDef = attrs[key];
5370
+ if (!attrDef) {
5371
+ const matchKey = Object.keys(attrs).find(
5372
+ (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
5373
+ );
5374
+ if (matchKey) {
5375
+ attrDef = attrs[matchKey];
5133
5376
  }
5134
- if (!attrDef) continue;
5135
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
5136
- if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
5137
- payload[key] = null;
5138
- }
5139
- continue;
5377
+ }
5378
+ if (!attrDef) continue;
5379
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
5380
+ if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
5381
+ payload[key] = null;
5140
5382
  }
5141
- switch (attrDef.type) {
5142
- case "integer":
5143
- case "biginteger": {
5144
- const parsedInt = parseInt(rawVal, 10);
5145
- payload[key] = isNaN(parsedInt) ? null : parsedInt;
5146
- break;
5383
+ continue;
5384
+ }
5385
+ switch (attrDef.type) {
5386
+ case "integer":
5387
+ case "biginteger": {
5388
+ const parsedInt = parseInt(rawVal, 10);
5389
+ payload[key] = isNaN(parsedInt) ? null : parsedInt;
5390
+ break;
5391
+ }
5392
+ case "float":
5393
+ case "decimal": {
5394
+ const parsedFloat = parseFloat(rawVal);
5395
+ payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
5396
+ break;
5397
+ }
5398
+ case "boolean":
5399
+ payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
5400
+ break;
5401
+ case "json":
5402
+ try {
5403
+ payload[key] = JSON.parse(rawVal);
5404
+ } catch {
5405
+ payload[key] = rawVal;
5147
5406
  }
5148
- case "float":
5149
- case "decimal": {
5150
- const parsedFloat = parseFloat(rawVal);
5151
- payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
5152
- break;
5407
+ break;
5408
+ case "dynamiczone": {
5409
+ try {
5410
+ const parsed = JSON.parse(rawVal);
5411
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
5412
+ payload[key] = parsed;
5413
+ }
5414
+ } catch {
5153
5415
  }
5154
- case "boolean":
5155
- payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
5156
- break;
5157
- case "json":
5416
+ break;
5417
+ }
5418
+ case "component": {
5419
+ if (attrDef.repeatable) {
5158
5420
  try {
5159
- payload[key] = JSON.parse(rawVal);
5421
+ const parsed = JSON.parse(rawVal);
5422
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5423
+ payload[key] = parsed;
5424
+ }
5160
5425
  } catch {
5161
- payload[key] = rawVal;
5162
5426
  }
5163
- break;
5164
- case "dynamiczone": {
5427
+ } else {
5165
5428
  try {
5166
5429
  const parsed = JSON.parse(rawVal);
5167
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
5430
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5168
5431
  payload[key] = parsed;
5169
5432
  }
5170
5433
  } catch {
5171
5434
  }
5172
- break;
5173
5435
  }
5174
- case "component": {
5175
- if (attrDef.repeatable) {
5176
- try {
5177
- const parsed = JSON.parse(rawVal);
5178
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5179
- payload[key] = parsed;
5180
- }
5181
- } catch {
5182
- }
5183
- } else {
5184
- try {
5185
- const parsed = JSON.parse(rawVal);
5186
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5187
- payload[key] = parsed;
5188
- }
5189
- } catch {
5190
- }
5436
+ break;
5437
+ }
5438
+ case "relation": {
5439
+ try {
5440
+ const parsed = JSON.parse(rawVal);
5441
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5442
+ payload[key] = parsed;
5443
+ break;
5191
5444
  }
5192
- break;
5445
+ } catch {
5193
5446
  }
5194
- case "relation": {
5447
+ if (isValidDocId(rawVal)) {
5448
+ payload[key] = rawVal;
5449
+ } else if (rawVal.includes(";") || rawVal.includes(",")) {
5450
+ const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5451
+ if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5452
+ payload[key] = parts;
5453
+ }
5454
+ }
5455
+ break;
5456
+ }
5457
+ case "media": {
5458
+ if (/^\d+$/.test(rawVal.trim())) {
5459
+ payload[key] = parseInt(rawVal.trim(), 10);
5460
+ } else {
5195
5461
  try {
5196
5462
  const parsed = JSON.parse(rawVal);
5197
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5463
+ if (typeof parsed === "number" || typeof parsed === "object") {
5198
5464
  payload[key] = parsed;
5199
- break;
5200
5465
  }
5201
5466
  } catch {
5202
5467
  }
5203
- if (isValidDocId(rawVal)) {
5204
- payload[key] = rawVal;
5205
- } else if (rawVal.includes(";") || rawVal.includes(",")) {
5206
- const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5207
- if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5208
- payload[key] = parts;
5209
- }
5210
- }
5211
- break;
5212
5468
  }
5213
- case "media": {
5214
- if (/^\d+$/.test(rawVal.trim())) {
5215
- payload[key] = parseInt(rawVal.trim(), 10);
5216
- } else {
5217
- try {
5218
- const parsed = JSON.parse(rawVal);
5219
- if (typeof parsed === "number" || typeof parsed === "object") {
5220
- payload[key] = parsed;
5221
- }
5222
- } catch {
5223
- }
5224
- }
5225
- break;
5469
+ break;
5470
+ }
5471
+ default:
5472
+ payload[key] = rawVal;
5473
+ }
5474
+ }
5475
+ return payload;
5476
+ }
5477
+ function formatValueDisplay(val) {
5478
+ if (val === null || val === void 0) return "(empty)";
5479
+ if (typeof val === "object") {
5480
+ try {
5481
+ return JSON.stringify(val);
5482
+ } catch {
5483
+ return String(val);
5484
+ }
5485
+ }
5486
+ const s = String(val).trim();
5487
+ return s === "" ? "(empty)" : s;
5488
+ }
5489
+ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5490
+ const attrs = ct.attributes || {};
5491
+ const { mode = "auto", publish = true, publishStrategy = "safe", user } = options;
5492
+ const isDraftAndPublish = Boolean(ct.options?.draftAndPublish);
5493
+ const stats = {
5494
+ total: rows.length,
5495
+ created: 0,
5496
+ updated: 0,
5497
+ skipped: 0,
5498
+ newWithoutDocId: 0,
5499
+ errors: [],
5500
+ diffs: []
5501
+ };
5502
+ for (let i = 0; i < rows.length; i++) {
5503
+ const rowNumber = startRowNumber + i;
5504
+ const row = rows[i];
5505
+ let docId = extractDocId(row);
5506
+ const rowLocale = extractLocale(row);
5507
+ if (ct.kind === "singleType" && !docId) {
5508
+ try {
5509
+ const findSingleArgs = {};
5510
+ if (rowLocale) findSingleArgs.locale = rowLocale;
5511
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
5512
+ if (singleDoc?.documentId) {
5513
+ docId = singleDoc.documentId;
5226
5514
  }
5227
- default:
5228
- payload[key] = rawVal;
5515
+ } catch {
5229
5516
  }
5230
5517
  }
5518
+ const payload = parseRowToPayload(strapi, attrs, row);
5231
5519
  try {
5232
5520
  if (mode === "update") {
5233
5521
  if (!docId) {
@@ -5277,7 +5565,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5277
5565
  if (rowLocale) updateArgs.locale = rowLocale;
5278
5566
  await strapi.documents(uid).update(updateArgs);
5279
5567
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5280
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5568
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5281
5569
  if (shouldPublish) {
5282
5570
  try {
5283
5571
  const pubArgs = { documentId: resolvedDocId };
@@ -5332,7 +5620,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5332
5620
  if (rowLocale) updateArgs.locale = rowLocale;
5333
5621
  await strapi.documents(uid).update(updateArgs);
5334
5622
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5335
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5623
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5336
5624
  if (shouldPublish) {
5337
5625
  try {
5338
5626
  const pubArgs = { documentId: resolvedDocId };
@@ -5373,7 +5661,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5373
5661
  if (rowLocale) createArgs.locale = rowLocale;
5374
5662
  const created = await strapi.documents(uid).create(createArgs);
5375
5663
  const newDocId = created?.documentId || docId;
5376
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5664
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5377
5665
  if (shouldPublishNew && newDocId) {
5378
5666
  try {
5379
5667
  const pubArgs = { documentId: newDocId };
@@ -5429,7 +5717,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5429
5717
  if (rowLocale) updateArgs.locale = rowLocale;
5430
5718
  await strapi.documents(uid).update(updateArgs);
5431
5719
  const isApproved = isDraftAndPublish && Boolean(existing?.publishedAt) && existing?.status !== "modified";
5432
- const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : isApproved);
5720
+ const shouldPublish = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : isApproved);
5433
5721
  if (shouldPublish) {
5434
5722
  try {
5435
5723
  const pubArgs = { documentId: resolvedDocId };
@@ -5470,7 +5758,7 @@ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
5470
5758
  if (rowLocale) createArgs.locale = rowLocale;
5471
5759
  const created = await strapi.documents(uid).create(createArgs);
5472
5760
  const newDocId = created?.documentId || docId;
5473
- const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" ? true : false);
5761
+ const shouldPublishNew = isDraftAndPublish && Boolean(publish) && (publishStrategy === "all" || options.unreviewedConsent ? true : false);
5474
5762
  if (shouldPublishNew && newDocId) {
5475
5763
  try {
5476
5764
  const pubArgs = { documentId: newDocId };
@@ -6214,6 +6502,174 @@ const csvService = ({ strapi }) => ({
6214
6502
  totalWarningCount: warnings.length
6215
6503
  };
6216
6504
  },
6505
+ /**
6506
+ * Previews CSV batch changes item-by-item without mutating the database.
6507
+ * Compares proposed CSV rows against existing documents to compute exact diffs for Maker-Checker review.
6508
+ */
6509
+ async previewCsvBatch(uid, rows, options = {}) {
6510
+ const ct = strapi.contentTypes[uid];
6511
+ if (!ct) {
6512
+ throw new Error(`Content type "${uid}" not found`);
6513
+ }
6514
+ const attrs = ct.attributes || {};
6515
+ const { mode = "auto" } = options;
6516
+ const diffs = [];
6517
+ let willUpdateCount = 0;
6518
+ let willCreateCount = 0;
6519
+ let unchangedCount = 0;
6520
+ let skippedCount = 0;
6521
+ for (let i = 0; i < rows.length; i++) {
6522
+ const rowNumber = i + 2;
6523
+ const row = rows[i];
6524
+ let docId = extractDocId(row);
6525
+ const rowLocale = extractLocale(row);
6526
+ if (ct.kind === "singleType" && !docId) {
6527
+ try {
6528
+ const findSingleArgs = {};
6529
+ if (rowLocale) findSingleArgs.locale = rowLocale;
6530
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
6531
+ if (singleDoc?.documentId) {
6532
+ docId = singleDoc.documentId;
6533
+ }
6534
+ } catch {
6535
+ }
6536
+ }
6537
+ let existing = null;
6538
+ if (docId) {
6539
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
6540
+ }
6541
+ if (mode === "update" && (!docId || !existing)) {
6542
+ skippedCount++;
6543
+ diffs.push({
6544
+ uid,
6545
+ collectionName: ct.info?.displayName || uid,
6546
+ documentId: docId || `(Row #${rowNumber})`,
6547
+ label: `Row #${rowNumber}`,
6548
+ attribute: "(record)",
6549
+ oldValue: null,
6550
+ newValue: null,
6551
+ oldDisplay: "—",
6552
+ newDisplay: "—",
6553
+ hasChange: false,
6554
+ status: "will_skip",
6555
+ action: "skip",
6556
+ message: !docId ? "Missing documentId in update mode" : `Document "${docId}" not found in database`
6557
+ });
6558
+ continue;
6559
+ }
6560
+ const payload = parseRowToPayload(strapi, attrs, row);
6561
+ if (existing) {
6562
+ const resolvedDocId = existing.documentId || docId;
6563
+ const entryLabel = existing.title || existing.name || existing.slug || `Entry #${resolvedDocId}`;
6564
+ let rowHasChanges = false;
6565
+ const payloadKeys = Object.keys(payload);
6566
+ if (payloadKeys.length === 0) {
6567
+ unchangedCount++;
6568
+ diffs.push({
6569
+ uid,
6570
+ collectionName: ct.info?.displayName || uid,
6571
+ documentId: resolvedDocId,
6572
+ label: entryLabel,
6573
+ attribute: "(no fields)",
6574
+ oldValue: null,
6575
+ newValue: null,
6576
+ oldDisplay: "—",
6577
+ newDisplay: "—",
6578
+ hasChange: false,
6579
+ status: "unchanged",
6580
+ action: "update",
6581
+ message: "No modified attributes in CSV row"
6582
+ });
6583
+ } else {
6584
+ for (const attrKey of payloadKeys) {
6585
+ const oldVal = existing?.[attrKey] ?? null;
6586
+ let newVal = payload[attrKey];
6587
+ if (attrs[attrKey]?.type === "component" && typeof newVal === "object" && newVal !== null) {
6588
+ newVal = deepMergeComponent(existing[attrKey], newVal);
6589
+ }
6590
+ const oldDisplay = formatValueDisplay(oldVal);
6591
+ const newDisplay = formatValueDisplay(newVal);
6592
+ const hasChange = String(oldVal ?? "") !== String(newVal ?? "");
6593
+ if (hasChange) {
6594
+ rowHasChanges = true;
6595
+ }
6596
+ diffs.push({
6597
+ uid,
6598
+ collectionName: ct.info?.displayName || uid,
6599
+ documentId: resolvedDocId,
6600
+ label: entryLabel,
6601
+ attribute: attrKey,
6602
+ oldValue: oldVal,
6603
+ newValue: newVal,
6604
+ oldDisplay,
6605
+ newDisplay,
6606
+ hasChange,
6607
+ status: hasChange ? "will_update" : "unchanged",
6608
+ action: "update",
6609
+ message: hasChange ? `Will update "${attrKey}" from CSV` : `Already matches CSV value`
6610
+ });
6611
+ }
6612
+ if (rowHasChanges) {
6613
+ willUpdateCount++;
6614
+ } else {
6615
+ unchangedCount++;
6616
+ }
6617
+ }
6618
+ } else {
6619
+ willCreateCount++;
6620
+ const resolvedDocId = docId || `(Auto-generated)`;
6621
+ const entryLabel = payload.title || payload.name || payload.slug || `New Entry (Row #${rowNumber})`;
6622
+ const payloadKeys = Object.keys(payload);
6623
+ if (payloadKeys.length === 0) {
6624
+ diffs.push({
6625
+ uid,
6626
+ collectionName: ct.info?.displayName || uid,
6627
+ documentId: resolvedDocId,
6628
+ label: entryLabel,
6629
+ attribute: "(all fields)",
6630
+ oldValue: null,
6631
+ newValue: null,
6632
+ oldDisplay: "(new entry)",
6633
+ newDisplay: "(empty record)",
6634
+ hasChange: true,
6635
+ status: "will_create",
6636
+ action: "create",
6637
+ message: "Will create new record"
6638
+ });
6639
+ } else {
6640
+ for (const attrKey of payloadKeys) {
6641
+ const newVal = payload[attrKey];
6642
+ diffs.push({
6643
+ uid,
6644
+ collectionName: ct.info?.displayName || uid,
6645
+ documentId: resolvedDocId,
6646
+ label: entryLabel,
6647
+ attribute: attrKey,
6648
+ oldValue: null,
6649
+ newValue: newVal,
6650
+ oldDisplay: "(new entry)",
6651
+ newDisplay: formatValueDisplay(newVal),
6652
+ hasChange: true,
6653
+ status: "will_create",
6654
+ action: "create",
6655
+ message: `Will create new record with attribute "${attrKey}"`
6656
+ });
6657
+ }
6658
+ }
6659
+ }
6660
+ }
6661
+ return {
6662
+ success: true,
6663
+ summary: {
6664
+ total: rows.length,
6665
+ willUpdate: willUpdateCount,
6666
+ willCreate: willCreateCount,
6667
+ unchanged: unchangedCount,
6668
+ skipped: skippedCount
6669
+ },
6670
+ diffs
6671
+ };
6672
+ },
6217
6673
  /**
6218
6674
  * Submits a CSV batch with Guardrails: Maker-Checker (4-Eyes), Execution Windows, Off-Peak Scheduling
6219
6675
  */