strapi-plugin-bulk-updater 1.4.3 → 1.4.6

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.
@@ -211,6 +211,30 @@ const routes = {
211
211
  config: {
212
212
  policies: []
213
213
  }
214
+ },
215
+ {
216
+ method: "POST",
217
+ path: "/csv/import-chunk",
218
+ handler: "csv.importChunk",
219
+ config: {
220
+ policies: []
221
+ }
222
+ },
223
+ {
224
+ method: "POST",
225
+ path: "/csv/validate-preflight",
226
+ handler: "csv.validatePreflight",
227
+ config: {
228
+ policies: []
229
+ }
230
+ },
231
+ {
232
+ method: "POST",
233
+ path: "/csv/submit-batch",
234
+ handler: "csv.submitBatch",
235
+ config: {
236
+ policies: []
237
+ }
214
238
  }
215
239
  ]
216
240
  }
@@ -663,6 +687,86 @@ const csvController = ({ strapi }) => ({
663
687
  ctx.status = 500;
664
688
  ctx.body = { success: false, error: err.message };
665
689
  }
690
+ },
691
+ async importChunk(ctx) {
692
+ try {
693
+ const { uid, rows, batchId, isFirstChunk, isLastChunk, startRowNumber, mode, publish } = ctx.request.body;
694
+ if (!uid) {
695
+ ctx.status = 400;
696
+ ctx.body = { success: false, error: "Target collection UID is required." };
697
+ return;
698
+ }
699
+ if (!Array.isArray(rows)) {
700
+ ctx.status = 400;
701
+ ctx.body = { success: false, error: "Rows array is required." };
702
+ return;
703
+ }
704
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
705
+ const result = await csvService2.importChunk(uid, rows, {
706
+ batchId: batchId ? String(batchId) : void 0,
707
+ isFirstChunk: Boolean(isFirstChunk),
708
+ isLastChunk: Boolean(isLastChunk),
709
+ startRowNumber: typeof startRowNumber === "number" ? startRowNumber : void 0,
710
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto",
711
+ publish: publish !== false,
712
+ user: ctx.state.user
713
+ });
714
+ ctx.body = result;
715
+ } catch (err) {
716
+ ctx.status = 500;
717
+ ctx.body = { success: false, error: err.message };
718
+ }
719
+ },
720
+ async validatePreflight(ctx) {
721
+ try {
722
+ const { uid, rows, mode } = ctx.request.body;
723
+ if (!uid) {
724
+ ctx.status = 400;
725
+ ctx.body = { success: false, error: "Target collection UID is required." };
726
+ return;
727
+ }
728
+ if (!Array.isArray(rows)) {
729
+ ctx.status = 400;
730
+ ctx.body = { success: false, error: "Rows array is required." };
731
+ return;
732
+ }
733
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
734
+ const result = await csvService2.validatePreflight(uid, rows, {
735
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto"
736
+ });
737
+ ctx.body = result;
738
+ } catch (err) {
739
+ ctx.status = 500;
740
+ ctx.body = { success: false, error: err.message };
741
+ }
742
+ },
743
+ async submitBatch(ctx) {
744
+ try {
745
+ const { uid, rows, executionMode, makerNotes, forceImmediate, mode, publish } = ctx.request.body;
746
+ if (!uid) {
747
+ ctx.status = 400;
748
+ ctx.body = { success: false, error: "Target collection UID is required." };
749
+ return;
750
+ }
751
+ if (!Array.isArray(rows)) {
752
+ ctx.status = 400;
753
+ ctx.body = { success: false, error: "Rows array is required." };
754
+ return;
755
+ }
756
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
757
+ const result = await csvService2.submitBatch(uid, rows, {
758
+ executionMode,
759
+ makerNotes,
760
+ forceImmediate: Boolean(forceImmediate),
761
+ mode: mode === "update" ? "update" : mode === "create_or_override" ? "create_or_override" : "auto",
762
+ publish: publish !== false,
763
+ user: ctx.state.user
764
+ });
765
+ ctx.body = result;
766
+ } catch (err) {
767
+ ctx.status = 500;
768
+ ctx.body = { success: false, error: err.message };
769
+ }
666
770
  }
667
771
  });
