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