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