strapi-plugin-bulk-updater 1.2.11 → 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.
@@ -59,6 +59,14 @@ const routes = {
59
59
  policies: []
60
60
  }
61
61
  },
62
+ {
63
+ method: "POST",
64
+ path: "/batch-publish",
65
+ handler: "bulkUpdater.batchPublish",
66
+ config: {
67
+ policies: []
68
+ }
69
+ },
62
70
  {
63
71
  method: "POST",
64
72
  path: "/preview",
@@ -195,6 +203,22 @@ const routes = {
195
203
  policies: []
196
204
  }
197
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
+ },
198
222
  {
199
223
  method: "GET",
200
224
  path: "/csv/export-violations/:ruleId",
@@ -353,6 +377,17 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
353
377
  ctx.body = { success: false, error: err.message };
354
378
  }
355
379
  },
380
+ async batchPublish(ctx) {
381
+ try {
382
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
383
+ const currentUser = ctx.state.user;
384
+ const result = await service.batchPublish(ctx.request.body || {}, currentUser);
385
+ ctx.body = result;
386
+ } catch (err) {
387
+ ctx.status = 400;
388
+ ctx.body = { success: false, error: err.message };
389
+ }
390
+ },
356
391
  async previewBatch(ctx) {
357
392
  try {
358
393
  const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
@@ -563,20 +598,46 @@ const csvController = ({ strapi: strapi2 }) => ({
563
598
  async exportCollection(ctx) {
564
599
  try {
565
600
  const { uid } = ctx.params;
566
- const { search, attribute, filterOperator, filterQuery, limit } = ctx.query;
601
+ const { search, attribute, filterOperator, filterQuery, limit, documentIds } = {
602
+ ...ctx.query,
603
+ ...ctx.request?.body || {}
604
+ };
567
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
+ }
568
610
  const result = await csvService2.exportCollectionCsv(uid, {
569
611
  search: search ? String(search) : void 0,
570
612
  attribute: attribute ? String(attribute) : void 0,
571
613
  filterOperator: filterOperator ? String(filterOperator) : void 0,
572
614
  filterQuery: filterQuery !== void 0 ? String(filterQuery) : void 0,
573
- limit: limit ? Number(limit) : void 0
615
+ limit: limit ? Number(limit) : void 0,
616
+ documentIds: parsedDocIds
574
617
  });
575
618
  ctx.body = {
576
619
  success: true,
577
620
  filename: result.filename,
578
621
  csvContent: result.csvContent,
579
- 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
580
641
  };
581
642
  } catch (err) {
582
643
  ctx.status = 500;
@@ -1093,6 +1154,164 @@ async function executeBatchInternal(strapi2, {
1093
1154
  results
1094
1155
  };
1095
1156
  }
1157
+ async function executePublishBatchInternal(strapi2, {
1158
+ selectedEntries = [],
1159
+ selectedCollections = []
1160
+ }, currentUser, existingBatchId) {
1161
+ const targetsMap = /* @__PURE__ */ new Map();
1162
+ for (const colUid of selectedCollections) {
1163
+ if (!strapi2.contentTypes[colUid]) continue;
1164
+ const entries = await strapi2.documents(colUid).findMany();
1165
+ for (const entry of entries || []) {
1166
+ const key = `${colUid}@@@${entry.documentId}`;
1167
+ targetsMap.set(key, { uid: colUid, documentId: entry.documentId });
1168
+ }
1169
+ }
1170
+ for (const item of selectedEntries) {
1171
+ if (!item.uid || !item.documentId) continue;
1172
+ const key = `${item.uid}@@@${item.documentId}`;
1173
+ targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
1174
+ }
1175
+ if (targetsMap.size === 0) {
1176
+ throw new Error("No collections or entries were selected to publish.");
1177
+ }
1178
+ const batchId = existingBatchId || `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
1179
+ const results = [];
1180
+ let publishedCount = 0;
1181
+ let skippedCount = 0;
1182
+ const jobState = {
1183
+ id: batchId,
1184
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1185
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1186
+ createdBy: currentUser ? {
1187
+ id: currentUser.id,
1188
+ email: currentUser.email,
1189
+ firstname: currentUser.firstname,
1190
+ lastname: currentUser.lastname,
1191
+ username: currentUser.username
1192
+ } : { email: "admin@system.local" },
1193
+ status: "processing",
1194
+ executionMode: "immediate",
1195
+ attribute: "(publish)",
1196
+ value: "published",
1197
+ totalTargets: targetsMap.size,
1198
+ processedCount: 0,
1199
+ updatedCount: 0,
1200
+ skippedCount: 0,
1201
+ failedCount: 0,
1202
+ isLive: false,
1203
+ publishOnApproval: true,
1204
+ results: []
1205
+ };
1206
+ batchJobsMap.set(batchId, jobState);
1207
+ const targets = Array.from(targetsMap.values());
1208
+ const CHUNK_SIZE = 25;
1209
+ for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
1210
+ const chunk = targets.slice(i, i + CHUNK_SIZE);
1211
+ const chunkPromises = chunk.map(async (target) => {
1212
+ try {
1213
+ const contentType = strapi2.contentTypes[target.uid];
1214
+ const displayName = contentType?.info?.displayName || target.uid;
1215
+ const isDraftAndPublish = Boolean(contentType?.options?.draftAndPublish);
1216
+ let existingDoc = null;
1217
+ try {
1218
+ existingDoc = await strapi2.documents(target.uid).findOne({
1219
+ documentId: target.documentId
1220
+ });
1221
+ } catch {
1222
+ existingDoc = null;
1223
+ }
1224
+ const entryLabel = existingDoc?.name || existingDoc?.title || existingDoc?.label || existingDoc?.documentId || `Entry #${target.documentId}`;
1225
+ if (!isDraftAndPublish) {
1226
+ return {
1227
+ uid: target.uid,
1228
+ collectionName: displayName,
1229
+ documentId: target.documentId,
1230
+ label: entryLabel,
1231
+ attribute: "(publish)",
1232
+ status: "skipped",
1233
+ message: `Skipped: Draft & Publish is disabled for collection ${displayName}`
1234
+ };
1235
+ }
1236
+ const isAlreadyPublished = Boolean(existingDoc?.publishedAt);
1237
+ await strapi2.documents(target.uid).publish({
1238
+ documentId: target.documentId
1239
+ });
1240
+ invalidateCollectionCache(target.uid);
1241
+ return {
1242
+ uid: target.uid,
1243
+ collectionName: displayName,
1244
+ documentId: target.documentId,
1245
+ label: entryLabel,
1246
+ attribute: "(publish)",
1247
+ oldValue: isAlreadyPublished ? "published" : "draft",
1248
+ newValue: "published",
1249
+ status: "updated",
1250
+ message: isAlreadyPublished ? "Re-published latest version live" : "Successfully published to live"
1251
+ };
1252
+ } catch (pubErr) {
1253
+ return {
1254
+ uid: target.uid,
1255
+ collectionName: strapi2.contentTypes[target.uid]?.info?.displayName || target.uid,
1256
+ documentId: target.documentId,
1257
+ label: `Entry #${target.documentId}`,
1258
+ attribute: "(publish)",
1259
+ status: "error",
1260
+ message: pubErr.message || "Failed to publish entry"
1261
+ };
1262
+ }
1263
+ });
1264
+ const chunkResults = await Promise.all(chunkPromises);
1265
+ for (const res of chunkResults) {
1266
+ if (res.status === "updated") publishedCount++;
1267
+ else if (res.status === "skipped") skippedCount++;
1268
+ results.push(res);
1269
+ }
1270
+ jobState.processedCount = results.length;
1271
+ jobState.updatedCount = publishedCount;
1272
+ jobState.skippedCount = skippedCount;
1273
+ jobState.failedCount = results.filter((r) => r.status === "error").length;
1274
+ }
1275
+ const affectedUids = new Set(targets.map((t) => t.uid));
1276
+ affectedUids.forEach((uid) => {
1277
+ invalidateCollectionCache(uid);
1278
+ });
1279
+ const failedCount = results.filter((r) => r.status === "error").length;
1280
+ jobState.failedCount = failedCount;
1281
+ jobState.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1282
+ jobState.isLive = publishedCount > 0;
1283
+ jobState.status = failedCount === 0 && publishedCount > 0 ? "successful" : failedCount > 0 && publishedCount > 0 ? "partial_failure" : failedCount > 0 ? "failed" : "successful";
1284
+ jobState.results = results.map((r) => ({
1285
+ ...r,
1286
+ isLive: r.status === "updated"
1287
+ }));
1288
+ const batchSummary = {
1289
+ batchId,
1290
+ attribute: "(publish)",
1291
+ value: "published",
1292
+ updatedCount: publishedCount,
1293
+ skippedCount,
1294
+ totalTargets: targetsMap.size,
1295
+ publishOnApproval: true
1296
+ };
1297
+ await strapi2.plugin("bulk-updater").service("audit").recordBatch(
1298
+ batchSummary,
1299
+ results,
1300
+ currentUser
1301
+ );
1302
+ return {
1303
+ success: true,
1304
+ batchId,
1305
+ publishedCount,
1306
+ updatedCount: publishedCount,
1307
+ skippedCount,
1308
+ failedCount,
1309
+ totalTargets: targetsMap.size,
1310
+ isLive: publishedCount > 0,
1311
+ status: jobState.status,
1312
+ results
1313
+ };
1314
+ }
1096
1315
  const bulkUpdaterService = ({ strapi: strapi2 }) => {
1097
1316
  if (!runnerInterval) {
1098
1317
  runnerInterval = setInterval(async () => {
@@ -1104,7 +1323,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1104
1323
  for (const [id, batch] of pendingBatches.entries()) {
1105
1324
  if (batch.status === "queued_offpeak" && !isBusinessHours) {
1106
1325
  strapi2.log.info(`[bulk-updater] Executing off-peak queued batch: ${id}`);
1107
- const result = await executeBatchInternal(strapi2, batch.payload, batch.createdBy, id);
1326
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, batch.createdBy, id) : await executeBatchInternal(strapi2, batch.payload, batch.createdBy, id);
1108
1327
  batch.status = "executed";
1109
1328
  batch.executionResult = result;
1110
1329
  }
@@ -1201,6 +1420,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1201
1420
  uid,
1202
1421
  kind: contentType.kind,
1203
1422
  isSingleType,
1423
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1204
1424
  singularName: contentType.info?.singularName || uid,
1205
1425
  pluralName: contentType.info?.pluralName || uid,
1206
1426
  displayName: contentType.info?.displayName || uid,
@@ -1382,6 +1602,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1382
1602
  uid,
1383
1603
  displayName: contentType.info?.displayName || uid,
1384
1604
  isSingleType,
1605
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1385
1606
  entries: [],
1386
1607
  pagination: {
1387
1608
  page: 1,
@@ -1473,6 +1694,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1473
1694
  uid,
1474
1695
  displayName: contentType.info?.displayName || uid,
1475
1696
  isSingleType: true,
1697
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1476
1698
  entries: singleDoc ? [singleDoc] : [],
1477
1699
  pagination: {
1478
1700
  page: 1,
@@ -1516,6 +1738,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1516
1738
  uid,
1517
1739
  displayName: contentType.info?.displayName || uid,
1518
1740
  isSingleType: false,
1741
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1519
1742
  entries: (entries || []).map((entry) => ({
1520
1743
  id: entry.id,
1521
1744
  documentId: entry.documentId,
@@ -2016,6 +2239,199 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2016
2239
  currentUser
2017
2240
  );
2018
2241
  },
2242
+ /**
2243
+ * Bulk Publish selected entries or collections
2244
+ */
2245
+ async batchPublish({
2246
+ selectedEntries = [],
2247
+ selectedCollections = [],
2248
+ executionMode = "immediate",
2249
+ makerNotes = "",
2250
+ forceImmediate = false
2251
+ }, currentUser) {
2252
+ const executionConfig = getExecutionWindowConfig(strapi2);
2253
+ const { isBusinessHours, policyMessage } = checkBusinessHours(executionConfig);
2254
+ if (executionMode === "maker_checker") {
2255
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2256
+ if (summary.totalTargets === 0) {
2257
+ throw new Error("No collections or entries were selected to publish.");
2258
+ }
2259
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2260
+ const batch = {
2261
+ id: batchId,
2262
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2263
+ createdBy: currentUser ? {
2264
+ id: currentUser.id,
2265
+ email: currentUser.email,
2266
+ firstname: currentUser.firstname,
2267
+ lastname: currentUser.lastname,
2268
+ username: currentUser.username
2269
+ } : { email: "admin@system.local" },
2270
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2271
+ summary: {
2272
+ attribute: "(publish)",
2273
+ value: "published",
2274
+ totalTargets: summary.totalTargets,
2275
+ collectionBreakdown: summary.collectionBreakdown,
2276
+ publishOnApproval: true
2277
+ },
2278
+ status: "pending_approval",
2279
+ executionMode: "maker_checker",
2280
+ makerNotes
2281
+ };
2282
+ pendingBatches.set(batchId, batch);
2283
+ batchJobsMap.set(batchId, {
2284
+ id: batchId,
2285
+ createdAt: batch.createdAt,
2286
+ createdBy: batch.createdBy,
2287
+ status: "pending_approval",
2288
+ executionMode: "maker_checker",
2289
+ attribute: "(publish)",
2290
+ value: "published",
2291
+ totalTargets: summary.totalTargets,
2292
+ processedCount: 0,
2293
+ updatedCount: 0,
2294
+ skippedCount: 0,
2295
+ failedCount: 0,
2296
+ isLive: false,
2297
+ publishOnApproval: true,
2298
+ makerNotes,
2299
+ results: []
2300
+ });
2301
+ return {
2302
+ success: true,
2303
+ pending: true,
2304
+ batchId,
2305
+ status: "pending_approval",
2306
+ message: "Bulk publish batch submitted for Checker authorization (4-Eyes Maker-Checker Workflow).",
2307
+ batch
2308
+ };
2309
+ }
2310
+ if (executionMode === "queue_offpeak") {
2311
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2312
+ if (summary.totalTargets === 0) {
2313
+ throw new Error("No collections or entries were selected to publish.");
2314
+ }
2315
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2316
+ const batch = {
2317
+ id: batchId,
2318
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2319
+ createdBy: currentUser ? {
2320
+ id: currentUser.id,
2321
+ email: currentUser.email,
2322
+ firstname: currentUser.firstname,
2323
+ lastname: currentUser.lastname,
2324
+ username: currentUser.username
2325
+ } : { email: "admin@system.local" },
2326
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2327
+ summary: {
2328
+ attribute: "(publish)",
2329
+ value: "published",
2330
+ totalTargets: summary.totalTargets,
2331
+ collectionBreakdown: summary.collectionBreakdown,
2332
+ publishOnApproval: true
2333
+ },
2334
+ status: "queued_offpeak",
2335
+ executionMode: "queue_offpeak",
2336
+ scheduledFor: executionConfig.offPeakStartTime,
2337
+ makerNotes
2338
+ };
2339
+ pendingBatches.set(batchId, batch);
2340
+ batchJobsMap.set(batchId, {
2341
+ id: batchId,
2342
+ createdAt: batch.createdAt,
2343
+ createdBy: batch.createdBy,
2344
+ status: "queued_offpeak",
2345
+ executionMode: "queue_offpeak",
2346
+ scheduledFor: executionConfig.offPeakStartTime,
2347
+ attribute: "(publish)",
2348
+ value: "published",
2349
+ totalTargets: summary.totalTargets,
2350
+ processedCount: 0,
2351
+ updatedCount: 0,
2352
+ skippedCount: 0,
2353
+ failedCount: 0,
2354
+ isLive: false,
2355
+ publishOnApproval: true,
2356
+ makerNotes,
2357
+ results: []
2358
+ });
2359
+ return {
2360
+ success: true,
2361
+ pending: true,
2362
+ batchId,
2363
+ status: "queued_offpeak",
2364
+ scheduledFor: executionConfig.offPeakStartTime,
2365
+ message: `Bulk publish batch queued for server off-peak maintenance window (${executionConfig.offPeakStartTime}).`,
2366
+ batch
2367
+ };
2368
+ }
2369
+ if (executionConfig.enabled && executionConfig.autoQueueDuringBusinessHours && isBusinessHours && !forceImmediate) {
2370
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2371
+ if (summary.totalTargets === 0) {
2372
+ throw new Error("No collections or entries were selected to publish.");
2373
+ }
2374
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2375
+ const batch = {
2376
+ id: batchId,
2377
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2378
+ createdBy: currentUser ? {
2379
+ id: currentUser.id,
2380
+ email: currentUser.email,
2381
+ firstname: currentUser.firstname,
2382
+ lastname: currentUser.lastname,
2383
+ username: currentUser.username
2384
+ } : { email: "admin@system.local" },
2385
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2386
+ summary: {
2387
+ attribute: "(publish)",
2388
+ value: "published",
2389
+ totalTargets: summary.totalTargets,
2390
+ collectionBreakdown: summary.collectionBreakdown,
2391
+ publishOnApproval: true
2392
+ },
2393
+ status: "queued_offpeak",
2394
+ executionMode: "queue_offpeak",
2395
+ scheduledFor: executionConfig.offPeakStartTime,
2396
+ makerNotes: "Automatically queued due to peak business hours restriction."
2397
+ };
2398
+ pendingBatches.set(batchId, batch);
2399
+ batchJobsMap.set(batchId, {
2400
+ id: batchId,
2401
+ createdAt: batch.createdAt,
2402
+ createdBy: batch.createdBy,
2403
+ status: "queued_offpeak",
2404
+ executionMode: "queue_offpeak",
2405
+ scheduledFor: executionConfig.offPeakStartTime,
2406
+ attribute: "(publish)",
2407
+ value: "published",
2408
+ totalTargets: summary.totalTargets,
2409
+ processedCount: 0,
2410
+ updatedCount: 0,
2411
+ skippedCount: 0,
2412
+ failedCount: 0,
2413
+ isLive: false,
2414
+ publishOnApproval: true,
2415
+ policyMessage,
2416
+ makerNotes: "Automatically queued due to peak business hours restriction.",
2417
+ results: []
2418
+ });
2419
+ return {
2420
+ success: true,
2421
+ pending: true,
2422
+ batchId,
2423
+ status: "queued_offpeak",
2424
+ scheduledFor: executionConfig.offPeakStartTime,
2425
+ message: policyMessage,
2426
+ batch
2427
+ };
2428
+ }
2429
+ return await executePublishBatchInternal(
2430
+ strapi2,
2431
+ { selectedEntries, selectedCollections },
2432
+ currentUser
2433
+ );
2434
+ },
2019
2435
  /**
2020
2436
  * Get Pending & Queued Batches
2021
2437
  */
@@ -2059,7 +2475,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2059
2475
  const { isBusinessHours } = checkBusinessHours(config);
2060
2476
  if (options.executeImmediately || !isBusinessHours) {
2061
2477
  batch.status = "approved";
2062
- const result = await executeBatchInternal(strapi2, batch.payload, checkerUser, batch.id);
2478
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, checkerUser, batch.id) : await executeBatchInternal(strapi2, batch.payload, checkerUser, batch.id);
2063
2479
  batch.status = "executed";
2064
2480
  batch.executionResult = result;
2065
2481
  return {
@@ -2120,7 +2536,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2120
2536
  if (batch.status === "executed") {
2121
2537
  throw new Error(`Batch ${batchId} has already been executed.`);
2122
2538
  }
2123
- const result = await executeBatchInternal(strapi2, batch.payload, currentUser, batch.id);
2539
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, currentUser, batch.id) : await executeBatchInternal(strapi2, batch.payload, currentUser, batch.id);
2124
2540
  batch.status = "executed";
2125
2541
  batch.executionResult = result;
2126
2542
  return {
@@ -2220,7 +2636,11 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2220
2636
  function escapeCsvVal(val) {
2221
2637
  if (val === null || val === void 0) return "";
2222
2638
  let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
2223
- 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")) {
2224
2644
  return `"${str2.replace(/"/g, '""')}"`;
2225
2645
  }
2226
2646
  return str2;
@@ -2264,7 +2684,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2264
2684
  return {
2265
2685
  success: true,
2266
2686
  filename,
2267
- csvContent: rows.join("\n"),
2687
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
2268
2688
  summary: {
2269
2689
  batchId: batch.id,
2270
2690
  status: batch.status,
@@ -3088,23 +3508,34 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3088
3508
  function escapeCsvValue(val) {
3089
3509
  if (val === null || val === void 0) return "";
3090
3510
  let str2 = "";
3091
- if (typeof val === "object") {
3511
+ if (val instanceof Date) {
3512
+ str2 = val.toISOString();
3513
+ } else if (typeof val === "object") {
3092
3514
  str2 = JSON.stringify(val);
3093
3515
  } else {
3094
3516
  str2 = String(val);
3095
3517
  }
3096
- 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")) {
3097
3523
  return `"${str2.replace(/"/g, '""')}"`;
3098
3524
  }
3099
3525
  return str2;
3100
3526
  }
3101
3527
  function parseCsv(csvText) {
3528
+ if (!csvText) return [];
3529
+ let cleanText = csvText;
3530
+ if (cleanText.charCodeAt(0) === 65279) {
3531
+ cleanText = cleanText.slice(1);
3532
+ }
3102
3533
  const lines = [];
3103
3534
  let current = "";
3104
3535
  let inQuotes = false;
3105
- for (let i = 0; i < csvText.length; i++) {
3106
- const char = csvText[i];
3107
- 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];
3108
3539
  if (char === '"') {
3109
3540
  if (inQuotes && nextChar === '"') {
3110
3541
  current += '"';
@@ -3164,6 +3595,103 @@ function parseCsv(csvText) {
3164
3595
  }
3165
3596
  return rows;
3166
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
+ }
3167
3695
  const csvService = ({ strapi: strapi2 }) => ({
3168
3696
  /**
3169
3697
  * Generates a sample template CSV with schema attribute headers and sample values
@@ -3212,9 +3740,7 @@ const csvService = ({ strapi: strapi2 }) => ({
3212
3740
  }
3213
3741
  sampleRow.push(sampleVal);
3214
3742
  }
3215
- const csvContent = `${headers.map(escapeCsvValue).join(",")}
3216
- ${sampleRow.map(escapeCsvValue).join(",")}
3217
- `;
3743
+ const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
3218
3744
  return {
3219
3745
  filename: `${ct.info?.singularName || "template"}_template.csv`,
3220
3746
  csvContent,
@@ -3222,65 +3748,60 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3222
3748
  };
3223
3749
  },
3224
3750
  /**
3225
- * 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.
3226
3753
  */
3227
3754
  async exportCollectionCsv(uid, options = {}) {
3228
3755
  const ct = strapi2.contentTypes[uid];
3229
3756
  if (!ct) {
3230
3757
  throw new Error(`Content type ${uid} not found`);
3231
3758
  }
3232
- const attrs = ct.attributes || {};
3233
- const excluded = ["createdBy", "updatedBy", "localizations"];
3234
- const exportFields = ["documentId"];
3235
- const compFieldsToPopulate = [];
3236
- for (const [attrName, attrDef] of Object.entries(attrs)) {
3237
- if (excluded.includes(attrName)) continue;
3238
- if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
3239
- if (attrDef.type === "component") {
3240
- if (!attrDef.repeatable && attrDef.component) {
3241
- const comp = strapi2.components[attrDef.component];
3242
- if (comp?.attributes) {
3243
- compFieldsToPopulate.push(attrName);
3244
- for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3245
- if (excluded.includes(subKey)) continue;
3246
- if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
3247
- exportFields.push(`${attrName}.${subKey}`);
3248
- }
3249
- }
3250
- }
3251
- continue;
3252
- }
3253
- exportFields.push(attrName);
3254
- }
3759
+ const allComponents = strapi2.components || {};
3760
+ const fields2 = discoverExportFields(ct, allComponents);
3255
3761
  const filters2 = {};
3256
- const { attribute, filterOperator, filterQuery, search } = options;
3257
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
3258
- switch (filterOperator) {
3259
- case "equals":
3260
- filters2[attribute] = { $eq: filterQuery };
3261
- break;
3262
- case "notEquals":
3263
- filters2[attribute] = { $ne: filterQuery };
3264
- break;
3265
- case "contains":
3266
- filters2[attribute] = { $containsi: filterQuery };
3267
- break;
3268
- case "notContains":
3269
- filters2[attribute] = { $notContainsi: filterQuery };
3270
- break;
3271
- case "startsWith":
3272
- filters2[attribute] = { $startsWith: filterQuery };
3273
- break;
3274
- case "endsWith":
3275
- filters2[attribute] = { $endsWith: filterQuery };
3276
- 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
+ }
3277
3798
  }
3278
3799
  }
3279
3800
  if (search && search.trim() !== "") {
3280
3801
  const q = search.trim();
3281
3802
  const searchConditions = [];
3282
3803
  const searchFields = ["name", "title", "email", "slug", "description"].filter(
3283
- (f) => attrs[f] && ["string", "text", "email"].includes(attrs[f].type)
3804
+ (f) => ct.attributes?.[f] && ["string", "text", "email"].includes(ct.attributes[f].type)
3284
3805
  );
3285
3806
  for (const field of searchFields) {
3286
3807
  searchConditions.push({ [field]: { $containsi: q } });
@@ -3293,34 +3814,37 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3293
3814
  }
3294
3815
  }
3295
3816
  }
3296
- const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
3297
- const entries = await strapi2.documents(uid).findMany({
3298
- filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3299
- limit: options.limit || 5e3,
3300
- sort: { createdAt: "desc" },
3301
- populate: populateOption
3302
- });
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
+ }
3303
3832
  const lines = [];
3304
- lines.push(exportFields.map(escapeCsvValue).join(","));
3833
+ lines.push(fields2.map((f) => escapeCsvValue(f.key)).join(","));
3305
3834
  for (const entry of entries || []) {
3306
- const rowValues = exportFields.map((field) => {
3307
- let val;
3308
- if (field.includes(".")) {
3309
- const [parent, child] = field.split(".");
3310
- val = entry[parent]?.[child];
3311
- } else {
3312
- val = entry[field];
3313
- }
3835
+ const rowValues = fields2.map((f) => {
3836
+ const val = getNestedExportValue(entry, f.key);
3314
3837
  return escapeCsvValue(val);
3315
3838
  });
3316
3839
  lines.push(rowValues.join(","));
3317
3840
  }
3318
- const csvContent = lines.join("\n");
3841
+ const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
3319
3842
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3320
3843
  return {
3321
3844
  filename,
3322
3845
  csvContent,
3323
- count: entries ? entries.length : 0
3846
+ count: entries ? entries.length : 0,
3847
+ fields: fields2.map((f) => f.key)
3324
3848
  };
3325
3849
  },
3326
3850
  /**
@@ -3360,11 +3884,76 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3360
3884
  const filename = `violations_${safeRuleName}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3361
3885
  return {
3362
3886
  filename,
3363
- csvContent: lines.join("\n"),
3887
+ csvContent: "\uFEFF" + lines.join("\r\n") + "\r\n",
3364
3888
  count: scanReport.violations.length,
3365
3889
  ruleName: scanReport.rule.name
3366
3890
  };
3367
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
+ },
3368
3957
  /**
3369
3958
  * Imports CSV records to update existing records or insert/override records
3370
3959
  */