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.
@@ -190,6 +190,22 @@ const routes = {
190
190
  policies: []
191
191
  }
192
192
  },
193
+ {
194
+ method: "POST",
195
+ path: "/csv/export/:uid",
196
+ handler: "csv.exportCollection",
197
+ config: {
198
+ policies: []
199
+ }
200
+ },
201
+ {
202
+ method: "POST",
203
+ path: "/csv/export-bulk",
204
+ handler: "csv.exportBulk",
205
+ config: {
206
+ policies: []
207
+ }
208
+ },
193
209
  {
194
210
  method: "GET",
195
211
  path: "/csv/export-violations/:ruleId",
@@ -569,20 +585,46 @@ const csvController = ({ strapi: strapi2 }) => ({
569
585
  async exportCollection(ctx) {
570
586
  try {
571
587
  const { uid } = ctx.params;
572
- const { search, attribute, filterOperator, filterQuery, limit } = ctx.query;
588
+ const { search, attribute, filterOperator, filterQuery, limit, documentIds } = {
589
+ ...ctx.query,
590
+ ...ctx.request?.body || {}
591
+ };
573
592
  const csvService2 = strapi2.plugin("bulk-updater").service("csv");
593
+ let parsedDocIds;
594
+ if (documentIds) {
595
+ parsedDocIds = Array.isArray(documentIds) ? documentIds : String(documentIds).split(",").map((s) => s.trim()).filter(Boolean);
596
+ }
574
597
  const result = await csvService2.exportCollectionCsv(uid, {
575
598
  search: search ? String(search) : void 0,
576
599
  attribute: attribute ? String(attribute) : void 0,
577
600
  filterOperator: filterOperator ? String(filterOperator) : void 0,
578
601
  filterQuery: filterQuery !== void 0 ? String(filterQuery) : void 0,
579
- limit: limit ? Number(limit) : void 0
602
+ limit: limit ? Number(limit) : void 0,
603
+ documentIds: parsedDocIds
580
604
  });
581
605
  ctx.body = {
582
606
  success: true,
583
607
  filename: result.filename,
584
608
  csvContent: result.csvContent,
585
- count: result.count
609
+ count: result.count,
610
+ fields: result.fields
611
+ };
612
+ } catch (err) {
613
+ ctx.status = 500;
614
+ ctx.body = { success: false, error: err.message };
615
+ }
616
+ },
617
+ async exportBulk(ctx) {
618
+ try {
619
+ const { selectedEntries } = ctx.request.body || {};
620
+ const csvService2 = strapi2.plugin("bulk-updater").service("csv");
621
+ const result = await csvService2.exportBulkCsv(selectedEntries);
622
+ ctx.body = {
623
+ success: true,
624
+ filename: result.filename,
625
+ csvContent: result.csvContent,
626
+ count: result.count,
627
+ isMultiCollection: result.isMultiCollection
586
628
  };
587
629
  } catch (err) {
588
630
  ctx.status = 500;
@@ -2581,7 +2623,11 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2581
2623
  function escapeCsvVal(val) {
2582
2624
  if (val === null || val === void 0) return "";
2583
2625
  let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
2584
- if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
2626
+ str2 = str2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2627
+ if (/^[=+@-]$/.test(str2.charAt(0))) {
2628
+ str2 = `'${str2}`;
2629
+ }
2630
+ if (str2.includes(",") || str2.includes('"') || str2.includes("\n")) {
2585
2631
  return `"${str2.replace(/"/g, '""')}"`;
2586
2632
  }
2587
2633
  return str2;
@@ -2625,7 +2671,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2625
2671
  return {
2626
2672
  success: true,
2627
2673
  filename,
2628
- csvContent: rows.join("\n"),
2674
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
2629
2675
  summary: {
2630
2676
  batchId: batch.id,
2631
2677
  status: batch.status,
@@ -3449,23 +3495,34 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3449
3495
  function escapeCsvValue(val) {
3450
3496
  if (val === null || val === void 0) return "";
3451
3497
  let str2 = "";
3452
- if (typeof val === "object") {
3498
+ if (val instanceof Date) {
3499
+ str2 = val.toISOString();
3500
+ } else if (typeof val === "object") {
3453
3501
  str2 = JSON.stringify(val);
3454
3502
  } else {
3455
3503
  str2 = String(val);
3456
3504
  }
3457
- if (str2.includes(",") || str2.includes('"') || str2.includes("\n") || str2.includes("\r")) {
3505
+ str2 = str2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3506
+ if (/^[=+@-]$/.test(str2.charAt(0))) {
3507
+ str2 = `'${str2}`;
3508
+ }
3509
+ if (str2.includes(",") || str2.includes('"') || str2.includes("\n")) {
3458
3510
  return `"${str2.replace(/"/g, '""')}"`;
3459
3511
  }
3460
3512
  return str2;
3461
3513
  }
3462
3514
  function parseCsv(csvText) {
3515
+ if (!csvText) return [];
3516
+ let cleanText = csvText;
3517
+ if (cleanText.charCodeAt(0) === 65279) {
3518
+ cleanText = cleanText.slice(1);
3519
+ }
3463
3520
  const lines = [];
3464
3521
  let current = "";
3465
3522
  let inQuotes = false;
3466
- for (let i = 0; i < csvText.length; i++) {
3467
- const char = csvText[i];
3468
- const nextChar = csvText[i + 1];
3523
+ for (let i = 0; i < cleanText.length; i++) {
3524
+ const char = cleanText[i];
3525
+ const nextChar = cleanText[i + 1];
3469
3526
  if (char === '"') {
3470
3527
  if (inQuotes && nextChar === '"') {
3471
3528
  current += '"';
@@ -3525,6 +3582,103 @@ function parseCsv(csvText) {
3525
3582
  }
3526
3583
  return rows;
3527
3584
  }
3585
+ function discoverExportFields(ct, allComponents, maxDepth = 4) {
3586
+ const fields2 = [{ key: "documentId", label: "documentId", type: "string" }];
3587
+ const excluded = ["createdBy", "updatedBy", "localizations"];
3588
+ function walkComponent(compUid, pathPrefix, labelPrefix, depth2) {
3589
+ if (depth2 >= maxDepth) return;
3590
+ const comp = allComponents[compUid];
3591
+ if (!comp?.attributes) return;
3592
+ for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3593
+ if (excluded.includes(subKey)) continue;
3594
+ const keyPath = `${pathPrefix}.${subKey}`;
3595
+ const displayLabel = `${labelPrefix} > ${subKey}`;
3596
+ if (subDef.type === "component" && subDef.component) {
3597
+ if (!subDef.repeatable) {
3598
+ walkComponent(subDef.component, keyPath, displayLabel, depth2 + 1);
3599
+ } else {
3600
+ fields2.push({ key: keyPath, label: displayLabel, type: "component" });
3601
+ }
3602
+ } else {
3603
+ fields2.push({ key: keyPath, label: displayLabel, type: subDef.type });
3604
+ }
3605
+ }
3606
+ }
3607
+ for (const [attrName, attrDef] of Object.entries(ct.attributes || {})) {
3608
+ if (excluded.includes(attrName)) continue;
3609
+ if (attrDef.type === "component" && attrDef.component) {
3610
+ if (!attrDef.repeatable) {
3611
+ walkComponent(attrDef.component, attrName, attrName, 0);
3612
+ } else {
3613
+ fields2.push({ key: attrName, label: attrName, type: "component" });
3614
+ }
3615
+ } else {
3616
+ fields2.push({ key: attrName, label: attrName, type: attrDef.type });
3617
+ }
3618
+ }
3619
+ return fields2;
3620
+ }
3621
+ function buildDeepPopulate(attributes, allComponents, depth2 = 0) {
3622
+ if (depth2 > 4) return true;
3623
+ const populate2 = {};
3624
+ for (const [key, def] of Object.entries(attributes || {})) {
3625
+ if (def.type === "component" && def.component) {
3626
+ const comp = allComponents[def.component];
3627
+ if (comp?.attributes) {
3628
+ populate2[key] = {
3629
+ populate: buildDeepPopulate(comp.attributes, allComponents, depth2 + 1)
3630
+ };
3631
+ } else {
3632
+ populate2[key] = true;
3633
+ }
3634
+ } else if (def.type === "dynamiczone") {
3635
+ populate2[key] = true;
3636
+ } else if (def.type === "relation" || def.type === "media") {
3637
+ populate2[key] = true;
3638
+ }
3639
+ }
3640
+ return Object.keys(populate2).length > 0 ? populate2 : true;
3641
+ }
3642
+ function getNestedExportValue(entry, path2) {
3643
+ if (!entry || !path2) return "";
3644
+ const parts = path2.split(".");
3645
+ let current = entry;
3646
+ for (let i = 0; i < parts.length; i++) {
3647
+ const part = parts[i];
3648
+ if (current === null || current === void 0) return "";
3649
+ if (Array.isArray(current)) {
3650
+ const subPath = parts.slice(i).join(".");
3651
+ return current.map((item) => {
3652
+ if (!item) return "";
3653
+ if (typeof item === "object") {
3654
+ const val = getNestedExportValue(item, subPath);
3655
+ return typeof val === "object" ? JSON.stringify(val) : String(val ?? "");
3656
+ }
3657
+ return String(item);
3658
+ }).filter(Boolean).join("; ");
3659
+ }
3660
+ current = current[part];
3661
+ }
3662
+ if (current === null || current === void 0) return "";
3663
+ if (typeof current === "object" && current.url) {
3664
+ return current.url;
3665
+ }
3666
+ if (typeof current === "object" && !Array.isArray(current)) {
3667
+ if (current.documentId || current.name || current.title) {
3668
+ return current.name || current.title || current.documentId;
3669
+ }
3670
+ }
3671
+ if (Array.isArray(current)) {
3672
+ return current.map((item) => {
3673
+ if (!item) return "";
3674
+ if (typeof item === "object") {
3675
+ return item.name || item.title || item.documentId || JSON.stringify(item);
3676
+ }
3677
+ return String(item);
3678
+ }).filter(Boolean).join("; ");
3679
+ }
3680
+ return current;
3681
+ }
3528
3682
  const csvService = ({ strapi: strapi2 }) => ({
3529
3683
  /**
3530
3684
  * Generates a sample template CSV with schema attribute headers and sample values
@@ -3573,9 +3727,7 @@ const csvService = ({ strapi: strapi2 }) => ({
3573
3727
  }
3574
3728
  sampleRow.push(sampleVal);
3575
3729
  }
3576
- const csvContent = `${headers.map(escapeCsvValue).join(",")}
3577
- ${sampleRow.map(escapeCsvValue).join(",")}
3578
- `;
3730
+ const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
3579
3731
  return {
3580
3732
  filename: `${ct.info?.singularName || "template"}_template.csv`,
3581
3733
  csvContent,
@@ -3583,65 +3735,60 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3583
3735
  };
3584
3736
  },
3585
3737
  /**
3586
- * Exports entries of a collection to CSV, with optional filters
3738
+ * Exports entries of a collection to CSV with deep nested component support,
3739
+ * RFC-4180 CRLF line endings, and UTF-8 BOM for 100% Microsoft Excel compatibility.
3587
3740
  */
3588
3741
  async exportCollectionCsv(uid, options = {}) {
3589
3742
  const ct = strapi2.contentTypes[uid];
3590
3743
  if (!ct) {
3591
3744
  throw new Error(`Content type ${uid} not found`);
3592
3745
  }
3593
- const attrs = ct.attributes || {};
3594
- const excluded = ["createdBy", "updatedBy", "localizations"];
3595
- const exportFields = ["documentId"];
3596
- const compFieldsToPopulate = [];
3597
- for (const [attrName, attrDef] of Object.entries(attrs)) {
3598
- if (excluded.includes(attrName)) continue;
3599
- if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
3600
- if (attrDef.type === "component") {
3601
- if (!attrDef.repeatable && attrDef.component) {
3602
- const comp = strapi2.components[attrDef.component];
3603
- if (comp?.attributes) {
3604
- compFieldsToPopulate.push(attrName);
3605
- for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3606
- if (excluded.includes(subKey)) continue;
3607
- if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
3608
- exportFields.push(`${attrName}.${subKey}`);
3609
- }
3610
- }
3611
- }
3612
- continue;
3613
- }
3614
- exportFields.push(attrName);
3615
- }
3746
+ const allComponents = strapi2.components || {};
3747
+ const fields2 = discoverExportFields(ct, allComponents);
3616
3748
  const filters2 = {};
3617
- const { attribute, filterOperator, filterQuery, search } = options;
3618
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
3619
- switch (filterOperator) {
3620
- case "equals":
3621
- filters2[attribute] = { $eq: filterQuery };
3622
- break;
3623
- case "notEquals":
3624
- filters2[attribute] = { $ne: filterQuery };
3625
- break;
3626
- case "contains":
3627
- filters2[attribute] = { $containsi: filterQuery };
3628
- break;
3629
- case "notContains":
3630
- filters2[attribute] = { $notContainsi: filterQuery };
3631
- break;
3632
- case "startsWith":
3633
- filters2[attribute] = { $startsWith: filterQuery };
3634
- break;
3635
- case "endsWith":
3636
- filters2[attribute] = { $endsWith: filterQuery };
3637
- break;
3749
+ const { attribute, filterOperator, filterQuery, search, documentIds } = options;
3750
+ if (documentIds && Array.isArray(documentIds) && documentIds.length > 0) {
3751
+ filters2.documentId = { $in: documentIds };
3752
+ }
3753
+ if (attribute && filterOperator && filterOperator !== "none") {
3754
+ const isNested = attribute.includes(".");
3755
+ if (filterOperator === "empty") {
3756
+ if (isNested) {
3757
+ const [parent, child] = attribute.split(".");
3758
+ filters2[parent] = { [child]: { $null: true } };
3759
+ } else {
3760
+ filters2[attribute] = { $null: true };
3761
+ }
3762
+ } else if (filterOperator === "not_empty") {
3763
+ if (isNested) {
3764
+ const [parent, child] = attribute.split(".");
3765
+ filters2[parent] = { [child]: { $notNull: true } };
3766
+ } else {
3767
+ filters2[attribute] = { $notNull: true };
3768
+ }
3769
+ } else if (filterQuery !== void 0 && filterQuery !== "") {
3770
+ const opMap = {
3771
+ equals: "$eq",
3772
+ notEquals: "$ne",
3773
+ contains: "$containsi",
3774
+ notContains: "$notContainsi",
3775
+ startsWith: "$startsWith",
3776
+ endsWith: "$endsWith"
3777
+ };
3778
+ const strapiOp = opMap[filterOperator] || "$eq";
3779
+ if (isNested) {
3780
+ const [parent, child] = attribute.split(".");
3781
+ filters2[parent] = { [child]: { [strapiOp]: filterQuery } };
3782
+ } else {
3783
+ filters2[attribute] = { [strapiOp]: filterQuery };
3784
+ }
3638
3785
  }
3639
3786
  }
3640
3787
  if (search && search.trim() !== "") {
3641
3788
  const q = search.trim();
3642
3789
  const searchConditions = [];
3643
3790
  const searchFields = ["name", "title", "email", "slug", "description"].filter(
3644
- (f) => attrs[f] && ["string", "text", "email"].includes(attrs[f].type)
3791
+ (f) => ct.attributes?.[f] && ["string", "text", "email"].includes(ct.attributes[f].type)
3645
3792
  );
3646
3793
  for (const field of searchFields) {
3647
3794
  searchConditions.push({ [field]: { $containsi: q } });
@@ -3654,34 +3801,37 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3654
3801
  }
3655
3802
  }
3656
3803
  }
3657
- const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
3658
- const entries = await strapi2.documents(uid).findMany({
3659
- filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3660
- limit: options.limit || 5e3,
3661
- sort: { createdAt: "desc" },
3662
- populate: populateOption
3663
- });
3804
+ const deepPopulate = buildDeepPopulate(ct.attributes || {}, allComponents);
3805
+ let entries = [];
3806
+ if (ct.kind === "singleType") {
3807
+ const doc = await strapi2.documents(uid).findFirst({
3808
+ populate: deepPopulate
3809
+ });
3810
+ entries = doc ? [doc] : [];
3811
+ } else {
3812
+ entries = await strapi2.documents(uid).findMany({
3813
+ filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3814
+ limit: options.limit || 5e3,
3815
+ sort: { createdAt: "desc" },
3816
+ populate: deepPopulate
3817
+ });
3818
+ }
3664
3819
  const lines = [];
3665
- lines.push(exportFields.map(escapeCsvValue).join(","));
3820
+ lines.push(fields2.map((f) => escapeCsvValue(f.key)).join(","));
3666
3821
  for (const entry of entries || []) {
3667
- const rowValues = exportFields.map((field) => {
3668
- let val;
3669
- if (field.includes(".")) {
3670
- const [parent, child] = field.split(".");
3671
- val = entry[parent]?.[child];
3672
- } else {
3673
- val = entry[field];
3674
- }
3822
+ const rowValues = fields2.map((f) => {
3823
+ const val = getNestedExportValue(entry, f.key);
3675
3824
  return escapeCsvValue(val);
3676
3825
  });
3677
3826
  lines.push(rowValues.join(","));
3678
3827
  }
3679
- const csvContent = lines.join("\n");
3828
+ const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
3680
3829
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3681
3830
  return {
3682
3831
  filename,
3683
3832
  csvContent,
3684
- count: entries ? entries.length : 0
3833
+ count: entries ? entries.length : 0,
3834
+ fields: fields2.map((f) => f.key)
3685
3835
  };
3686
3836
  },
3687
3837
  /**
@@ -3721,11 +3871,76 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3721
3871
  const filename = `violations_${safeRuleName}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3722
3872
  return {
3723
3873
  filename,
3724
- csvContent: lines.join("\n"),
3874
+ csvContent: "\uFEFF" + lines.join("\r\n") + "\r\n",
3725
3875
  count: scanReport.violations.length,
3726
3876
  ruleName: scanReport.rule.name
3727
3877
  };
3728
3878
  },
3879
+ /**
3880
+ * Bulk Export: Exports selected entries across multiple collections or a multi-collection batch.
3881
+ */
3882
+ async exportBulkCsv(selectedEntries) {
3883
+ if (!selectedEntries || selectedEntries.length === 0) {
3884
+ throw new Error("No entries selected for bulk export");
3885
+ }
3886
+ const byUid = {};
3887
+ for (const item of selectedEntries) {
3888
+ if (!byUid[item.uid]) byUid[item.uid] = [];
3889
+ byUid[item.uid].push(item.documentId);
3890
+ }
3891
+ const uids = Object.keys(byUid);
3892
+ if (uids.length === 1) {
3893
+ const uid = uids[0];
3894
+ return await this.exportCollectionCsv(uid, { documentIds: byUid[uid] });
3895
+ }
3896
+ const rows = [];
3897
+ const headers = [
3898
+ "Collection UID",
3899
+ "Collection Name",
3900
+ "Document ID",
3901
+ "Record Title / Name",
3902
+ "Primary Identifier",
3903
+ "Created At",
3904
+ "Updated At",
3905
+ "Published Status"
3906
+ ];
3907
+ rows.push(headers.map(escapeCsvValue).join(","));
3908
+ let totalExported = 0;
3909
+ for (const uid of uids) {
3910
+ const ct = strapi2.contentTypes[uid];
3911
+ if (!ct) continue;
3912
+ const displayName = ct.info?.displayName || uid;
3913
+ const docIds = byUid[uid];
3914
+ const entries = await strapi2.documents(uid).findMany({
3915
+ filters: { documentId: { $in: docIds } },
3916
+ limit: docIds.length
3917
+ });
3918
+ for (const entry of entries || []) {
3919
+ const titleOrName = entry.title || entry.name || entry.subject || entry.email || `Entry #${entry.documentId}`;
3920
+ const identifier = entry.slug || entry.email || entry.username || entry.documentId;
3921
+ const isPublished = Boolean(entry.publishedAt);
3922
+ const row = [
3923
+ uid,
3924
+ displayName,
3925
+ entry.documentId,
3926
+ titleOrName,
3927
+ identifier,
3928
+ entry.createdAt || "",
3929
+ entry.updatedAt || "",
3930
+ isPublished ? "Published" : "Draft"
3931
+ ];
3932
+ rows.push(row.map(escapeCsvValue).join(","));
3933
+ totalExported++;
3934
+ }
3935
+ }
3936
+ const filename = `bulk_export_selected_${totalExported}_entries_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3937
+ return {
3938
+ filename,
3939
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
3940
+ count: totalExported,
3941
+ isMultiCollection: true
3942
+ };
3943
+ },
3729
3944
  /**
3730
3945
  * Imports CSV records to update existing records or insert/override records
3731
3946
  */