strapi-plugin-bulk-updater 1.2.12 → 1.2.13

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.
@@ -203,6 +203,22 @@ const routes = {
203
203
  policies: []
204
204
  }
205
205
  },
206
+ {
207
+ method: "POST",
208
+ path: "/csv/export/:uid",
209
+ handler: "csv.exportCollection",
210
+ config: {
211
+ policies: []
212
+ }
213
+ },
214
+ {
215
+ method: "POST",
216
+ path: "/csv/export-bulk",
217
+ handler: "csv.exportBulk",
218
+ config: {
219
+ policies: []
220
+ }
221
+ },
206
222
  {
207
223
  method: "GET",
208
224
  path: "/csv/export-violations/:ruleId",
@@ -582,20 +598,46 @@ const csvController = ({ strapi: strapi2 }) => ({
582
598
  async exportCollection(ctx) {
583
599
  try {
584
600
  const { uid } = ctx.params;
585
- const { search, attribute, filterOperator, filterQuery, limit } = ctx.query;
601
+ const { search, attribute, filterOperator, filterQuery, limit, documentIds } = {
602
+ ...ctx.query,
603
+ ...ctx.request?.body || {}
604
+ };
586
605
  const csvService2 = strapi2.plugin("bulk-updater").service("csv");
606
+ let parsedDocIds;
607
+ if (documentIds) {
608
+ parsedDocIds = Array.isArray(documentIds) ? documentIds : String(documentIds).split(",").map((s) => s.trim()).filter(Boolean);
609
+ }
587
610
  const result = await csvService2.exportCollectionCsv(uid, {
588
611
  search: search ? String(search) : void 0,
589
612
  attribute: attribute ? String(attribute) : void 0,
590
613
  filterOperator: filterOperator ? String(filterOperator) : void 0,
591
614
  filterQuery: filterQuery !== void 0 ? String(filterQuery) : void 0,
592
- limit: limit ? Number(limit) : void 0
615
+ limit: limit ? Number(limit) : void 0,
616
+ documentIds: parsedDocIds
593
617
  });
594
618
  ctx.body = {
595
619
  success: true,
596
620
  filename: result.filename,
597
621
  csvContent: result.csvContent,
598
- count: result.count
622
+ count: result.count,
623
+ fields: result.fields
624
+ };
625
+ } catch (err) {
626
+ ctx.status = 500;
627
+ ctx.body = { success: false, error: err.message };
628
+ }
629
+ },
630
+ async exportBulk(ctx) {
631
+ try {
632
+ const { selectedEntries } = ctx.request.body || {};
633
+ const csvService2 = strapi2.plugin("bulk-updater").service("csv");
634
+ const result = await csvService2.exportBulkCsv(selectedEntries);
635
+ ctx.body = {
636
+ success: true,
637
+ filename: result.filename,
638
+ csvContent: result.csvContent,
639
+ count: result.count,
640
+ isMultiCollection: result.isMultiCollection
599
641
  };
600
642
  } catch (err) {
601
643
  ctx.status = 500;
@@ -2594,7 +2636,11 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2594
2636
  function escapeCsvVal(val) {
2595
2637
  if (val === null || val === void 0) return "";
2596
2638
  let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
2597
- if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
2639
+ str2 = str2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2640
+ if (/^[=+@-]$/.test(str2.charAt(0))) {
2641
+ str2 = `'${str2}`;
2642
+ }
2643
+ if (str2.includes(",") || str2.includes('"') || str2.includes("\n")) {
2598
2644
  return `"${str2.replace(/"/g, '""')}"`;
2599
2645
  }
2600
2646
  return str2;
@@ -2638,7 +2684,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2638
2684
  return {
2639
2685
  success: true,
2640
2686
  filename,
2641
- csvContent: rows.join("\n"),
2687
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
2642
2688
  summary: {
2643
2689
  batchId: batch.id,
2644
2690
  status: batch.status,
@@ -3462,23 +3508,34 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3462
3508
  function escapeCsvValue(val) {
3463
3509
  if (val === null || val === void 0) return "";
3464
3510
  let str2 = "";
3465
- if (typeof val === "object") {
3511
+ if (val instanceof Date) {
3512
+ str2 = val.toISOString();
3513
+ } else if (typeof val === "object") {
3466
3514
  str2 = JSON.stringify(val);
3467
3515
  } else {
3468
3516
  str2 = String(val);
3469
3517
  }
3470
- if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
3518
+ str2 = str2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3519
+ if (/^[=+@-]$/.test(str2.charAt(0))) {
3520
+ str2 = `'${str2}`;
3521
+ }
3522
+ if (str2.includes(",") || str2.includes('"') || str2.includes("\n")) {
3471
3523
  return `"${str2.replace(/"/g, '""')}"`;
3472
3524
  }
3473
3525
  return str2;
3474
3526
  }
3475
3527
  function parseCsv(csvText) {
3528
+ if (!csvText) return [];
3529
+ let cleanText = csvText;
3530
+ if (cleanText.charCodeAt(0) === 65279) {
3531
+ cleanText = cleanText.slice(1);
3532
+ }
3476
3533
  const lines = [];
3477
3534
  let current = "";
3478
3535
  let inQuotes = false;
3479
- for (let i = 0; i < csvText.length; i++) {
3480
- const char = csvText[i];
3481
- const nextChar = csvText[i + 1];
3536
+ for (let i = 0; i < cleanText.length; i++) {
3537
+ const char = cleanText[i];
3538
+ const nextChar = cleanText[i + 1];
3482
3539
  if (char === '"') {
3483
3540
  if (inQuotes && nextChar === '"') {
3484
3541
  current += '"';
@@ -3538,6 +3595,103 @@ function parseCsv(csvText) {
3538
3595
  }
3539
3596
  return rows;
3540
3597
  }
3598
+ function discoverExportFields(ct, allComponents, maxDepth = 4) {
3599
+ const fields2 = [{ key: "documentId", label: "documentId", type: "string" }];
3600
+ const excluded = ["createdBy", "updatedBy", "localizations"];
3601
+ function walkComponent(compUid, pathPrefix, labelPrefix, depth2) {
3602
+ if (depth2 >= maxDepth) return;
3603
+ const comp = allComponents[compUid];
3604
+ if (!comp?.attributes) return;
3605
+ for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3606
+ if (excluded.includes(subKey)) continue;
3607
+ const keyPath = `${pathPrefix}.${subKey}`;
3608
+ const displayLabel = `${labelPrefix} > ${subKey}`;
3609
+ if (subDef.type === "component" && subDef.component) {
3610
+ if (!subDef.repeatable) {
3611
+ walkComponent(subDef.component, keyPath, displayLabel, depth2 + 1);
3612
+ } else {
3613
+ fields2.push({ key: keyPath, label: displayLabel, type: "component" });
3614
+ }
3615
+ } else {
3616
+ fields2.push({ key: keyPath, label: displayLabel, type: subDef.type });
3617
+ }
3618
+ }
3619
+ }
3620
+ for (const [attrName, attrDef] of Object.entries(ct.attributes || {})) {
3621
+ if (excluded.includes(attrName)) continue;
3622
+ if (attrDef.type === "component" && attrDef.component) {
3623
+ if (!attrDef.repeatable) {
3624
+ walkComponent(attrDef.component, attrName, attrName, 0);
3625
+ } else {
3626
+ fields2.push({ key: attrName, label: attrName, type: "component" });
3627
+ }
3628
+ } else {
3629
+ fields2.push({ key: attrName, label: attrName, type: attrDef.type });
3630
+ }
3631
+ }
3632
+ return fields2;
3633
+ }
3634
+ function buildDeepPopulate(attributes, allComponents, depth2 = 0) {
3635
+ if (depth2 > 4) return true;
3636
+ const populate2 = {};
3637
+ for (const [key, def] of Object.entries(attributes || {})) {
3638
+ if (def.type === "component" && def.component) {
3639
+ const comp = allComponents[def.component];
3640
+ if (comp?.attributes) {
3641
+ populate2[key] = {
3642
+ populate: buildDeepPopulate(comp.attributes, allComponents, depth2 + 1)
3643
+ };
3644
+ } else {
3645
+ populate2[key] = true;
3646
+ }
3647
+ } else if (def.type === "dynamiczone") {
3648
+ populate2[key] = true;
3649
+ } else if (def.type === "relation" || def.type === "media") {
3650
+ populate2[key] = true;
3651
+ }
3652
+ }
3653
+ return Object.keys(populate2).length > 0 ? populate2 : true;
3654
+ }
3655
+ function getNestedExportValue(entry, path2) {
3656
+ if (!entry || !path2) return "";
3657
+ const parts = path2.split(".");
3658
+ let current = entry;
3659
+ for (let i = 0; i < parts.length; i++) {
3660
+ const part = parts[i];
3661
+ if (current === null || current === void 0) return "";
3662
+ if (Array.isArray(current)) {
3663
+ const subPath = parts.slice(i).join(".");
3664
+ return current.map((item) => {
3665
+ if (!item) return "";
3666
+ if (typeof item === "object") {
3667
+ const val = getNestedExportValue(item, subPath);
3668
+ return typeof val === "object" ? JSON.stringify(val) : String(val ?? "");
3669
+ }
3670
+ return String(item);
3671
+ }).filter(Boolean).join("; ");
3672
+ }
3673
+ current = current[part];
3674
+ }
3675
+ if (current === null || current === void 0) return "";
3676
+ if (typeof current === "object" && current.url) {
3677
+ return current.url;
3678
+ }
3679
+ if (typeof current === "object" && !Array.isArray(current)) {
3680
+ if (current.documentId || current.name || current.title) {
3681
+ return current.name || current.title || current.documentId;
3682
+ }
3683
+ }
3684
+ if (Array.isArray(current)) {
3685
+ return current.map((item) => {
3686
+ if (!item) return "";
3687
+ if (typeof item === "object") {
3688
+ return item.name || item.title || item.documentId || JSON.stringify(item);
3689
+ }
3690
+ return String(item);
3691
+ }).filter(Boolean).join("; ");
3692
+ }
3693
+ return current;
3694
+ }
3541
3695
  const csvService = ({ strapi: strapi2 }) => ({
3542
3696
  /**
3543
3697
  * Generates a sample template CSV with schema attribute headers and sample values
@@ -3586,9 +3740,7 @@ const csvService = ({ strapi: strapi2 }) => ({
3586
3740
  }
3587
3741
  sampleRow.push(sampleVal);
3588
3742
  }
3589
- const csvContent = `${headers.map(escapeCsvValue).join(",")}
3590
- ${sampleRow.map(escapeCsvValue).join(",")}
3591
- `;
3743
+ const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
3592
3744
  return {
3593
3745
  filename: `${ct.info?.singularName || "template"}_template.csv`,
3594
3746
  csvContent,
@@ -3596,65 +3748,60 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3596
3748
  };
3597
3749
  },
3598
3750
  /**
3599
- * Exports entries of a collection to CSV, with optional filters
3751
+ * Exports entries of a collection to CSV with deep nested component support,
3752
+ * RFC-4180 CRLF line endings, and UTF-8 BOM for 100% Microsoft Excel compatibility.
3600
3753
  */
3601
3754
  async exportCollectionCsv(uid, options = {}) {
3602
3755
  const ct = strapi2.contentTypes[uid];
3603
3756
  if (!ct) {
3604
3757
  throw new Error(`Content type ${uid} not found`);
3605
3758
  }
3606
- const attrs = ct.attributes || {};
3607
- const excluded = ["createdBy", "updatedBy", "localizations"];
3608
- const exportFields = ["documentId"];
3609
- const compFieldsToPopulate = [];
3610
- for (const [attrName, attrDef] of Object.entries(attrs)) {
3611
- if (excluded.includes(attrName)) continue;
3612
- if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
3613
- if (attrDef.type === "component") {
3614
- if (!attrDef.repeatable && attrDef.component) {
3615
- const comp = strapi2.components[attrDef.component];
3616
- if (comp?.attributes) {
3617
- compFieldsToPopulate.push(attrName);
3618
- for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3619
- if (excluded.includes(subKey)) continue;
3620
- if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
3621
- exportFields.push(`${attrName}.${subKey}`);
3622
- }
3623
- }
3624
- }
3625
- continue;
3626
- }
3627
- exportFields.push(attrName);
3628
- }
3759
+ const allComponents = strapi2.components || {};
3760
+ const fields2 = discoverExportFields(ct, allComponents);
3629
3761
  const filters2 = {};
3630
- const { attribute, filterOperator, filterQuery, search } = options;
3631
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
3632
- switch (filterOperator) {
3633
- case "equals":
3634
- filters2[attribute] = { $eq: filterQuery };
3635
- break;
3636
- case "notEquals":
3637
- filters2[attribute] = { $ne: filterQuery };
3638
- break;
3639
- case "contains":
3640
- filters2[attribute] = { $containsi: filterQuery };
3641
- break;
3642
- case "notContains":
3643
- filters2[attribute] = { $notContainsi: filterQuery };
3644
- break;
3645
- case "startsWith":
3646
- filters2[attribute] = { $startsWith: filterQuery };
3647
- break;
3648
- case "endsWith":
3649
- filters2[attribute] = { $endsWith: filterQuery };
3650
- break;
3762
+ const { attribute, filterOperator, filterQuery, search, documentIds } = options;
3763
+ if (documentIds && Array.isArray(documentIds) && documentIds.length > 0) {
3764
+ filters2.documentId = { $in: documentIds };
3765
+ }
3766
+ if (attribute && filterOperator && filterOperator !== "none") {
3767
+ const isNested = attribute.includes(".");
3768
+ if (filterOperator === "empty") {
3769
+ if (isNested) {
3770
+ const [parent, child] = attribute.split(".");
3771
+ filters2[parent] = { [child]: { $null: true } };
3772
+ } else {
3773
+ filters2[attribute] = { $null: true };
3774
+ }
3775
+ } else if (filterOperator === "not_empty") {
3776
+ if (isNested) {
3777
+ const [parent, child] = attribute.split(".");
3778
+ filters2[parent] = { [child]: { $notNull: true } };
3779
+ } else {
3780
+ filters2[attribute] = { $notNull: true };
3781
+ }
3782
+ } else if (filterQuery !== void 0 && filterQuery !== "") {
3783
+ const opMap = {
3784
+ equals: "$eq",
3785
+ notEquals: "$ne",
3786
+ contains: "$containsi",
3787
+ notContains: "$notContainsi",
3788
+ startsWith: "$startsWith",
3789
+ endsWith: "$endsWith"
3790
+ };
3791
+ const strapiOp = opMap[filterOperator] || "$eq";
3792
+ if (isNested) {
3793
+ const [parent, child] = attribute.split(".");
3794
+ filters2[parent] = { [child]: { [strapiOp]: filterQuery } };
3795
+ } else {
3796
+ filters2[attribute] = { [strapiOp]: filterQuery };
3797
+ }
3651
3798
  }
3652
3799
  }
3653
3800
  if (search && search.trim() !== "") {
3654
3801
  const q = search.trim();
3655
3802
  const searchConditions = [];
3656
3803
  const searchFields = ["name", "title", "email", "slug", "description"].filter(
3657
- (f) => attrs[f] && ["string", "text", "email"].includes(attrs[f].type)
3804
+ (f) => ct.attributes?.[f] && ["string", "text", "email"].includes(ct.attributes[f].type)
3658
3805
  );
3659
3806
  for (const field of searchFields) {
3660
3807
  searchConditions.push({ [field]: { $containsi: q } });
@@ -3667,34 +3814,37 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3667
3814
  }
3668
3815
  }
3669
3816
  }
3670
- const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
3671
- const entries = await strapi2.documents(uid).findMany({
3672
- filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3673
- limit: options.limit || 5e3,
3674
- sort: { createdAt: "desc" },
3675
- populate: populateOption
3676
- });
3817
+ const deepPopulate = buildDeepPopulate(ct.attributes || {}, allComponents);
3818
+ let entries = [];
3819
+ if (ct.kind === "singleType") {
3820
+ const doc = await strapi2.documents(uid).findFirst({
3821
+ populate: deepPopulate
3822
+ });
3823
+ entries = doc ? [doc] : [];
3824
+ } else {
3825
+ entries = await strapi2.documents(uid).findMany({
3826
+ filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3827
+ limit: options.limit || 5e3,
3828
+ sort: { createdAt: "desc" },
3829
+ populate: deepPopulate
3830
+ });
3831
+ }
3677
3832
  const lines = [];
3678
- lines.push(exportFields.map(escapeCsvValue).join(","));
3833
+ lines.push(fields2.map((f) => escapeCsvValue(f.key)).join(","));
3679
3834
  for (const entry of entries || []) {
3680
- const rowValues = exportFields.map((field) => {
3681
- let val;
3682
- if (field.includes(".")) {
3683
- const [parent, child] = field.split(".");
3684
- val = entry[parent]?.[child];
3685
- } else {
3686
- val = entry[field];
3687
- }
3835
+ const rowValues = fields2.map((f) => {
3836
+ const val = getNestedExportValue(entry, f.key);
3688
3837
  return escapeCsvValue(val);
3689
3838
  });
3690
3839
  lines.push(rowValues.join(","));
3691
3840
  }
3692
- const csvContent = lines.join("\n");
3841
+ const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
3693
3842
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3694
3843
  return {
3695
3844
  filename,
3696
3845
  csvContent,
3697
- count: entries ? entries.length : 0
3846
+ count: entries ? entries.length : 0,
3847
+ fields: fields2.map((f) => f.key)
3698
3848
  };
3699
3849
  },
3700
3850
  /**
@@ -3734,11 +3884,76 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3734
3884
  const filename = `violations_${safeRuleName}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3735
3885
  return {
3736
3886
  filename,
3737
- csvContent: lines.join("\n"),
3887
+ csvContent: "\uFEFF" + lines.join("\r\n") + "\r\n",
3738
3888
  count: scanReport.violations.length,
3739
3889
  ruleName: scanReport.rule.name
3740
3890
  };
3741
3891
  },
3892
+ /**
3893
+ * Bulk Export: Exports selected entries across multiple collections or a multi-collection batch.
3894
+ */
3895
+ async exportBulkCsv(selectedEntries) {
3896
+ if (!selectedEntries || selectedEntries.length === 0) {
3897
+ throw new Error("No entries selected for bulk export");
3898
+ }
3899
+ const byUid = {};
3900
+ for (const item of selectedEntries) {
3901
+ if (!byUid[item.uid]) byUid[item.uid] = [];
3902
+ byUid[item.uid].push(item.documentId);
3903
+ }
3904
+ const uids = Object.keys(byUid);
3905
+ if (uids.length === 1) {
3906
+ const uid = uids[0];
3907
+ return await this.exportCollectionCsv(uid, { documentIds: byUid[uid] });
3908
+ }
3909
+ const rows = [];
3910
+ const headers = [
3911
+ "Collection UID",
3912
+ "Collection Name",
3913
+ "Document ID",
3914
+ "Record Title / Name",
3915
+ "Primary Identifier",
3916
+ "Created At",
3917
+ "Updated At",
3918
+ "Published Status"
3919
+ ];
3920
+ rows.push(headers.map(escapeCsvValue).join(","));
3921
+ let totalExported = 0;
3922
+ for (const uid of uids) {
3923
+ const ct = strapi2.contentTypes[uid];
3924
+ if (!ct) continue;
3925
+ const displayName = ct.info?.displayName || uid;
3926
+ const docIds = byUid[uid];
3927
+ const entries = await strapi2.documents(uid).findMany({
3928
+ filters: { documentId: { $in: docIds } },
3929
+ limit: docIds.length
3930
+ });
3931
+ for (const entry of entries || []) {
3932
+ const titleOrName = entry.title || entry.name || entry.subject || entry.email || `Entry #${entry.documentId}`;
3933
+ const identifier = entry.slug || entry.email || entry.username || entry.documentId;
3934
+ const isPublished = Boolean(entry.publishedAt);
3935
+ const row = [
3936
+ uid,
3937
+ displayName,
3938
+ entry.documentId,
3939
+ titleOrName,
3940
+ identifier,
3941
+ entry.createdAt || "",
3942
+ entry.updatedAt || "",
3943
+ isPublished ? "Published" : "Draft"
3944
+ ];
3945
+ rows.push(row.map(escapeCsvValue).join(","));
3946
+ totalExported++;
3947
+ }
3948
+ }
3949
+ const filename = `bulk_export_selected_${totalExported}_entries_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3950
+ return {
3951
+ filename,
3952
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
3953
+ count: totalExported,
3954
+ isMultiCollection: true
3955
+ };
3956
+ },
3742
3957
  /**
3743
3958
  * Imports CSV records to update existing records or insert/override records
3744
3959
  */