668
772
  const FIELDS_TO_IGNORE = [
@@ -945,13 +1049,15 @@ function getAllNestedValues(obj, path) {
945
1049
  if (!item || typeof item !== "object") return;
946
1050
  if (parsed.targetIndex !== void 0 && idx !== parsed.targetIndex) return;
947
1051
  if (parsed.targetComponentUid && item.__component !== parsed.targetComponentUid) return;
1052
+ const compSchema = global.strapi?.components?.[item.__component];
1053
+ const fieldInSchema = tail.length > 0 && compSchema?.attributes ? tail[0] in compSchema.attributes : false;
948
1054
  const subVal = tail.length > 0 ? getDeepProperty(item, tail.join(".")) : item;
949
- if (subVal !== void 0) {
1055
+ if (subVal !== void 0 || fieldInSchema) {
950
1056
  results.push({
951
1057
  index: idx,
952
1058
  componentUid: item.__component,
953
1059
  componentName: formatComponentName(item.__component),
954
- value: subVal,
1060
+ value: subVal !== void 0 ? subVal : null,
955
1061
  path: `${head}[#${idx}${item.__component ? ` ${formatComponentName(item.__component)}` : ""}].${tail.join(".")}`
956
1062
  });
957
1063
  }
@@ -1050,7 +1156,9 @@ function setDeepProperty(obj, pathParts, value, options) {
1050
1156
  if (options?.targetComponentUid) {
1051
1157
  if (item.__component !== options.targetComponentUid) return item;
1052
1158
  }
1053
- if (head in item || tail.length > 0 && typeof item[head] === "object") {
1159
+ const compSchema = global.strapi?.components?.[item.__component];
1160
+ const fieldExistsInSchema = compSchema?.attributes ? head in compSchema.attributes : true;
1161
+ if (head in item || fieldExistsInSchema || tail.length === 0) {
1054
1162
  return {
1055
1163
  ...item,
1056
1164
  [head]: setDeepProperty(item[head], tail, value, options)
@@ -1164,6 +1272,12 @@ const pendingBatches = /* @__PURE__ */ new Map();
1164
1272
  const batchJobsMap = /* @__PURE__ */ new Map();
1165
1273
  let isRunnerRunning = false;
1166
1274
  let runnerInterval = null;
1275
+ function registerPendingBatch(batch, jobState) {
1276
+ pendingBatches.set(batch.id, batch);
1277
+ if (jobState) {
1278
+ batchJobsMap.set(batch.id, jobState);
1279
+ }
1280
+ }
1167
1281
  function invalidateCollectionCache(uid) {
1168
1282
  modelsCache.delete(uid);
1169
1283
  for (const key of queryCache.keys()) {
@@ -1786,6 +1900,22 @@ async function executePublishBatchInternal(strapi, {
1786
1900
  results
1787
1901
  };
1788
1902
  }
1903
+ async function executeBatchByPayload(strapi, batch, currentUser) {
1904
+ if (batch.payload?.action === "csv_import") {
1905
+ const csvService2 = strapi.plugin("bulk-updater").service("csv");
1906
+ return await csvService2.executeCsvPendingBatch(
1907
+ batch.payload.uid,
1908
+ batch.payload.rows,
1909
+ batch.id,
1910
+ batch.payload.options,
1911
+ currentUser
1912
+ );
1913
+ } else if (batch.payload?.action === "publish") {
1914
+ return await executePublishBatchInternal(strapi, batch.payload, currentUser, batch.id);
1915
+ } else {
1916
+ return await executeBatchInternal(strapi, batch.payload, currentUser, batch.id);
1917
+ }
1918
+ }
1789
1919
  const bulkUpdaterService = ({ strapi }) => {
1790
1920
  if (!runnerInterval) {
1791
1921
  runnerInterval = setInterval(async () => {
@@ -1797,7 +1927,7 @@ const bulkUpdaterService = ({ strapi }) => {
1797
1927
  for (const [id, batch] of pendingBatches.entries()) {
1798
1928
  if (batch.status === "queued_offpeak" && !isBusinessHours) {
1799
1929
  strapi.log.info(`[bulk-updater] Executing off-peak queued batch: ${id}`);
1800
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, batch.createdBy, id) : await executeBatchInternal(strapi, batch.payload, batch.createdBy, id);
1930
+ const result = await executeBatchByPayload(strapi, batch, batch.createdBy);
1801
1931
  batch.status = "executed";
1802
1932
  batch.executionResult = result;
1803
1933
  }
@@ -2410,7 +2540,12 @@ const bulkUpdaterService = ({ strapi }) => {
2410
2540
  for (const item of selectedEntries) {
2411
2541
  if (!item.uid || !item.documentId) continue;
2412
2542
  const key = `${item.uid}@@@${item.documentId}`;
2413
- targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
2543
+ targetsMap.set(key, {
2544
+ uid: item.uid,
2545
+ documentId: item.documentId,
2546
+ dynamicZoneIndexes: item.dynamicZoneIndexes,
2547
+ targetComponentUid: item.targetComponentUid
2548
+ });
2414
2549
  }
2415
2550
  if (targetsMap.size === 0) {
2416
2551
  throw new Error("No collections or entries were selected for preview.");
@@ -2474,6 +2609,49 @@ const bulkUpdaterService = ({ strapi }) => {
2474
2609
  }
2475
2610
  const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
2476
2611
  if (isComponentNested) {
2612
+ const parsed = parseDynamicZonePath(attribute);
2613
+ const rootKey = parsed.rootKey;
2614
+ const isRootDz = contentType?.attributes?.[rootKey]?.type === "dynamiczone";
2615
+ const targetIndexes = target.dynamicZoneIndexes;
2616
+ const targetComponentUid = target.targetComponentUid || parsed.targetComponentUid;
2617
+ if (isRootDz && currentEntry?.[rootKey] && Array.isArray(currentEntry[rootKey])) {
2618
+ let affectedMatches = getAllNestedValues({ [rootKey]: currentEntry[rootKey] }, attribute);
2619
+ if (targetIndexes && targetIndexes.length > 0) {
2620
+ affectedMatches = affectedMatches.filter((m) => targetIndexes.includes(m.index));
2621
+ }
2622
+ if (targetComponentUid) {
2623
+ affectedMatches = affectedMatches.filter((m) => m.componentUid === targetComponentUid);
2624
+ }
2625
+ if (affectedMatches.length > 0) {
2626
+ for (const match of affectedMatches) {
2627
+ let blockNewVal = value;
2628
+ if (regexObj && typeof match.value === "string") {
2629
+ blockNewVal = match.value.replace(regexObj, value);
2630
+ }
2631
+ const hasChange3 = String(match.value ?? "") !== String(blockNewVal ?? "");
2632
+ if (hasChange3) {
2633
+ willUpdateCount++;
2634
+ } else {
2635
+ unchangedCount++;
2636
+ }
2637
+ diffs.push({
2638
+ uid: target.uid,
2639
+ collectionName: displayName,
2640
+ documentId: target.documentId,
2641
+ label: `${entryLabel} [Block #${match.index} (${match.componentName})]`,
2642
+ attribute: `${rootKey}[#${match.index} ${match.componentName}].${parsed.subPath}`,
2643
+ oldValue: match.value ?? null,
2644
+ newValue: blockNewVal,
2645
+ oldDisplay: match.value !== void 0 && match.value !== null && String(match.value).trim() !== "" ? String(match.value) : "(empty)",
2646
+ newDisplay: String(blockNewVal ?? ""),
2647
+ hasChange: hasChange3,
2648
+ status: hasChange3 ? "will_update" : "unchanged",
2649
+ message: hasChange3 ? "Block value will be updated" : "Block already has target value"
2650
+ });
2651
+ }
2652
+ continue;
2653
+ }
2654
+ }
2477
2655
  const currentVal2 = getDeepProperty(currentEntry, attribute);
2478
2656
  let newValue2 = value;
2479
2657
  if (regexObj && typeof currentVal2 === "string") {
@@ -3053,7 +3231,7 @@ const bulkUpdaterService = ({ strapi }) => {
3053
3231
  const { isBusinessHours } = checkBusinessHours(config);
3054
3232
  if (options.executeImmediately || !isBusinessHours) {
3055
3233
  batch.status = "approved";
3056
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, checkerUser, batch.id) : await executeBatchInternal(strapi, batch.payload, checkerUser, batch.id);
3234
+ const result = await executeBatchByPayload(strapi, batch, checkerUser);
3057
3235
  batch.status = "executed";
3058
3236
  batch.executionResult = result;
3059
3237
  return {
@@ -3114,7 +3292,7 @@ const bulkUpdaterService = ({ strapi }) => {
3114
3292
  if (batch.status === "executed") {
3115
3293
  throw new Error(`Batch ${batchId} has already been executed.`);
3116
3294
  }
3117
- const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi, batch.payload, currentUser, batch.id) : await executeBatchInternal(strapi, batch.payload, currentUser, batch.id);
3295
+ const result = await executeBatchByPayload(strapi, batch, currentUser);
3118
3296
  batch.status = "executed";
3119
3297
  batch.executionResult = result;
3120
3298
  return {
@@ -3361,6 +3539,20 @@ const bulkUpdaterService = ({ strapi }) => {
3361
3539
  },
3362
3540
  invalidateCollectionCache(uid) {
3363
3541
  invalidateCollectionCache(uid);
3542
+ },
3543
+ registerPendingBatch(batch, jobState) {
3544
+ pendingBatches.set(batch.id, batch);
3545
+ if (jobState) {
3546
+ batchJobsMap.set(batch.id, jobState);
3547
+ }
3548
+ return batch;
3549
+ },
3550
+ getExecutionWindowConfig() {
3551
+ return getExecutionWindowConfig(strapi);
3552
+ },
3553
+ checkBusinessHours(config) {
3554
+ const cfg = config || getExecutionWindowConfig(strapi);
3555
+ return checkBusinessHours(cfg);
3364
3556
  }
3365
3557
  };
3366
3558
  };
@@ -4567,92 +4759,645 @@ function getNestedExportValue(entry, path) {
4567
4759
  }
4568
4760
  return current;
4569
4761
  }
4570
- const csvService = ({ strapi }) => ({
4571
- /**
4572
- * Generates a sample template CSV with schema attribute headers and sample values
4573
- */
4574
- async getTemplateCsv(uid) {
4575
- const ct = strapi.contentTypes[uid];
4576
- if (!ct) {
4577
- throw new Error(`Content type ${uid} not found`);
4762
+ const csvImportSessions = /* @__PURE__ */ new Map();
4763
+ function cleanupStaleSessions() {
4764
+ const oneHourAgo = Date.now() - 36e5;
4765
+ for (const [id, session] of csvImportSessions.entries()) {
4766
+ if (session.createdAt < oneHourAgo) {
4767
+ csvImportSessions.delete(id);
4578
4768
  }
4579
- const attrs = ct.attributes || {};
4580
- const excluded = ["createdAt", "updatedAt", "publishedAt", "createdBy", "updatedBy", "localizations"];
4581
- const headers = ["documentId"];
4582
- const sampleRow = ["(Leave empty to create new, or enter existing docId)"];
4583
- for (const [attrName, attrDef] of Object.entries(attrs)) {
4584
- if (excluded.includes(attrName)) continue;
4585
- if (attrDef.type === "relation") continue;
4586
- if (attrDef.type === "component" || attrDef.type === "dynamiczone") continue;
4587
- headers.push(attrName);
4588
- let sampleVal = "";
4769
+ }
4770
+ }
4771
+ const SYSTEM_FIELD_KEYS = /* @__PURE__ */ new Set([
4772
+ "documentid",
4773
+ "document_id",
4774
+ "docid",
4775
+ "id",
4776
+ "createdat",
4777
+ "updatedat",
4778
+ "publishedat",
4779
+ "createdby",
4780
+ "updatedby",
4781
+ "localizations",
4782
+ "locale",
4783
+ "status"
4784
+ ]);
4785
+ async function findExistingDocument(strapi, uid, targetDocId, docLocale) {
4786
+ if (!targetDocId) return null;
4787
+ try {
4788
+ const queryArgs = { documentId: targetDocId };
4789
+ if (docLocale) queryArgs.locale = docLocale;
4790
+ const found = await strapi.documents(uid).findOne(queryArgs);
4791
+ if (found) return found;
4792
+ } catch {
4793
+ }
4794
+ if (/^\d+$/.test(targetDocId)) {
4795
+ try {
4796
+ const queryArgs = {
4797
+ filters: { id: parseInt(targetDocId, 10) },
4798
+ limit: 1
4799
+ };
4800
+ if (docLocale) queryArgs.locale = docLocale;
4801
+ const list = await strapi.documents(uid).findMany(queryArgs);
4802
+ if (list && list.length > 0) return list[0];
4803
+ } catch {
4804
+ }
4805
+ }
4806
+ return null;
4807
+ }
4808
+ async function processCsvRows(strapi, ct, uid, rows, startRowNumber, options) {
4809
+ const attrs = ct.attributes || {};
4810
+ const { mode = "auto", publish = true, user } = options;
4811
+ const stats = {
4812
+ total: rows.length,
4813
+ created: 0,
4814
+ updated: 0,
4815
+ skipped: 0,
4816
+ newWithoutDocId: 0,
4817
+ errors: [],
4818
+ diffs: []
4819
+ };
4820
+ for (let i = 0; i < rows.length; i++) {
4821
+ const rowNumber = startRowNumber + i;
4822
+ const row = rows[i];
4823
+ let docId = extractDocId(row);
4824
+ const rowLocale = extractLocale(row);
4825
+ if (ct.kind === "singleType" && !docId) {
4826
+ try {
4827
+ const findSingleArgs = {};
4828
+ if (rowLocale) findSingleArgs.locale = rowLocale;
4829
+ const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
4830
+ if (singleDoc?.documentId) {
4831
+ docId = singleDoc.documentId;
4832
+ }
4833
+ } catch {
4834
+ }
4835
+ }
4836
+ const payload = {};
4837
+ for (const [key, rawVal] of Object.entries(row)) {
4838
+ const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
4839
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
4840
+ if (key.includes(".")) {
4841
+ const [parent, child] = key.split(".");
4842
+ const parentDef = attrs[parent];
4843
+ if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
4844
+ const comp = strapi.components[parentDef.component];
4845
+ const childDef = comp?.attributes?.[child];
4846
+ if (childDef) {
4847
+ if (!payload[parent]) payload[parent] = {};
4848
+ let val = rawVal;
4849
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
4850
+ val = null;
4851
+ } else if (childDef.type === "integer" || childDef.type === "biginteger") {
4852
+ const parsedInt = parseInt(rawVal, 10);
4853
+ val = isNaN(parsedInt) ? null : parsedInt;
4854
+ } else if (childDef.type === "float" || childDef.type === "decimal") {
4855
+ const parsedFloat = parseFloat(rawVal);
4856
+ val = isNaN(parsedFloat) ? null : parsedFloat;
4857
+ } else if (childDef.type === "boolean") {
4858
+ val = rawVal.toLowerCase() === "true" || rawVal === "1";
4859
+ } else if (childDef.type === "json") {
4860
+ try {
4861
+ val = JSON.parse(rawVal);
4862
+ } catch {
4863
+ val = rawVal;
4864
+ }
4865
+ }
4866
+ payload[parent][child] = val;
4867
+ }
4868
+ }
4869
+ continue;
4870
+ }
4871
+ let attrDef = attrs[key];
4872
+ if (!attrDef) {
4873
+ const matchKey = Object.keys(attrs).find(
4874
+ (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
4875
+ );
4876
+ if (matchKey) {
4877
+ attrDef = attrs[matchKey];
4878
+ }
4879
+ }
4880
+ if (!attrDef) continue;
4881
+ if (rawVal === "" || rawVal === null || rawVal === void 0) {
4882
+ if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
4883
+ payload[key] = null;
4884
+ }
4885
+ continue;
4886
+ }
4589
4887
  switch (attrDef.type) {
4590
- case "string":
4591
- case "text":
4592
- case "richtext":
4593
- sampleVal = `Sample ${attrName}`;
4594
- break;
4595
4888
  case "integer":
4596
- case "biginteger":
4889
+ case "biginteger": {
4890
+ const parsedInt = parseInt(rawVal, 10);
4891
+ payload[key] = isNaN(parsedInt) ? null : parsedInt;
4892
+ break;
4893
+ }
4597
4894
  case "float":
4598
- case "decimal":
4599
- sampleVal = "10";
4895
+ case "decimal": {
4896
+ const parsedFloat = parseFloat(rawVal);
4897
+ payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
4600
4898
  break;
4899
+ }
4601
4900
  case "boolean":
4602
- sampleVal = "true";
4901
+ payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
4603
4902
  break;
4604
- case "email":
4605
- sampleVal = "sample@example.com";
4903
+ case "json":
4904
+ try {
4905
+ payload[key] = JSON.parse(rawVal);
4906
+ } catch {
4907
+ payload[key] = rawVal;
4908
+ }
4606
4909
  break;
4607
- case "enumeration":
4608
- sampleVal = attrDef.enum?.[0] || "option1";
4910
+ case "dynamiczone": {
4911
+ try {
4912
+ const parsed = JSON.parse(rawVal);
4913
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
4914
+ payload[key] = parsed;
4915
+ }
4916
+ } catch {
4917
+ }
4609
4918
  break;
4610
- case "customField":
4611
- sampleVal = "value";
4919
+ }
4920
+ case "component": {
4921
+ if (attrDef.repeatable) {
4922
+ try {
4923
+ const parsed = JSON.parse(rawVal);
4924
+ if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
4925
+ payload[key] = parsed;
4926
+ }
4927
+ } catch {
4928
+ }
4929
+ } else {
4930
+ try {
4931
+ const parsed = JSON.parse(rawVal);
4932
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
4933
+ payload[key] = parsed;
4934
+ }
4935
+ } catch {
4936
+ }
4937
+ }
4612
4938
  break;
4939
+ }
4940
+ case "relation": {
4941
+ try {
4942
+ const parsed = JSON.parse(rawVal);
4943
+ if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
4944
+ payload[key] = parsed;
4945
+ break;
4946
+ }
4947
+ } catch {
4948
+ }
4949
+ if (isValidDocId(rawVal)) {
4950
+ payload[key] = rawVal;
4951
+ } else if (rawVal.includes(";") || rawVal.includes(",")) {
4952
+ const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
4953
+ if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
4954
+ payload[key] = parts;
4955
+ }
4956
+ }
4957
+ break;
4958
+ }
4959
+ case "media": {
4960
+ if (/^\d+$/.test(rawVal.trim())) {
4961
+ payload[key] = parseInt(rawVal.trim(), 10);
4962
+ } else {
4963
+ try {
4964
+ const parsed = JSON.parse(rawVal);
4965
+ if (typeof parsed === "number" || typeof parsed === "object") {
4966
+ payload[key] = parsed;
4967
+ }
4968
+ } catch {
4969
+ }
4970
+ }
4971
+ break;
4972
+ }
4613
4973
  default:
4614
- sampleVal = "sample";
4974
+ payload[key] = rawVal;
4615
4975
  }
4616
- sampleRow.push(sampleVal);
4617
- }
4618
- const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
4619
- return {
4620
- filename: `${ct.info?.singularName || "template"}_template.csv`,
4621
- csvContent,
4622
- headers
4623
- };
4624
- },
4625
- /**
4626
- * Exports entries of a collection to CSV with deep nested component support,
4627
- * RFC-4180 CRLF line endings, and UTF-8 BOM for 100% Microsoft Excel compatibility.
4628
- */
4629
- async exportCollectionCsv(uid, options = {}) {
4630
- const ct = strapi.contentTypes[uid];
4631
- if (!ct) {
4632
- throw new Error(`Content type ${uid} not found`);
4633
- }
4634
- const allComponents = strapi.components || {};
4635
- const fields = discoverExportFields(ct, allComponents);
4636
- const filters = {};
4637
- const { attribute, filterOperator, filterQuery, search, documentIds } = options;
4638
- if (documentIds && Array.isArray(documentIds) && documentIds.length > 0) {
4639
- filters.documentId = { $in: documentIds };
4640
4976
  }
4641
- if (attribute && filterOperator && filterOperator !== "none") {
4642
- const isNested = attribute.includes(".");
4643
- if (filterOperator === "empty") {
4644
- if (isNested) {
4645
- const [parent, child] = attribute.split(".");
4646
- filters[parent] = { [child]: { $null: true } };
4647
- } else {
4648
- filters[attribute] = { $null: true };
4649
- }
4650
- } else if (filterOperator === "not_empty") {
4651
- if (isNested) {
4652
- const [parent, child] = attribute.split(".");
4653
- filters[parent] = { [child]: { $notNull: true } };
4654
- } else {
4655
- filters[attribute] = { $notNull: true };
4977
+ try {
4978
+ if (mode === "update") {
4979
+ if (!docId) {
4980
+ stats.skipped++;
4981
+ stats.errors.push({
4982
+ row: rowNumber,
4983
+ error: "Missing or invalid documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
4984
+ });
4985
+ continue;
4986
+ }
4987
+ const existing = await findExistingDocument(strapi, uid, docId, rowLocale);
4988
+ if (!existing) {
4989
+ stats.skipped++;
4990
+ stats.errors.push({
4991
+ row: rowNumber,
4992
+ documentId: docId,
4993
+ error: `Document with ID "${docId}" not found in database.`
4994
+ });
4995
+ continue;
4996
+ }
4997
+ const resolvedDocId = existing.documentId || docId;
4998
+ const mergedPayload = { ...payload };
4999
+ for (const key of Object.keys(payload)) {
5000
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5001
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5002
+ }
5003
+ }
5004
+ if (Object.keys(mergedPayload).length === 0) {
5005
+ stats.skipped++;
5006
+ stats.diffs.push({
5007
+ uid,
5008
+ collectionName: ct.info?.displayName || uid,
5009
+ documentId: resolvedDocId,
5010
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5011
+ attribute: "",
5012
+ oldValue: existing,
5013
+ newValue: {},
5014
+ status: "skipped",
5015
+ message: `Skipped (no editable attributes in row ${rowNumber})`
5016
+ });
5017
+ continue;
5018
+ }
5019
+ const updateArgs = {
5020
+ documentId: resolvedDocId,
5021
+ data: mergedPayload
5022
+ };
5023
+ if (rowLocale) updateArgs.locale = rowLocale;
5024
+ await strapi.documents(uid).update(updateArgs);
5025
+ if (publish && ct.options?.draftAndPublish) {
5026
+ try {
5027
+ const pubArgs = { documentId: resolvedDocId };
5028
+ if (rowLocale) pubArgs.locale = rowLocale;
5029
+ await strapi.documents(uid).publish(pubArgs);
5030
+ } catch (pubErr) {
5031
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5032
+ }
5033
+ }
5034
+ try {
5035
+ await recordContentHistoryVersion(strapi, {
5036
+ uid,
5037
+ documentId: resolvedDocId,
5038
+ userId: user?.id,
5039
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5040
+ });
5041
+ } catch {
5042
+ }
5043
+ stats.updated++;
5044
+ for (const attrKey of Object.keys(payload)) {
5045
+ const oldVal = existing?.[attrKey] ?? null;
5046
+ const newVal = payload[attrKey];
5047
+ stats.diffs.push({
5048
+ uid,
5049
+ collectionName: ct.info?.displayName || uid,
5050
+ documentId: resolvedDocId,
5051
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5052
+ attribute: attrKey,
5053
+ oldValue: oldVal,
5054
+ newValue: newVal,
5055
+ status: "updated",
5056
+ message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5057
+ });
5058
+ }
5059
+ } else if (mode === "create_or_override") {
5060
+ let existing = null;
5061
+ if (docId) {
5062
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5063
+ }
5064
+ if (existing) {
5065
+ const resolvedDocId = existing.documentId || docId;
5066
+ const mergedPayload = { ...payload };
5067
+ for (const key of Object.keys(payload)) {
5068
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5069
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5070
+ }
5071
+ }
5072
+ const updateArgs = {
5073
+ documentId: resolvedDocId,
5074
+ data: mergedPayload
5075
+ };
5076
+ if (rowLocale) updateArgs.locale = rowLocale;
5077
+ await strapi.documents(uid).update(updateArgs);
5078
+ if (publish && ct.options?.draftAndPublish) {
5079
+ try {
5080
+ const pubArgs = { documentId: resolvedDocId };
5081
+ if (rowLocale) pubArgs.locale = rowLocale;
5082
+ await strapi.documents(uid).publish(pubArgs);
5083
+ } catch (pubErr) {
5084
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5085
+ }
5086
+ }
5087
+ try {
5088
+ await recordContentHistoryVersion(strapi, {
5089
+ uid,
5090
+ documentId: resolvedDocId,
5091
+ userId: user?.id,
5092
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5093
+ });
5094
+ } catch {
5095
+ }
5096
+ stats.updated++;
5097
+ for (const attrKey of Object.keys(payload)) {
5098
+ const oldVal = existing?.[attrKey] ?? null;
5099
+ const newVal = payload[attrKey];
5100
+ stats.diffs.push({
5101
+ uid,
5102
+ collectionName: ct.info?.displayName || uid,
5103
+ documentId: resolvedDocId,
5104
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5105
+ attribute: attrKey,
5106
+ oldValue: oldVal,
5107
+ newValue: newVal,
5108
+ status: "updated",
5109
+ message: `Overridden "${attrKey}" via CSV import (row ${rowNumber})`
5110
+ });
5111
+ }
5112
+ continue;
5113
+ }
5114
+ const createArgs = { data: payload };
5115
+ if (rowLocale) createArgs.locale = rowLocale;
5116
+ const created = await strapi.documents(uid).create(createArgs);
5117
+ const newDocId = created?.documentId || docId;
5118
+ if (publish && ct.options?.draftAndPublish && newDocId) {
5119
+ try {
5120
+ const pubArgs = { documentId: newDocId };
5121
+ if (rowLocale) pubArgs.locale = rowLocale;
5122
+ await strapi.documents(uid).publish(pubArgs);
5123
+ } catch (pubErr) {
5124
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5125
+ }
5126
+ }
5127
+ try {
5128
+ await recordContentHistoryVersion(strapi, {
5129
+ uid,
5130
+ documentId: newDocId,
5131
+ userId: user?.id,
5132
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5133
+ });
5134
+ } catch {
5135
+ }
5136
+ stats.created++;
5137
+ if (!docId) {
5138
+ stats.newWithoutDocId++;
5139
+ }
5140
+ for (const attrKey of Object.keys(payload)) {
5141
+ stats.diffs.push({
5142
+ uid,
5143
+ collectionName: ct.info?.displayName || uid,
5144
+ documentId: newDocId,
5145
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
5146
+ attribute: attrKey,
5147
+ oldValue: null,
5148
+ newValue: payload[attrKey],
5149
+ status: "created",
5150
+ message: docId ? `Created "${attrKey}" via CSV import (row ${rowNumber})` : `Created "${attrKey}" as new entry (row ${rowNumber})`
5151
+ });
5152
+ }
5153
+ } else {
5154
+ let existing = null;
5155
+ if (docId) {
5156
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5157
+ }
5158
+ if (existing) {
5159
+ const resolvedDocId = existing.documentId || docId;
5160
+ const mergedPayload = { ...payload };
5161
+ for (const key of Object.keys(payload)) {
5162
+ if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5163
+ mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5164
+ }
5165
+ }
5166
+ const updateArgs = {
5167
+ documentId: resolvedDocId,
5168
+ data: mergedPayload
5169
+ };
5170
+ if (rowLocale) updateArgs.locale = rowLocale;
5171
+ await strapi.documents(uid).update(updateArgs);
5172
+ if (publish && ct.options?.draftAndPublish) {
5173
+ try {
5174
+ const pubArgs = { documentId: resolvedDocId };
5175
+ if (rowLocale) pubArgs.locale = rowLocale;
5176
+ await strapi.documents(uid).publish(pubArgs);
5177
+ } catch (pubErr) {
5178
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5179
+ }
5180
+ }
5181
+ try {
5182
+ await recordContentHistoryVersion(strapi, {
5183
+ uid,
5184
+ documentId: resolvedDocId,
5185
+ userId: user?.id,
5186
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5187
+ });
5188
+ } catch {
5189
+ }
5190
+ stats.updated++;
5191
+ for (const attrKey of Object.keys(payload)) {
5192
+ const oldVal = existing?.[attrKey] ?? null;
5193
+ const newVal = payload[attrKey];
5194
+ stats.diffs.push({
5195
+ uid,
5196
+ collectionName: ct.info?.displayName || uid,
5197
+ documentId: resolvedDocId,
5198
+ label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5199
+ attribute: attrKey,
5200
+ oldValue: oldVal,
5201
+ newValue: newVal,
5202
+ status: "updated",
5203
+ message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5204
+ });
5205
+ }
5206
+ continue;
5207
+ }
5208
+ const createArgs = { data: payload };
5209
+ if (rowLocale) createArgs.locale = rowLocale;
5210
+ const created = await strapi.documents(uid).create(createArgs);
5211
+ const newDocId = created?.documentId || docId;
5212
+ if (publish && ct.options?.draftAndPublish && newDocId) {
5213
+ try {
5214
+ const pubArgs = { documentId: newDocId };
5215
+ if (rowLocale) pubArgs.locale = rowLocale;
5216
+ await strapi.documents(uid).publish(pubArgs);
5217
+ } catch (pubErr) {
5218
+ strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5219
+ }
5220
+ }
5221
+ try {
5222
+ await recordContentHistoryVersion(strapi, {
5223
+ uid,
5224
+ documentId: newDocId,
5225
+ userId: user?.id,
5226
+ statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5227
+ });
5228
+ } catch {
5229
+ }
5230
+ stats.created++;
5231
+ if (!docId) {
5232
+ stats.newWithoutDocId++;
5233
+ }
5234
+ for (const attrKey of Object.keys(payload)) {
5235
+ stats.diffs.push({
5236
+ uid,
5237
+ collectionName: ct.info?.displayName || uid,
5238
+ documentId: newDocId,
5239
+ label: created?.title || created?.name || `New Entry #${newDocId}`,
5240
+ attribute: attrKey,
5241
+ oldValue: null,
5242
+ newValue: payload[attrKey],
5243
+ status: "created",
5244
+ message: docId ? `Created "${attrKey}" as new entry (documentId "${docId}" not found in database - row ${rowNumber})` : `Created "${attrKey}" as new entry (no documentId provided in CSV - row ${rowNumber})`
5245
+ });
5246
+ }
5247
+ }
5248
+ } catch (err) {
5249
+ stats.errors.push({
5250
+ row: rowNumber,
5251
+ documentId: docId || void 0,
5252
+ error: extractDetailedErrorMessage(err)
5253
+ });
5254
+ }
5255
+ }
5256
+ return stats;
5257
+ }
5258
+ async function recordCsvAuditLog(strapi, ct, uid, batchId, stats, user) {
5259
+ if (stats.updated === 0 && stats.created === 0) return;
5260
+ try {
5261
+ const auditService2 = strapi.plugin("bulk-updater").service("audit");
5262
+ if (!auditService2) return;
5263
+ const isEnabled = typeof auditService2.isAuditEnabled === "function" ? auditService2.isAuditEnabled() : Boolean(auditService2.getAuditConfig?.().enabled ?? true);
5264
+ if (!isEnabled) return;
5265
+ const userEmail = user?.email || "admin@strapi.io";
5266
+ const userName = user ? `${user.firstname || ""} ${user.lastname || ""}`.trim() : "CSV Importer";
5267
+ const collectionDisplayName = ct.info?.displayName || uid;
5268
+ const updatedDiffs = stats.diffs.filter(
5269
+ (d) => d.status === "updated" || d.status === "created"
5270
+ );
5271
+ const uniqueAttributes = Array.from(
5272
+ new Set(updatedDiffs.map((d) => d.attribute))
5273
+ ).filter(Boolean);
5274
+ const attrListStr = uniqueAttributes.length > 0 ? uniqueAttributes.join(", ") : "fields";
5275
+ const batchAttribute = `CSV [${collectionDisplayName}]: ${attrListStr}`;
5276
+ let batchValue = "";
5277
+ if (updatedDiffs.length === 1) {
5278
+ const d = updatedDiffs[0];
5279
+ const newStr = d.newValue !== null && d.newValue !== void 0 ? String(d.newValue) : "(empty)";
5280
+ batchValue = `"${d.label}": ${d.attribute} → "${newStr}"`;
5281
+ } else if (updatedDiffs.length > 1 && stats.updated === 1 && stats.created === 0) {
5282
+ const d = updatedDiffs[0];
5283
+ const changes = updatedDiffs.map((ud) => `${ud.attribute}: "${ud.newValue ?? ""}"`).join(", ");
5284
+ batchValue = `"${d.label}": ${changes}`;
5285
+ } else {
5286
+ const sampleVal = updatedDiffs[0] ? ` (e.g. ${updatedDiffs[0].attribute}: "${updatedDiffs[0].newValue ?? ""}")` : "";
5287
+ batchValue = `${stats.updated} updated, ${stats.created} created in ${collectionDisplayName}${sampleVal}`;
5288
+ }
5289
+ const batchSummary = {
5290
+ batchId,
5291
+ attribute: batchAttribute,
5292
+ value: batchValue,
5293
+ updatedCount: stats.updated + stats.created,
5294
+ skippedCount: stats.skipped,
5295
+ totalTargets: stats.total
5296
+ };
5297
+ const userObj = {
5298
+ email: userEmail || "admin@strapi.io",
5299
+ firstname: userName || "Admin"
5300
+ };
5301
+ if (typeof auditService2.recordBatch === "function") {
5302
+ await auditService2.recordBatch(batchSummary, stats.diffs, userObj);
5303
+ } else if (typeof auditService2.logOperation === "function") {
5304
+ await auditService2.logOperation({
5305
+ ...batchSummary,
5306
+ userEmail,
5307
+ userName,
5308
+ diffs: stats.diffs
5309
+ });
5310
+ }
5311
+ } catch (auditErr) {
5312
+ strapi.log.warn("[bulk-updater] Failed logging CSV import audit:", auditErr);
5313
+ }
5314
+ }
5315
+ const csvService = ({ strapi }) => ({
5316
+ /**
5317
+ * Generates a sample template CSV with schema attribute headers and sample values
5318
+ */
5319
+ async getTemplateCsv(uid) {
5320
+ const ct = strapi.contentTypes[uid];
5321
+ if (!ct) {
5322
+ throw new Error(`Content type ${uid} not found`);
5323
+ }
5324
+ const attrs = ct.attributes || {};
5325
+ const excluded = ["createdAt", "updatedAt", "publishedAt", "createdBy", "updatedBy", "localizations"];
5326
+ const headers = ["documentId"];
5327
+ const sampleRow = ["(Leave empty to create new, or enter existing docId)"];
5328
+ for (const [attrName, attrDef] of Object.entries(attrs)) {
5329
+ if (excluded.includes(attrName)) continue;
5330
+ if (attrDef.type === "relation") continue;
5331
+ if (attrDef.type === "component" || attrDef.type === "dynamiczone") continue;
5332
+ headers.push(attrName);
5333
+ let sampleVal = "";
5334
+ switch (attrDef.type) {
5335
+ case "string":
5336
+ case "text":
5337
+ case "richtext":
5338
+ sampleVal = `Sample ${attrName}`;
5339
+ break;
5340
+ case "integer":
5341
+ case "biginteger":
5342
+ case "float":
5343
+ case "decimal":
5344
+ sampleVal = "10";
5345
+ break;
5346
+ case "boolean":
5347
+ sampleVal = "true";
5348
+ break;
5349
+ case "email":
5350
+ sampleVal = "sample@example.com";
5351
+ break;
5352
+ case "enumeration":
5353
+ sampleVal = attrDef.enum?.[0] || "option1";
5354
+ break;
5355
+ case "customField":
5356
+ sampleVal = "value";
5357
+ break;
5358
+ default:
5359
+ sampleVal = "sample";
5360
+ }
5361
+ sampleRow.push(sampleVal);
5362
+ }
5363
+ const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
5364
+ return {
5365
+ filename: `${ct.info?.singularName || "template"}_template.csv`,
5366
+ csvContent,
5367
+ headers
5368
+ };
5369
+ },
5370
+ /**
5371
+ * Exports entries of a collection to CSV with deep nested component support,
5372
+ * RFC-4180 CRLF line endings, and UTF-8 BOM for 100% Microsoft Excel compatibility.
5373
+ */
5374
+ async exportCollectionCsv(uid, options = {}) {
5375
+ const ct = strapi.contentTypes[uid];
5376
+ if (!ct) {
5377
+ throw new Error(`Content type ${uid} not found`);
5378
+ }
5379
+ const allComponents = strapi.components || {};
5380
+ const fields = discoverExportFields(ct, allComponents);
5381
+ const filters = {};
5382
+ const { attribute, filterOperator, filterQuery, search, documentIds } = options;
5383
+ if (documentIds && Array.isArray(documentIds) && documentIds.length > 0) {
5384
+ filters.documentId = { $in: documentIds };
5385
+ }
5386
+ if (attribute && filterOperator && filterOperator !== "none") {
5387
+ const isNested = attribute.includes(".");
5388
+ if (filterOperator === "empty") {
5389
+ if (isNested) {
5390
+ const [parent, child] = attribute.split(".");
5391
+ filters[parent] = { [child]: { $null: true } };
5392
+ } else {
5393
+ filters[attribute] = { $null: true };
5394
+ }
5395
+ } else if (filterOperator === "not_empty") {
5396
+ if (isNested) {
5397
+ const [parent, child] = attribute.split(".");
5398
+ filters[parent] = { [child]: { $notNull: true } };
5399
+ } else {
5400
+ filters[attribute] = { $notNull: true };
4656
5401
  }
4657
5402
  } else if (filterQuery !== void 0 && filterQuery !== "") {
4658
5403
  const opMap = {
@@ -4690,40 +5435,64 @@ const csvService = ({ strapi }) => ({
4690
5435
  }
4691
5436
  }
4692
5437
  const deepPopulate = buildDeepPopulate(ct.attributes || {}, allComponents);
4693
- let entries = [];
5438
+ const lines = [];
5439
+ lines.push(fields.map((f) => escapeCsvValue(f.key)).join(","));
5440
+ let totalExported = 0;
5441
+ const maxLimit = options.limit && options.limit > 0 ? options.limit : Infinity;
5442
+ const CHUNK_SIZE = 1e3;
4694
5443
  if (ct.kind === "singleType") {
4695
5444
  const findFirstArgs = {};
4696
5445
  if (deepPopulate) {
4697
5446
  findFirstArgs.populate = deepPopulate;
4698
5447
  }
4699
5448
  const doc = await strapi.documents(uid).findFirst(findFirstArgs);
4700
- entries = doc ? [doc] : [];
5449
+ if (doc) {
5450
+ const rowValues = fields.map((f) => escapeCsvValue(getNestedExportValue(doc, f.key)));
5451
+ lines.push(rowValues.join(","));
5452
+ totalExported = 1;
5453
+ }
4701
5454
  } else {
4702
- const findManyArgs = {
4703
- filters: Object.keys(filters).length > 0 ? filters : void 0,
4704
- limit: options.limit || 5e3,
4705
- sort: { createdAt: "desc" }
4706
- };
4707
- if (deepPopulate) {
4708
- findManyArgs.populate = deepPopulate;
5455
+ let currentPage = 1;
5456
+ let hasMore = true;
5457
+ while (hasMore && totalExported < maxLimit) {
5458
+ const remaining = maxLimit - totalExported;
5459
+ const currentBatchLimit = Math.min(CHUNK_SIZE, remaining);
5460
+ const findPageArgs = {
5461
+ filters: Object.keys(filters).length > 0 ? filters : void 0,
5462
+ page: currentPage,
5463
+ pageSize: currentBatchLimit,
5464
+ sort: { createdAt: "desc" }
5465
+ };
5466
+ if (deepPopulate) {
5467
+ findPageArgs.populate = deepPopulate;
5468
+ }
5469
+ const chunkEntries = await strapi.documents(uid).findMany(findPageArgs);
5470
+ if (!chunkEntries || chunkEntries.length === 0) {
5471
+ hasMore = false;
5472
+ break;
5473
+ }
5474
+ for (const entry of chunkEntries) {
5475
+ const rowValues = fields.map((f) => escapeCsvValue(getNestedExportValue(entry, f.key)));
5476
+ lines.push(rowValues.join(","));
5477
+ totalExported++;
5478
+ if (totalExported >= maxLimit) {
5479
+ hasMore = false;
5480
+ break;
5481
+ }
5482
+ }
5483
+ if (chunkEntries.length < currentBatchLimit) {
5484
+ hasMore = false;
5485
+ } else {
5486
+ currentPage++;
5487
+ }
4709
5488
  }
4710
- entries = await strapi.documents(uid).findMany(findManyArgs);
4711
- }
4712
- const lines = [];
4713
- lines.push(fields.map((f) => escapeCsvValue(f.key)).join(","));
4714
- for (const entry of entries || []) {
4715
- const rowValues = fields.map((f) => {
4716
- const val = getNestedExportValue(entry, f.key);
4717
- return escapeCsvValue(val);
4718
- });
4719
- lines.push(rowValues.join(","));
4720
5489
  }
4721
5490
  const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
4722
5491
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
4723
5492
  return {
4724
5493
  filename,
4725
5494
  csvContent,
4726
- count: entries ? entries.length : 0,
5495
+ count: totalExported,
4727
5496
  fields: fields.map((f) => f.key)
4728
5497
  };
4729
5498
  },
@@ -4784,7 +5553,7 @@ const csvService = ({ strapi }) => ({
4784
5553
  const uids = Object.keys(byUid);
4785
5554
  if (uids.length === 1) {
4786
5555
  const uid = uids[0];
4787
- return await this.exportCollectionCsv(uid, { documentIds: byUid[uid] });
5556
+ return await this.exportCollectionCsv(uid, { documentIds: byUid[uid], limit: 0 });
4788
5557
  }
4789
5558
  const rows = [];
4790
5559
  const headers = [
@@ -4835,7 +5604,77 @@ const csvService = ({ strapi }) => ({
4835
5604
  };
4836
5605
  },
4837
5606
  /**
4838
- * Imports CSV records to update existing records or insert/override records
5607
+ * Imports a single chunk of CSV rows (for large dataset streaming/batching).
5608
+ * Aggregates stats across chunks and writes unified audit trail on the final chunk.
5609
+ */
5610
+ async importChunk(uid, rows, options) {
5611
+ const ct = strapi.contentTypes[uid];
5612
+ if (!ct) {
5613
+ throw new Error(`Content type ${uid} not found`);
5614
+ }
5615
+ cleanupStaleSessions();
5616
+ const batchId = options.batchId || `csv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5617
+ let session = csvImportSessions.get(batchId);
5618
+ if (!session || options.isFirstChunk) {
5619
+ session = {
5620
+ batchId,
5621
+ uid,
5622
+ createdAt: Date.now(),
5623
+ total: 0,
5624
+ created: 0,
5625
+ updated: 0,
5626
+ skipped: 0,
5627
+ newWithoutDocId: 0,
5628
+ errors: [],
5629
+ diffs: []
5630
+ };
5631
+ csvImportSessions.set(batchId, session);
5632
+ }
5633
+ const startRowNumber = options.startRowNumber || session.total + 2;
5634
+ const chunkStats = await processCsvRows(strapi, ct, uid, rows || [], startRowNumber, options);
5635
+ session.total += chunkStats.total;
5636
+ session.created += chunkStats.created;
5637
+ session.updated += chunkStats.updated;
5638
+ session.skipped += chunkStats.skipped;
5639
+ session.newWithoutDocId += chunkStats.newWithoutDocId;
5640
+ session.errors.push(...chunkStats.errors);
5641
+ session.diffs.push(...chunkStats.diffs);
5642
+ if (options.isLastChunk) {
5643
+ await recordCsvAuditLog(strapi, ct, uid, batchId, session, options.user);
5644
+ try {
5645
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5646
+ if (bulkService && bulkService.invalidateCollectionCache) {
5647
+ bulkService.invalidateCollectionCache(uid);
5648
+ }
5649
+ } catch {
5650
+ }
5651
+ const aggregateStats = {
5652
+ total: session.total,
5653
+ created: session.created,
5654
+ updated: session.updated,
5655
+ skipped: session.skipped,
5656
+ newWithoutDocId: session.newWithoutDocId,
5657
+ errors: session.errors,
5658
+ diffs: session.diffs
5659
+ };
5660
+ csvImportSessions.delete(batchId);
5661
+ return {
5662
+ success: true,
5663
+ batchId,
5664
+ chunkStats,
5665
+ stats: aggregateStats,
5666
+ isFinished: true
5667
+ };
5668
+ }
5669
+ return {
5670
+ success: true,
5671
+ batchId,
5672
+ chunkStats,
5673
+ isFinished: false
5674
+ };
5675
+ },
5676
+ /**
5677
+ * Imports CSV records to update existing records or insert/override records (single payload).
4839
5678
  */
4840
5679
  async importCsv(uid, csvText, options) {
4841
5680
  const ct = strapi.contentTypes[uid];
@@ -4846,560 +5685,433 @@ const csvService = ({ strapi }) => ({
4846
5685
  if (!parsedRows || parsedRows.length === 0) {
4847
5686
  throw new Error("CSV is empty or could not be parsed.");
4848
5687
  }
4849
- const attrs = ct.attributes || {};
4850
- const { mode = "auto", publish = true, user } = options;
4851
- const stats = {
4852
- total: parsedRows.length,
4853
- created: 0,
4854
- updated: 0,
4855
- skipped: 0,
4856
- newWithoutDocId: 0,
4857
- errors: [],
4858
- diffs: []
4859
- };
4860
5688
  const batchId = `csv_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
4861
- const userEmail = user?.email || "admin@strapi.io";
4862
- const userName = user ? `${user.firstname || ""} ${user.lastname || ""}`.trim() : "CSV Importer";
4863
- const findExistingDocument = async (targetDocId, docLocale) => {
4864
- if (!targetDocId) return null;
4865
- try {
4866
- const queryArgs = { documentId: targetDocId };
4867
- if (docLocale) queryArgs.locale = docLocale;
4868
- const found = await strapi.documents(uid).findOne(queryArgs);
4869
- if (found) return found;
4870
- } catch {
4871
- }
4872
- if (/^\d+$/.test(targetDocId)) {
4873
- try {
4874
- const queryArgs = {
4875
- filters: { id: parseInt(targetDocId, 10) },
4876
- limit: 1
4877
- };
4878
- if (docLocale) queryArgs.locale = docLocale;
4879
- const list = await strapi.documents(uid).findMany(queryArgs);
4880
- if (list && list.length > 0) return list[0];
4881
- } catch {
4882
- }
5689
+ const stats = await processCsvRows(strapi, ct, uid, parsedRows, 2, options);
5690
+ try {
5691
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5692
+ if (bulkService && bulkService.invalidateCollectionCache) {
5693
+ bulkService.invalidateCollectionCache(uid);
4883
5694
  }
4884
- return null;
5695
+ } catch {
5696
+ }
5697
+ await recordCsvAuditLog(strapi, ct, uid, batchId, stats, options.user);
5698
+ return {
5699
+ success: true,
5700
+ batchId,
5701
+ stats
4885
5702
  };
4886
- const SYSTEM_FIELD_KEYS = /* @__PURE__ */ new Set([
4887
- "documentid",
4888
- "document_id",
4889
- "docid",
4890
- "id",
4891
- "createdat",
4892
- "updatedat",
4893
- "publishedat",
4894
- "createdby",
4895
- "updatedby",
4896
- "localizations",
4897
- "locale",
4898
- "status"
4899
- ]);
4900
- for (let i = 0; i < parsedRows.length; i++) {
4901
- const rowNumber = i + 2;
4902
- const row = parsedRows[i];
4903
- let docId = extractDocId(row);
4904
- const rowLocale = extractLocale(row);
4905
- if (ct.kind === "singleType" && !docId) {
4906
- try {
4907
- const findSingleArgs = {};
4908
- if (rowLocale) findSingleArgs.locale = rowLocale;
4909
- const singleDoc = await strapi.documents(uid).findFirst(findSingleArgs);
4910
- if (singleDoc?.documentId) {
4911
- docId = singleDoc.documentId;
4912
- }
4913
- } catch {
4914
- }
4915
- }
4916
- const payload = {};
4917
- for (const [key, rawVal] of Object.entries(row)) {
4918
- const normalizedKey = key.toLowerCase().replace(/[\s_-]/g, "");
4919
- if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
4920
- if (key.includes(".")) {
4921
- const [parent, child] = key.split(".");
4922
- const parentDef = attrs[parent];
4923
- if (parentDef?.type === "component" && !parentDef.repeatable && parentDef.component) {
4924
- const comp = strapi.components[parentDef.component];
4925
- const childDef = comp?.attributes?.[child];
4926
- if (childDef) {
4927
- if (!payload[parent]) payload[parent] = {};
4928
- let val = rawVal;
4929
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
4930
- val = null;
4931
- } else if (childDef.type === "integer" || childDef.type === "biginteger") {
4932
- const parsedInt = parseInt(rawVal, 10);
4933
- val = isNaN(parsedInt) ? null : parsedInt;
4934
- } else if (childDef.type === "float" || childDef.type === "decimal") {
4935
- const parsedFloat = parseFloat(rawVal);
4936
- val = isNaN(parsedFloat) ? null : parsedFloat;
4937
- } else if (childDef.type === "boolean") {
4938
- val = rawVal.toLowerCase() === "true" || rawVal === "1";
4939
- } else if (childDef.type === "json") {
4940
- try {
4941
- val = JSON.parse(rawVal);
4942
- } catch {
4943
- val = rawVal;
4944
- }
4945
- }
4946
- payload[parent][child] = val;
4947
- }
4948
- }
4949
- continue;
4950
- }
4951
- let attrDef = attrs[key];
4952
- if (!attrDef) {
4953
- const matchKey = Object.keys(attrs).find(
4954
- (k) => k.toLowerCase() === key.toLowerCase() || k.toLowerCase() === key.toLowerCase().replace(/\s+/g, "")
4955
- );
4956
- if (matchKey) {
4957
- attrDef = attrs[matchKey];
4958
- }
4959
- }
4960
- if (!attrDef) continue;
4961
- if (rawVal === "" || rawVal === null || rawVal === void 0) {
4962
- if (attrDef.type !== "dynamiczone" && !(attrDef.type === "component" && attrDef.repeatable)) {
4963
- payload[key] = null;
4964
- }
4965
- continue;
4966
- }
4967
- switch (attrDef.type) {
4968
- case "integer":
4969
- case "biginteger": {
4970
- const parsedInt = parseInt(rawVal, 10);
4971
- payload[key] = isNaN(parsedInt) ? null : parsedInt;
4972
- break;
4973
- }
4974
- case "float":
4975
- case "decimal": {
4976
- const parsedFloat = parseFloat(rawVal);
4977
- payload[key] = isNaN(parsedFloat) ? null : parsedFloat;
4978
- break;
4979
- }
4980
- case "boolean":
4981
- payload[key] = rawVal.toLowerCase() === "true" || rawVal === "1";
4982
- break;
4983
- case "json":
4984
- try {
4985
- payload[key] = JSON.parse(rawVal);
4986
- } catch {
4987
- payload[key] = rawVal;
4988
- }
4989
- break;
4990
- case "dynamiczone": {
4991
- try {
4992
- const parsed = JSON.parse(rawVal);
4993
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object" && typeof item.__component === "string")) {
4994
- payload[key] = parsed;
4995
- }
4996
- } catch {
4997
- }
4998
- break;
4999
- }
5000
- case "component": {
5001
- if (attrDef.repeatable) {
5002
- try {
5003
- const parsed = JSON.parse(rawVal);
5004
- if (Array.isArray(parsed) && parsed.every((item) => item && typeof item === "object")) {
5005
- payload[key] = parsed;
5006
- }
5007
- } catch {
5008
- }
5009
- } else {
5010
- try {
5011
- const parsed = JSON.parse(rawVal);
5012
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
5013
- payload[key] = parsed;
5014
- }
5015
- } catch {
5016
- }
5017
- }
5018
- break;
5019
- }
5020
- case "relation": {
5021
- try {
5022
- const parsed = JSON.parse(rawVal);
5023
- if (Array.isArray(parsed) || typeof parsed === "object" && parsed !== null) {
5024
- payload[key] = parsed;
5025
- break;
5026
- }
5027
- } catch {
5028
- }
5029
- if (isValidDocId(rawVal)) {
5030
- payload[key] = rawVal;
5031
- } else if (rawVal.includes(";") || rawVal.includes(",")) {
5032
- const parts = rawVal.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
5033
- if (parts.length > 0 && parts.every((p) => isValidDocId(p))) {
5034
- payload[key] = parts;
5035
- }
5036
- }
5037
- break;
5038
- }
5039
- case "media": {
5040
- if (/^\d+$/.test(rawVal.trim())) {
5041
- payload[key] = parseInt(rawVal.trim(), 10);
5042
- } else {
5043
- try {
5044
- const parsed = JSON.parse(rawVal);
5045
- if (typeof parsed === "number" || typeof parsed === "object") {
5046
- payload[key] = parsed;
5047
- }
5048
- } catch {
5049
- }
5050
- }
5051
- break;
5052
- }
5053
- default:
5054
- payload[key] = rawVal;
5055
- }
5703
+ },
5704
+ /**
5705
+ * Executes a pending CSV batch approved by Checker or queued for off-peak execution
5706
+ */
5707
+ async executeCsvPendingBatch(uid, rows, batchId, options = {}, checkerUser) {
5708
+ const ct = strapi.contentTypes[uid];
5709
+ if (!ct) {
5710
+ throw new Error(`Content type ${uid} not found`);
5711
+ }
5712
+ const effectiveUser = checkerUser || options.user;
5713
+ const stats = await processCsvRows(strapi, ct, uid, rows || [], 2, {
5714
+ ...options,
5715
+ user: effectiveUser
5716
+ });
5717
+ await recordCsvAuditLog(strapi, ct, uid, batchId, stats, effectiveUser);
5718
+ try {
5719
+ const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5720
+ if (bulkService && bulkService.invalidateCollectionCache) {
5721
+ bulkService.invalidateCollectionCache(uid);
5056
5722
  }
5057
- try {
5058
- if (mode === "update") {
5059
- if (!docId) {
5060
- stats.skipped++;
5061
- stats.errors.push({
5062
- row: rowNumber,
5063
- error: "Missing or invalid documentId for Update mode. (Tip: Use Smart Auto-Detect mode to auto-add entries without document IDs)."
5064
- });
5065
- continue;
5066
- }
5067
- const existing = await findExistingDocument(docId, rowLocale);
5068
- if (!existing) {
5069
- stats.skipped++;
5070
- stats.errors.push({
5723
+ } catch {
5724
+ }
5725
+ return {
5726
+ success: true,
5727
+ batchId,
5728
+ stats
5729
+ };
5730
+ },
5731
+ /**
5732
+ * Pre-Flight Dry-Run Validation: Non-mutating analysis verifying document existence,
5733
+ * data types, schema integrity, and quota rule violations before execution.
5734
+ */
5735
+ async validatePreflight(uid, rows, options = {}) {
5736
+ const ct = strapi.contentTypes[uid];
5737
+ if (!ct) {
5738
+ throw new Error(`Content type ${uid} not found`);
5739
+ }
5740
+ const { mode = "auto" } = options;
5741
+ const attrs = ct.attributes || {};
5742
+ let validationRulesService2 = null;
5743
+ let activeRules = [];
5744
+ try {
5745
+ validationRulesService2 = strapi.plugin("bulk-updater").service("validationRules");
5746
+ if (validationRulesService2) {
5747
+ const rulesRes = await validationRulesService2.getRules();
5748
+ activeRules = (rulesRes || []).filter((r) => r.enabled);
5749
+ }
5750
+ } catch {
5751
+ }
5752
+ let validRows = 0;
5753
+ let estimatedUpdates = 0;
5754
+ let estimatedCreates = 0;
5755
+ const errors2 = [];
5756
+ const warnings = [];
5757
+ for (let i = 0; i < rows.length; i++) {
5758
+ const rowNumber = i + 2;
5759
+ const row = rows[i];
5760
+ const docId = extractDocId(row);
5761
+ const rowLocale = extractLocale(row);
5762
+ let rowHasError = false;
5763
+ let existing = null;
5764
+ if (docId) {
5765
+ existing = await findExistingDocument(strapi, uid, docId, rowLocale);
5766
+ }
5767
+ if (mode === "update") {
5768
+ if (!docId) {
5769
+ errors2.push({
5770
+ row: rowNumber,
5771
+ error: "Missing or invalid documentId for Update Only mode."
5772
+ });
5773
+ rowHasError = true;
5774
+ } else if (!existing) {
5775
+ errors2.push({
5776
+ row: rowNumber,
5777
+ documentId: docId,
5778
+ error: `Document with ID "${docId}" not found in database.`
5779
+ });
5780
+ rowHasError = true;
5781
+ } else {
5782
+ estimatedUpdates++;
5783
+ }
5784
+ } else if (mode === "create_or_override") {
5785
+ if (existing) {
5786
+ estimatedUpdates++;
5787
+ } else {
5788
+ estimatedCreates++;
5789
+ }
5790
+ } else {
5791
+ if (existing) {
5792
+ estimatedUpdates++;
5793
+ } else {
5794
+ estimatedCreates++;
5795
+ if (docId) {
5796
+ warnings.push({
5071
5797
  row: rowNumber,
5072
5798
  documentId: docId,
5073
- error: `Document with ID "${docId}" not found in database.`
5074
- });
5075
- continue;
5076
- }
5077
- const resolvedDocId = existing.documentId || docId;
5078
- const mergedPayload = { ...payload };
5079
- for (const key of Object.keys(payload)) {
5080
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5081
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5082
- }
5083
- }
5084
- if (Object.keys(mergedPayload).length === 0) {
5085
- stats.skipped++;
5086
- stats.diffs.push({
5087
- uid,
5088
- collectionName: ct.info?.displayName || uid,
5089
- documentId: resolvedDocId,
5090
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5091
- attribute: "",
5092
- oldValue: existing,
5093
- newValue: {},
5094
- status: "skipped",
5095
- message: `Skipped (no editable attributes in row ${rowNumber})`
5096
- });
5097
- continue;
5098
- }
5099
- const updateArgs = {
5100
- documentId: resolvedDocId,
5101
- data: mergedPayload
5102
- };
5103
- if (rowLocale) updateArgs.locale = rowLocale;
5104
- await strapi.documents(uid).update(updateArgs);
5105
- if (publish && ct.options?.draftAndPublish) {
5106
- try {
5107
- const pubArgs = { documentId: resolvedDocId };
5108
- if (rowLocale) pubArgs.locale = rowLocale;
5109
- await strapi.documents(uid).publish(pubArgs);
5110
- } catch (pubErr) {
5111
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5112
- }
5113
- }
5114
- try {
5115
- await recordContentHistoryVersion(strapi, {
5116
- uid,
5117
- documentId: resolvedDocId,
5118
- userId: user?.id,
5119
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5120
- });
5121
- } catch {
5122
- }
5123
- stats.updated++;
5124
- for (const attrKey of Object.keys(payload)) {
5125
- const oldVal = existing?.[attrKey] ?? null;
5126
- const newVal = payload[attrKey];
5127
- stats.diffs.push({
5128
- uid,
5129
- collectionName: ct.info?.displayName || uid,
5130
- documentId: resolvedDocId,
5131
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5132
- attribute: attrKey,
5133
- oldValue: oldVal,
5134
- newValue: newVal,
5135
- status: "updated",
5136
- message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5799
+ warning: `Document ID "${docId}" not found in DB; will create as a new entry.`
5137
5800
  });
5138
5801
  }
5139
- } else if (mode === "create_or_override") {
5140
- let existing = null;
5141
- if (docId) {
5142
- existing = await findExistingDocument(docId, rowLocale);
5143
- }
5144
- if (existing) {
5145
- const resolvedDocId = existing.documentId || docId;
5146
- const mergedPayload = { ...payload };
5147
- for (const key of Object.keys(payload)) {
5148
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5149
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5150
- }
5151
- }
5152
- const updateArgs = {
5153
- documentId: resolvedDocId,
5154
- data: mergedPayload
5155
- };
5156
- if (rowLocale) updateArgs.locale = rowLocale;
5157
- await strapi.documents(uid).update(updateArgs);
5158
- if (publish && ct.options?.draftAndPublish) {
5159
- try {
5160
- const pubArgs = { documentId: resolvedDocId };
5161
- if (rowLocale) pubArgs.locale = rowLocale;
5162
- await strapi.documents(uid).publish(pubArgs);
5163
- } catch (pubErr) {
5164
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5165
- }
5166
- }
5167
- try {
5168
- await recordContentHistoryVersion(strapi, {
5169
- uid,
5170
- documentId: resolvedDocId,
5171
- userId: user?.id,
5172
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5802
+ }
5803
+ }
5804
+ for (const [colKey, rawVal] of Object.entries(row)) {
5805
+ if (!rawVal || rawVal.trim() === "") continue;
5806
+ const normalizedKey = colKey.toLowerCase().replace(/[\s_-]/g, "");
5807
+ if (SYSTEM_FIELD_KEYS.has(normalizedKey)) continue;
5808
+ let attrDef = attrs[colKey];
5809
+ if (!attrDef && !colKey.includes(".")) {
5810
+ const matchKey = Object.keys(attrs).find(
5811
+ (k) => k.toLowerCase() === colKey.toLowerCase() || k.toLowerCase() === colKey.toLowerCase().replace(/\s+/g, "")
5812
+ );
5813
+ if (matchKey) attrDef = attrs[matchKey];
5814
+ }
5815
+ if (attrDef) {
5816
+ if (attrDef.type === "integer" || attrDef.type === "biginteger") {
5817
+ const num = parseInt(rawVal, 10);
5818
+ if (isNaN(num)) {
5819
+ errors2.push({
5820
+ row: rowNumber,
5821
+ documentId: docId || void 0,
5822
+ error: `Column "${colKey}" expects an integer, received: "${rawVal}"`
5173
5823
  });
5174
- } catch {
5824
+ rowHasError = true;
5175
5825
  }
5176
- stats.updated++;
5177
- for (const attrKey of Object.keys(payload)) {
5178
- const oldVal = existing?.[attrKey] ?? null;
5179
- const newVal = payload[attrKey];
5180
- stats.diffs.push({
5181
- uid,
5182
- collectionName: ct.info?.displayName || uid,
5183
- documentId: resolvedDocId,
5184
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5185
- attribute: attrKey,
5186
- oldValue: oldVal,
5187
- newValue: newVal,
5188
- status: "updated",
5189
- message: `Overridden "${attrKey}" via CSV import (row ${rowNumber})`
5826
+ } else if (attrDef.type === "float" || attrDef.type === "decimal") {
5827
+ const num = parseFloat(rawVal);
5828
+ if (isNaN(num)) {
5829
+ errors2.push({
5830
+ row: rowNumber,
5831
+ documentId: docId || void 0,
5832
+ error: `Column "${colKey}" expects a numeric float/decimal, received: "${rawVal}"`
5190
5833
  });
5834
+ rowHasError = true;
5191
5835
  }
5192
- continue;
5193
- }
5194
- const createArgs = { data: payload };
5195
- if (rowLocale) createArgs.locale = rowLocale;
5196
- const created = await strapi.documents(uid).create(createArgs);
5197
- const newDocId = created?.documentId || docId;
5198
- if (publish && ct.options?.draftAndPublish && newDocId) {
5199
- try {
5200
- const pubArgs = { documentId: newDocId };
5201
- if (rowLocale) pubArgs.locale = rowLocale;
5202
- await strapi.documents(uid).publish(pubArgs);
5203
- } catch (pubErr) {
5204
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5205
- }
5206
- }
5207
- try {
5208
- await recordContentHistoryVersion(strapi, {
5209
- uid,
5210
- documentId: newDocId,
5211
- userId: user?.id,
5212
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5213
- });
5214
- } catch {
5215
- }
5216
- stats.created++;
5217
- if (!docId) {
5218
- stats.newWithoutDocId++;
5219
- }
5220
- for (const attrKey of Object.keys(payload)) {
5221
- stats.diffs.push({
5222
- uid,
5223
- collectionName: ct.info?.displayName || uid,
5224
- documentId: newDocId,
5225
- label: created?.title || created?.name || `New Entry #${newDocId}`,
5226
- attribute: attrKey,
5227
- oldValue: null,
5228
- newValue: payload[attrKey],
5229
- status: "created",
5230
- message: docId ? `Created "${attrKey}" via CSV import (row ${rowNumber})` : `Created "${attrKey}" as new entry (row ${rowNumber})`
5231
- });
5232
- }
5233
- } else {
5234
- let existing = null;
5235
- if (docId) {
5236
- existing = await findExistingDocument(docId, rowLocale);
5237
- }
5238
- if (existing) {
5239
- const resolvedDocId = existing.documentId || docId;
5240
- const mergedPayload = { ...payload };
5241
- for (const key of Object.keys(payload)) {
5242
- if (attrs[key]?.type === "component" && typeof payload[key] === "object" && payload[key] !== null) {
5243
- mergedPayload[key] = { ...existing[key] || {}, ...payload[key] };
5244
- }
5245
- }
5246
- const updateArgs = {
5247
- documentId: resolvedDocId,
5248
- data: mergedPayload
5249
- };
5250
- if (rowLocale) updateArgs.locale = rowLocale;
5251
- await strapi.documents(uid).update(updateArgs);
5252
- if (publish && ct.options?.draftAndPublish) {
5253
- try {
5254
- const pubArgs = { documentId: resolvedDocId };
5255
- if (rowLocale) pubArgs.locale = rowLocale;
5256
- await strapi.documents(uid).publish(pubArgs);
5257
- } catch (pubErr) {
5258
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${resolvedDocId}:`, pubErr);
5259
- }
5836
+ } else if (attrDef.type === "enumeration" && Array.isArray(attrDef.enum)) {
5837
+ if (!attrDef.enum.includes(rawVal.trim())) {
5838
+ warnings.push({
5839
+ row: rowNumber,
5840
+ documentId: docId || void 0,
5841
+ warning: `Column "${colKey}" value "${rawVal}" is not in defined enum options: [${attrDef.enum.join(", ")}]`
5842
+ });
5260
5843
  }
5844
+ } else if (attrDef.type === "json") {
5261
5845
  try {
5262
- await recordContentHistoryVersion(strapi, {
5263
- uid,
5264
- documentId: resolvedDocId,
5265
- userId: user?.id,
5266
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5267
- });
5846
+ JSON.parse(rawVal);
5268
5847
  } catch {
5269
- }
5270
- stats.updated++;
5271
- for (const attrKey of Object.keys(payload)) {
5272
- const oldVal = existing?.[attrKey] ?? null;
5273
- const newVal = payload[attrKey];
5274
- stats.diffs.push({
5275
- uid,
5276
- collectionName: ct.info?.displayName || uid,
5277
- documentId: resolvedDocId,
5278
- label: existing.title || existing.name || `Entry #${resolvedDocId}`,
5279
- attribute: attrKey,
5280
- oldValue: oldVal,
5281
- newValue: newVal,
5282
- status: "updated",
5283
- message: `Updated "${attrKey}" via CSV import (row ${rowNumber})`
5848
+ warnings.push({
5849
+ row: rowNumber,
5850
+ documentId: docId || void 0,
5851
+ warning: `Column "${colKey}" is type JSON but contains non-JSON text.`
5284
5852
  });
5285
5853
  }
5286
- continue;
5287
5854
  }
5288
- const createArgs = { data: payload };
5289
- if (rowLocale) createArgs.locale = rowLocale;
5290
- const created = await strapi.documents(uid).create(createArgs);
5291
- const newDocId = created?.documentId || docId;
5292
- if (publish && ct.options?.draftAndPublish && newDocId) {
5293
- try {
5294
- const pubArgs = { documentId: newDocId };
5295
- if (rowLocale) pubArgs.locale = rowLocale;
5296
- await strapi.documents(uid).publish(pubArgs);
5297
- } catch (pubErr) {
5298
- strapi.log.warn(`[bulk-updater] Auto-publish failed for ${newDocId}:`, pubErr);
5855
+ }
5856
+ if (activeRules.length > 0) {
5857
+ for (const rule of activeRules) {
5858
+ if (rule.collectionUid && rule.collectionUid !== "*" && rule.collectionUid !== uid) continue;
5859
+ if (rule.includeRichTextHtml && (rawVal.includes("<h") || rawVal.includes("# "))) {
5860
+ const headings = extractHeadingsFromRichText(rawVal);
5861
+ for (const [tag, count] of Object.entries(headings)) {
5862
+ const max = rule.quotas?.[tag.toLowerCase()];
5863
+ if (max !== void 0 && count > max) {
5864
+ warnings.push({
5865
+ row: rowNumber,
5866
+ documentId: docId || void 0,
5867
+ warning: `Rule "${rule.name}" quota warning: column "${colKey}" contains ${count} "${tag.toUpperCase()}" headings (maximum allowed: ${max}).`
5868
+ });
5869
+ }
5870
+ }
5299
5871
  }
5300
5872
  }
5301
- try {
5302
- await recordContentHistoryVersion(strapi, {
5303
- uid,
5304
- documentId: newDocId,
5305
- userId: user?.id,
5306
- statusOverride: publish && ct.options?.draftAndPublish ? "published" : void 0
5307
- });
5308
- } catch {
5309
- }
5310
- stats.created++;
5311
- if (!docId) {
5312
- stats.newWithoutDocId++;
5313
- }
5314
- for (const attrKey of Object.keys(payload)) {
5315
- stats.diffs.push({
5316
- uid,
5317
- collectionName: ct.info?.displayName || uid,
5318
- documentId: newDocId,
5319
- label: created?.title || created?.name || `New Entry #${newDocId}`,
5320
- attribute: attrKey,
5321
- oldValue: null,
5322
- newValue: payload[attrKey],
5323
- status: "created",
5324
- message: docId ? `Created "${attrKey}" as new entry (documentId "${docId}" not found in database - row ${rowNumber})` : `Created "${attrKey}" as new entry (no documentId provided in CSV - row ${rowNumber})`
5325
- });
5326
- }
5327
5873
  }
5328
- } catch (err) {
5329
- stats.errors.push({
5330
- row: rowNumber,
5331
- documentId: docId || void 0,
5332
- error: extractDetailedErrorMessage(err)
5333
- });
5334
5874
  }
5335
- }
5336
- try {
5337
- const bulkService = strapi.plugin("bulk-updater").service("bulkUpdater");
5338
- if (bulkService && bulkService.invalidateCollectionCache) {
5339
- bulkService.invalidateCollectionCache(uid);
5875
+ if (!rowHasError) {
5876
+ validRows++;
5340
5877
  }
5341
- } catch {
5342
5878
  }
5343
- if (stats.updated > 0 || stats.created > 0) {
5344
- try {
5345
- const auditService2 = strapi.plugin("bulk-updater").service("audit");
5346
- if (auditService2) {
5347
- const isEnabled = typeof auditService2.isAuditEnabled === "function" ? auditService2.isAuditEnabled() : Boolean(auditService2.getAuditConfig?.().enabled ?? true);
5348
- if (isEnabled) {
5349
- const collectionDisplayName = ct.info?.displayName || uid;
5350
- const updatedDiffs = stats.diffs.filter(
5351
- (d) => d.status === "updated" || d.status === "created"
5352
- );
5353
- const uniqueAttributes = Array.from(
5354
- new Set(updatedDiffs.map((d) => d.attribute))
5355
- ).filter(Boolean);
5356
- const attrListStr = uniqueAttributes.length > 0 ? uniqueAttributes.join(", ") : "fields";
5357
- const batchAttribute = `CSV [${collectionDisplayName}]: ${attrListStr}`;
5358
- let batchValue = "";
5359
- if (updatedDiffs.length === 1) {
5360
- const d = updatedDiffs[0];
5361
- const newStr = d.newValue !== null && d.newValue !== void 0 ? String(d.newValue) : "(empty)";
5362
- batchValue = `"${d.label}": ${d.attribute} → "${newStr}"`;
5363
- } else if (updatedDiffs.length > 1 && stats.updated === 1 && stats.created === 0) {
5364
- const d = updatedDiffs[0];
5365
- const changes = updatedDiffs.map((ud) => `${ud.attribute}: "${ud.newValue ?? ""}"`).join(", ");
5366
- batchValue = `"${d.label}": ${changes}`;
5367
- } else {
5368
- const sampleVal = updatedDiffs[0] ? ` (e.g. ${updatedDiffs[0].attribute}: "${updatedDiffs[0].newValue ?? ""}")` : "";
5369
- batchValue = `${stats.updated} updated, ${stats.created} created in ${collectionDisplayName}${sampleVal}`;
5370
- }
5371
- const batchSummary = {
5372
- batchId,
5373
- attribute: batchAttribute,
5374
- value: batchValue,
5375
- updatedCount: stats.updated + stats.created,
5376
- skippedCount: stats.skipped,
5377
- totalTargets: stats.total
5378
- };
5379
- const userObj = {
5380
- email: userEmail || "admin@strapi.io",
5381
- firstname: userName || "Admin"
5382
- };
5383
- if (typeof auditService2.recordBatch === "function") {
5384
- await auditService2.recordBatch(batchSummary, stats.diffs, userObj);
5385
- } else if (typeof auditService2.logOperation === "function") {
5386
- await auditService2.logOperation({
5387
- ...batchSummary,
5388
- userEmail,
5389
- userName,
5390
- diffs: stats.diffs
5391
- });
5392
- }
5393
- }
5394
- }
5395
- } catch (auditErr) {
5396
- strapi.log.warn("[bulk-updater] Failed logging CSV import audit:", auditErr);
5397
- }
5879
+ return {
5880
+ success: true,
5881
+ totalRows: rows.length,
5882
+ validRows,
5883
+ estimatedUpdates,
5884
+ estimatedCreates,
5885
+ hasErrors: errors2.length > 0,
5886
+ hasWarnings: warnings.length > 0,
5887
+ errors: errors2.slice(0, 100),
5888
+ warnings: warnings.slice(0, 100),
5889
+ totalErrorCount: errors2.length,
5890
+ totalWarningCount: warnings.length
5891
+ };
5892
+ },
5893
+ /**
5894
+ * Submits a CSV batch with Guardrails: Maker-Checker (4-Eyes), Execution Windows, Off-Peak Scheduling
5895
+ */
5896
+ async submitBatch(uid, rows, options = {}) {
5897
+ const ct = strapi.contentTypes[uid];
5898
+ if (!ct) {
5899
+ throw new Error(`Content type ${uid} not found`);
5900
+ }
5901
+ if (!rows || rows.length === 0) {
5902
+ throw new Error("No CSV records provided for batch submission.");
5903
+ }
5904
+ const {
5905
+ executionMode = "immediate",
5906
+ makerNotes = "",
5907
+ forceImmediate = false,
5908
+ mode = "auto",
5909
+ publish = true,
5910
+ user
5911
+ } = options;
5912
+ const executionConfig = getExecutionWindowConfig(strapi);
5913
+ const { isBusinessHours, policyMessage } = checkBusinessHours(executionConfig);
5914
+ const collectionDisplayName = ct.info?.displayName || uid;
5915
+ if (executionMode === "maker_checker") {
5916
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5917
+ const batch = {
5918
+ id: batchId,
5919
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5920
+ createdBy: user ? {
5921
+ id: user.id,
5922
+ email: user.email,
5923
+ firstname: user.firstname,
5924
+ lastname: user.lastname,
5925
+ username: user.username
5926
+ } : { email: "admin@system.local" },
5927
+ payload: {
5928
+ action: "csv_import",
5929
+ uid,
5930
+ rows,
5931
+ options: {
5932
+ mode,
5933
+ publish,
5934
+ user
5935
+ },
5936
+ attribute: `CSV Import: ${collectionDisplayName}`,
5937
+ value: `${rows.length} rows (${mode} mode)`,
5938
+ publishOnApproval: publish
5939
+ },
5940
+ summary: {
5941
+ attribute: `CSV Import: ${collectionDisplayName}`,
5942
+ value: `${rows.length} rows (${mode} mode)`,
5943
+ totalTargets: rows.length,
5944
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
5945
+ publishOnApproval: publish
5946
+ },
5947
+ status: "pending_approval",
5948
+ executionMode: "maker_checker",
5949
+ makerNotes
5950
+ };
5951
+ registerPendingBatch(batch, {
5952
+ id: batchId,
5953
+ createdAt: batch.createdAt,
5954
+ createdBy: batch.createdBy,
5955
+ status: "pending_approval",
5956
+ executionMode: "maker_checker",
5957
+ attribute: `CSV Import: ${collectionDisplayName}`,
5958
+ value: `${rows.length} rows (${mode} mode)`,
5959
+ totalTargets: rows.length,
5960
+ processedCount: 0,
5961
+ updatedCount: 0,
5962
+ skippedCount: 0,
5963
+ failedCount: 0,
5964
+ isLive: false,
5965
+ publishOnApproval: publish,
5966
+ makerNotes,
5967
+ results: []
5968
+ });
5969
+ return {
5970
+ success: true,
5971
+ pending: true,
5972
+ batchId,
5973
+ status: "pending_approval",
5974
+ message: "CSV batch submitted for Checker authorization (4-Eyes Maker-Checker Workflow).",
5975
+ batch
5976
+ };
5977
+ }
5978
+ if (executionMode === "queue_offpeak") {
5979
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
5980
+ const batch = {
5981
+ id: batchId,
5982
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5983
+ createdBy: user ? {
5984
+ id: user.id,
5985
+ email: user.email,
5986
+ firstname: user.firstname,
5987
+ lastname: user.lastname,
5988
+ username: user.username
5989
+ } : { email: "admin@system.local" },
5990
+ payload: {
5991
+ action: "csv_import",
5992
+ uid,
5993
+ rows,
5994
+ options: {
5995
+ mode,
5996
+ publish,
5997
+ user
5998
+ },
5999
+ attribute: `CSV Import: ${collectionDisplayName}`,
6000
+ value: `${rows.length} rows (${mode} mode)`,
6001
+ publishOnApproval: publish
6002
+ },
6003
+ summary: {
6004
+ attribute: `CSV Import: ${collectionDisplayName}`,
6005
+ value: `${rows.length} rows (${mode} mode)`,
6006
+ totalTargets: rows.length,
6007
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
6008
+ publishOnApproval: publish
6009
+ },
6010
+ status: "queued_offpeak",
6011
+ executionMode: "queue_offpeak",
6012
+ scheduledFor: executionConfig.offPeakStartTime,
6013
+ makerNotes
6014
+ };
6015
+ registerPendingBatch(batch, {
6016
+ id: batchId,
6017
+ createdAt: batch.createdAt,
6018
+ createdBy: batch.createdBy,
6019
+ status: "queued_offpeak",
6020
+ executionMode: "queue_offpeak",
6021
+ scheduledFor: executionConfig.offPeakStartTime,
6022
+ attribute: `CSV Import: ${collectionDisplayName}`,
6023
+ value: `${rows.length} rows (${mode} mode)`,
6024
+ totalTargets: rows.length,
6025
+ processedCount: 0,
6026
+ updatedCount: 0,
6027
+ skippedCount: 0,
6028
+ failedCount: 0,
6029
+ isLive: false,
6030
+ publishOnApproval: publish,
6031
+ makerNotes,
6032
+ results: []
6033
+ });
6034
+ return {
6035
+ success: true,
6036
+ pending: true,
6037
+ batchId,
6038
+ status: "queued_offpeak",
6039
+ scheduledFor: executionConfig.offPeakStartTime,
6040
+ message: `CSV batch queued for server off-peak maintenance window (${executionConfig.offPeakStartTime}).`,
6041
+ batch
6042
+ };
6043
+ }
6044
+ if (executionConfig.enabled && executionConfig.autoQueueDuringBusinessHours && isBusinessHours && !forceImmediate) {
6045
+ const batchId = `csv_batch_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
6046
+ const batch = {
6047
+ id: batchId,
6048
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6049
+ createdBy: user ? {
6050
+ id: user.id,
6051
+ email: user.email,
6052
+ firstname: user.firstname,
6053
+ lastname: user.lastname,
6054
+ username: user.username
6055
+ } : { email: "admin@system.local" },
6056
+ payload: {
6057
+ action: "csv_import",
6058
+ uid,
6059
+ rows,
6060
+ options: {
6061
+ mode,
6062
+ publish,
6063
+ user
6064
+ },
6065
+ attribute: `CSV Import: ${collectionDisplayName}`,
6066
+ value: `${rows.length} rows (${mode} mode)`,
6067
+ publishOnApproval: publish
6068
+ },
6069
+ summary: {
6070
+ attribute: `CSV Import: ${collectionDisplayName}`,
6071
+ value: `${rows.length} rows (${mode} mode)`,
6072
+ totalTargets: rows.length,
6073
+ collectionBreakdown: { [collectionDisplayName]: rows.length },
6074
+ publishOnApproval: publish
6075
+ },
6076
+ status: "queued_offpeak",
6077
+ executionMode: "queue_offpeak",
6078
+ scheduledFor: executionConfig.offPeakStartTime,
6079
+ makerNotes: "Automatically queued due to peak business hours restriction."
6080
+ };
6081
+ registerPendingBatch(batch, {
6082
+ id: batchId,
6083
+ createdAt: batch.createdAt,
6084
+ createdBy: batch.createdBy,
6085
+ status: "queued_offpeak",
6086
+ executionMode: "queue_offpeak",
6087
+ scheduledFor: executionConfig.offPeakStartTime,
6088
+ attribute: `CSV Import: ${collectionDisplayName}`,
6089
+ value: `${rows.length} rows (${mode} mode)`,
6090
+ totalTargets: rows.length,
6091
+ processedCount: 0,
6092
+ updatedCount: 0,
6093
+ skippedCount: 0,
6094
+ failedCount: 0,
6095
+ isLive: false,
6096
+ publishOnApproval: publish,
6097
+ policyMessage,
6098
+ makerNotes: "Automatically queued due to peak business hours restriction.",
6099
+ results: []
6100
+ });
6101
+ return {
6102
+ success: true,
6103
+ pending: true,
6104
+ batchId,
6105
+ status: "queued_offpeak",
6106
+ scheduledFor: executionConfig.offPeakStartTime,
6107
+ message: policyMessage,
6108
+ batch
6109
+ };
5398
6110
  }
5399
6111
  return {
5400
6112
  success: true,
5401
- batchId,
5402
- stats
6113
+ pending: false,
6114
+ executeNow: true
5403
6115
  };
5404
6116
  }
5405
6117
  });