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.
@@ -46,6 +46,14 @@ const routes = {
46
46
  policies: []
47
47
  }
48
48
  },
49
+ {
50
+ method: "POST",
51
+ path: "/batch-publish",
52
+ handler: "bulkUpdater.batchPublish",
53
+ config: {
54
+ policies: []
55
+ }
56
+ },
49
57
  {
50
58
  method: "POST",
51
59
  path: "/preview",
@@ -182,6 +190,22 @@ const routes = {
182
190
  policies: []
183
191
  }
184
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
+ },
185
209
  {
186
210
  method: "GET",
187
211
  path: "/csv/export-violations/:ruleId",
@@ -340,6 +364,17 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
340
364
  ctx.body = { success: false, error: err.message };
341
365
  }
342
366
  },
367
+ async batchPublish(ctx) {
368
+ try {
369
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
370
+ const currentUser = ctx.state.user;
371
+ const result = await service.batchPublish(ctx.request.body || {}, currentUser);
372
+ ctx.body = result;
373
+ } catch (err) {
374
+ ctx.status = 400;
375
+ ctx.body = { success: false, error: err.message };
376
+ }
377
+ },
343
378
  async previewBatch(ctx) {
344
379
  try {
345
380
  const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
@@ -550,20 +585,46 @@ const csvController = ({ strapi: strapi2 }) => ({
550
585
  async exportCollection(ctx) {
551
586
  try {
552
587
  const { uid } = ctx.params;
553
- const { search, attribute, filterOperator, filterQuery, limit } = ctx.query;
588
+ const { search, attribute, filterOperator, filterQuery, limit, documentIds } = {
589
+ ...ctx.query,
590
+ ...ctx.request?.body || {}
591
+ };
554
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
+ }
555
597
  const result = await csvService2.exportCollectionCsv(uid, {
556
598
  search: search ? String(search) : void 0,
557
599
  attribute: attribute ? String(attribute) : void 0,
558
600
  filterOperator: filterOperator ? String(filterOperator) : void 0,
559
601
  filterQuery: filterQuery !== void 0 ? String(filterQuery) : void 0,
560
- limit: limit ? Number(limit) : void 0
602
+ limit: limit ? Number(limit) : void 0,
603
+ documentIds: parsedDocIds
561
604
  });
562
605
  ctx.body = {
563
606
  success: true,
564
607
  filename: result.filename,
565
608
  csvContent: result.csvContent,
566
- 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
567
628
  };
568
629
  } catch (err) {
569
630
  ctx.status = 500;
@@ -1080,6 +1141,164 @@ async function executeBatchInternal(strapi2, {
1080
1141
  results
1081
1142
  };
1082
1143
  }
1144
+ async function executePublishBatchInternal(strapi2, {
1145
+ selectedEntries = [],
1146
+ selectedCollections = []
1147
+ }, currentUser, existingBatchId) {
1148
+ const targetsMap = /* @__PURE__ */ new Map();
1149
+ for (const colUid of selectedCollections) {
1150
+ if (!strapi2.contentTypes[colUid]) continue;
1151
+ const entries = await strapi2.documents(colUid).findMany();
1152
+ for (const entry of entries || []) {
1153
+ const key = `${colUid}@@@${entry.documentId}`;
1154
+ targetsMap.set(key, { uid: colUid, documentId: entry.documentId });
1155
+ }
1156
+ }
1157
+ for (const item of selectedEntries) {
1158
+ if (!item.uid || !item.documentId) continue;
1159
+ const key = `${item.uid}@@@${item.documentId}`;
1160
+ targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
1161
+ }
1162
+ if (targetsMap.size === 0) {
1163
+ throw new Error("No collections or entries were selected to publish.");
1164
+ }
1165
+ const batchId = existingBatchId || `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
1166
+ const results = [];
1167
+ let publishedCount = 0;
1168
+ let skippedCount = 0;
1169
+ const jobState = {
1170
+ id: batchId,
1171
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1172
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1173
+ createdBy: currentUser ? {
1174
+ id: currentUser.id,
1175
+ email: currentUser.email,
1176
+ firstname: currentUser.firstname,
1177
+ lastname: currentUser.lastname,
1178
+ username: currentUser.username
1179
+ } : { email: "admin@system.local" },
1180
+ status: "processing",
1181
+ executionMode: "immediate",
1182
+ attribute: "(publish)",
1183
+ value: "published",
1184
+ totalTargets: targetsMap.size,
1185
+ processedCount: 0,
1186
+ updatedCount: 0,
1187
+ skippedCount: 0,
1188
+ failedCount: 0,
1189
+ isLive: false,
1190
+ publishOnApproval: true,
1191
+ results: []
1192
+ };
1193
+ batchJobsMap.set(batchId, jobState);
1194
+ const targets = Array.from(targetsMap.values());
1195
+ const CHUNK_SIZE = 25;
1196
+ for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
1197
+ const chunk = targets.slice(i, i + CHUNK_SIZE);
1198
+ const chunkPromises = chunk.map(async (target) => {
1199
+ try {
1200
+ const contentType = strapi2.contentTypes[target.uid];
1201
+ const displayName = contentType?.info?.displayName || target.uid;
1202
+ const isDraftAndPublish = Boolean(contentType?.options?.draftAndPublish);
1203
+ let existingDoc = null;
1204
+ try {
1205
+ existingDoc = await strapi2.documents(target.uid).findOne({
1206
+ documentId: target.documentId
1207
+ });
1208
+ } catch {
1209
+ existingDoc = null;
1210
+ }
1211
+ const entryLabel = existingDoc?.name || existingDoc?.title || existingDoc?.label || existingDoc?.documentId || `Entry #${target.documentId}`;
1212
+ if (!isDraftAndPublish) {
1213
+ return {
1214
+ uid: target.uid,
1215
+ collectionName: displayName,
1216
+ documentId: target.documentId,
1217
+ label: entryLabel,
1218
+ attribute: "(publish)",
1219
+ status: "skipped",
1220
+ message: `Skipped: Draft & Publish is disabled for collection ${displayName}`
1221
+ };
1222
+ }
1223
+ const isAlreadyPublished = Boolean(existingDoc?.publishedAt);
1224
+ await strapi2.documents(target.uid).publish({
1225
+ documentId: target.documentId
1226
+ });
1227
+ invalidateCollectionCache(target.uid);
1228
+ return {
1229
+ uid: target.uid,
1230
+ collectionName: displayName,
1231
+ documentId: target.documentId,
1232
+ label: entryLabel,
1233
+ attribute: "(publish)",
1234
+ oldValue: isAlreadyPublished ? "published" : "draft",
1235
+ newValue: "published",
1236
+ status: "updated",
1237
+ message: isAlreadyPublished ? "Re-published latest version live" : "Successfully published to live"
1238
+ };
1239
+ } catch (pubErr) {
1240
+ return {
1241
+ uid: target.uid,
1242
+ collectionName: strapi2.contentTypes[target.uid]?.info?.displayName || target.uid,
1243
+ documentId: target.documentId,
1244
+ label: `Entry #${target.documentId}`,
1245
+ attribute: "(publish)",
1246
+ status: "error",
1247
+ message: pubErr.message || "Failed to publish entry"
1248
+ };
1249
+ }
1250
+ });
1251
+ const chunkResults = await Promise.all(chunkPromises);
1252
+ for (const res of chunkResults) {
1253
+ if (res.status === "updated") publishedCount++;
1254
+ else if (res.status === "skipped") skippedCount++;
1255
+ results.push(res);
1256
+ }
1257
+ jobState.processedCount = results.length;
1258
+ jobState.updatedCount = publishedCount;
1259
+ jobState.skippedCount = skippedCount;
1260
+ jobState.failedCount = results.filter((r) => r.status === "error").length;
1261
+ }
1262
+ const affectedUids = new Set(targets.map((t) => t.uid));
1263
+ affectedUids.forEach((uid) => {
1264
+ invalidateCollectionCache(uid);
1265
+ });
1266
+ const failedCount = results.filter((r) => r.status === "error").length;
1267
+ jobState.failedCount = failedCount;
1268
+ jobState.completedAt = (/* @__PURE__ */ new Date()).toISOString();
1269
+ jobState.isLive = publishedCount > 0;
1270
+ jobState.status = failedCount === 0 && publishedCount > 0 ? "successful" : failedCount > 0 && publishedCount > 0 ? "partial_failure" : failedCount > 0 ? "failed" : "successful";
1271
+ jobState.results = results.map((r) => ({
1272
+ ...r,
1273
+ isLive: r.status === "updated"
1274
+ }));
1275
+ const batchSummary = {
1276
+ batchId,
1277
+ attribute: "(publish)",
1278
+ value: "published",
1279
+ updatedCount: publishedCount,
1280
+ skippedCount,
1281
+ totalTargets: targetsMap.size,
1282
+ publishOnApproval: true
1283
+ };
1284
+ await strapi2.plugin("bulk-updater").service("audit").recordBatch(
1285
+ batchSummary,
1286
+ results,
1287
+ currentUser
1288
+ );
1289
+ return {
1290
+ success: true,
1291
+ batchId,
1292
+ publishedCount,
1293
+ updatedCount: publishedCount,
1294
+ skippedCount,
1295
+ failedCount,
1296
+ totalTargets: targetsMap.size,
1297
+ isLive: publishedCount > 0,
1298
+ status: jobState.status,
1299
+ results
1300
+ };
1301
+ }
1083
1302
  const bulkUpdaterService = ({ strapi: strapi2 }) => {
1084
1303
  if (!runnerInterval) {
1085
1304
  runnerInterval = setInterval(async () => {
@@ -1091,7 +1310,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1091
1310
  for (const [id, batch] of pendingBatches.entries()) {
1092
1311
  if (batch.status === "queued_offpeak" && !isBusinessHours) {
1093
1312
  strapi2.log.info(`[bulk-updater] Executing off-peak queued batch: ${id}`);
1094
- const result = await executeBatchInternal(strapi2, batch.payload, batch.createdBy, id);
1313
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, batch.createdBy, id) : await executeBatchInternal(strapi2, batch.payload, batch.createdBy, id);
1095
1314
  batch.status = "executed";
1096
1315
  batch.executionResult = result;
1097
1316
  }
@@ -1188,6 +1407,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1188
1407
  uid,
1189
1408
  kind: contentType.kind,
1190
1409
  isSingleType,
1410
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1191
1411
  singularName: contentType.info?.singularName || uid,
1192
1412
  pluralName: contentType.info?.pluralName || uid,
1193
1413
  displayName: contentType.info?.displayName || uid,
@@ -1369,6 +1589,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1369
1589
  uid,
1370
1590
  displayName: contentType.info?.displayName || uid,
1371
1591
  isSingleType,
1592
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1372
1593
  entries: [],
1373
1594
  pagination: {
1374
1595
  page: 1,
@@ -1460,6 +1681,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1460
1681
  uid,
1461
1682
  displayName: contentType.info?.displayName || uid,
1462
1683
  isSingleType: true,
1684
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1463
1685
  entries: singleDoc ? [singleDoc] : [],
1464
1686
  pagination: {
1465
1687
  page: 1,
@@ -1503,6 +1725,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1503
1725
  uid,
1504
1726
  displayName: contentType.info?.displayName || uid,
1505
1727
  isSingleType: false,
1728
+ draftAndPublish: Boolean(contentType.options?.draftAndPublish),
1506
1729
  entries: (entries || []).map((entry) => ({
1507
1730
  id: entry.id,
1508
1731
  documentId: entry.documentId,
@@ -2003,6 +2226,199 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2003
2226
  currentUser
2004
2227
  );
2005
2228
  },
2229
+ /**
2230
+ * Bulk Publish selected entries or collections
2231
+ */
2232
+ async batchPublish({
2233
+ selectedEntries = [],
2234
+ selectedCollections = [],
2235
+ executionMode = "immediate",
2236
+ makerNotes = "",
2237
+ forceImmediate = false
2238
+ }, currentUser) {
2239
+ const executionConfig = getExecutionWindowConfig(strapi2);
2240
+ const { isBusinessHours, policyMessage } = checkBusinessHours(executionConfig);
2241
+ if (executionMode === "maker_checker") {
2242
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2243
+ if (summary.totalTargets === 0) {
2244
+ throw new Error("No collections or entries were selected to publish.");
2245
+ }
2246
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2247
+ const batch = {
2248
+ id: batchId,
2249
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2250
+ createdBy: currentUser ? {
2251
+ id: currentUser.id,
2252
+ email: currentUser.email,
2253
+ firstname: currentUser.firstname,
2254
+ lastname: currentUser.lastname,
2255
+ username: currentUser.username
2256
+ } : { email: "admin@system.local" },
2257
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2258
+ summary: {
2259
+ attribute: "(publish)",
2260
+ value: "published",
2261
+ totalTargets: summary.totalTargets,
2262
+ collectionBreakdown: summary.collectionBreakdown,
2263
+ publishOnApproval: true
2264
+ },
2265
+ status: "pending_approval",
2266
+ executionMode: "maker_checker",
2267
+ makerNotes
2268
+ };
2269
+ pendingBatches.set(batchId, batch);
2270
+ batchJobsMap.set(batchId, {
2271
+ id: batchId,
2272
+ createdAt: batch.createdAt,
2273
+ createdBy: batch.createdBy,
2274
+ status: "pending_approval",
2275
+ executionMode: "maker_checker",
2276
+ attribute: "(publish)",
2277
+ value: "published",
2278
+ totalTargets: summary.totalTargets,
2279
+ processedCount: 0,
2280
+ updatedCount: 0,
2281
+ skippedCount: 0,
2282
+ failedCount: 0,
2283
+ isLive: false,
2284
+ publishOnApproval: true,
2285
+ makerNotes,
2286
+ results: []
2287
+ });
2288
+ return {
2289
+ success: true,
2290
+ pending: true,
2291
+ batchId,
2292
+ status: "pending_approval",
2293
+ message: "Bulk publish batch submitted for Checker authorization (4-Eyes Maker-Checker Workflow).",
2294
+ batch
2295
+ };
2296
+ }
2297
+ if (executionMode === "queue_offpeak") {
2298
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2299
+ if (summary.totalTargets === 0) {
2300
+ throw new Error("No collections or entries were selected to publish.");
2301
+ }
2302
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2303
+ const batch = {
2304
+ id: batchId,
2305
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2306
+ createdBy: currentUser ? {
2307
+ id: currentUser.id,
2308
+ email: currentUser.email,
2309
+ firstname: currentUser.firstname,
2310
+ lastname: currentUser.lastname,
2311
+ username: currentUser.username
2312
+ } : { email: "admin@system.local" },
2313
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2314
+ summary: {
2315
+ attribute: "(publish)",
2316
+ value: "published",
2317
+ totalTargets: summary.totalTargets,
2318
+ collectionBreakdown: summary.collectionBreakdown,
2319
+ publishOnApproval: true
2320
+ },
2321
+ status: "queued_offpeak",
2322
+ executionMode: "queue_offpeak",
2323
+ scheduledFor: executionConfig.offPeakStartTime,
2324
+ makerNotes
2325
+ };
2326
+ pendingBatches.set(batchId, batch);
2327
+ batchJobsMap.set(batchId, {
2328
+ id: batchId,
2329
+ createdAt: batch.createdAt,
2330
+ createdBy: batch.createdBy,
2331
+ status: "queued_offpeak",
2332
+ executionMode: "queue_offpeak",
2333
+ scheduledFor: executionConfig.offPeakStartTime,
2334
+ attribute: "(publish)",
2335
+ value: "published",
2336
+ totalTargets: summary.totalTargets,
2337
+ processedCount: 0,
2338
+ updatedCount: 0,
2339
+ skippedCount: 0,
2340
+ failedCount: 0,
2341
+ isLive: false,
2342
+ publishOnApproval: true,
2343
+ makerNotes,
2344
+ results: []
2345
+ });
2346
+ return {
2347
+ success: true,
2348
+ pending: true,
2349
+ batchId,
2350
+ status: "queued_offpeak",
2351
+ scheduledFor: executionConfig.offPeakStartTime,
2352
+ message: `Bulk publish batch queued for server off-peak maintenance window (${executionConfig.offPeakStartTime}).`,
2353
+ batch
2354
+ };
2355
+ }
2356
+ if (executionConfig.enabled && executionConfig.autoQueueDuringBusinessHours && isBusinessHours && !forceImmediate) {
2357
+ const summary = await computeTargetsSummary(strapi2, { selectedCollections, selectedEntries });
2358
+ if (summary.totalTargets === 0) {
2359
+ throw new Error("No collections or entries were selected to publish.");
2360
+ }
2361
+ const batchId = `batch_pub_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
2362
+ const batch = {
2363
+ id: batchId,
2364
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2365
+ createdBy: currentUser ? {
2366
+ id: currentUser.id,
2367
+ email: currentUser.email,
2368
+ firstname: currentUser.firstname,
2369
+ lastname: currentUser.lastname,
2370
+ username: currentUser.username
2371
+ } : { email: "admin@system.local" },
2372
+ payload: { action: "publish", attribute: "(publish)", value: "published", selectedEntries, selectedCollections, publishOnApproval: true },
2373
+ summary: {
2374
+ attribute: "(publish)",
2375
+ value: "published",
2376
+ totalTargets: summary.totalTargets,
2377
+ collectionBreakdown: summary.collectionBreakdown,
2378
+ publishOnApproval: true
2379
+ },
2380
+ status: "queued_offpeak",
2381
+ executionMode: "queue_offpeak",
2382
+ scheduledFor: executionConfig.offPeakStartTime,
2383
+ makerNotes: "Automatically queued due to peak business hours restriction."
2384
+ };
2385
+ pendingBatches.set(batchId, batch);
2386
+ batchJobsMap.set(batchId, {
2387
+ id: batchId,
2388
+ createdAt: batch.createdAt,
2389
+ createdBy: batch.createdBy,
2390
+ status: "queued_offpeak",
2391
+ executionMode: "queue_offpeak",
2392
+ scheduledFor: executionConfig.offPeakStartTime,
2393
+ attribute: "(publish)",
2394
+ value: "published",
2395
+ totalTargets: summary.totalTargets,
2396
+ processedCount: 0,
2397
+ updatedCount: 0,
2398
+ skippedCount: 0,
2399
+ failedCount: 0,
2400
+ isLive: false,
2401
+ publishOnApproval: true,
2402
+ policyMessage,
2403
+ makerNotes: "Automatically queued due to peak business hours restriction.",
2404
+ results: []
2405
+ });
2406
+ return {
2407
+ success: true,
2408
+ pending: true,
2409
+ batchId,
2410
+ status: "queued_offpeak",
2411
+ scheduledFor: executionConfig.offPeakStartTime,
2412
+ message: policyMessage,
2413
+ batch
2414
+ };
2415
+ }
2416
+ return await executePublishBatchInternal(
2417
+ strapi2,
2418
+ { selectedEntries, selectedCollections },
2419
+ currentUser
2420
+ );
2421
+ },
2006
2422
  /**
2007
2423
  * Get Pending & Queued Batches
2008
2424
  */
@@ -2046,7 +2462,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2046
2462
  const { isBusinessHours } = checkBusinessHours(config);
2047
2463
  if (options.executeImmediately || !isBusinessHours) {
2048
2464
  batch.status = "approved";
2049
- const result = await executeBatchInternal(strapi2, batch.payload, checkerUser, batch.id);
2465
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, checkerUser, batch.id) : await executeBatchInternal(strapi2, batch.payload, checkerUser, batch.id);
2050
2466
  batch.status = "executed";
2051
2467
  batch.executionResult = result;
2052
2468
  return {
@@ -2107,7 +2523,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2107
2523
  if (batch.status === "executed") {
2108
2524
  throw new Error(`Batch ${batchId} has already been executed.`);
2109
2525
  }
2110
- const result = await executeBatchInternal(strapi2, batch.payload, currentUser, batch.id);
2526
+ const result = batch.payload?.action === "publish" ? await executePublishBatchInternal(strapi2, batch.payload, currentUser, batch.id) : await executeBatchInternal(strapi2, batch.payload, currentUser, batch.id);
2111
2527
  batch.status = "executed";
2112
2528
  batch.executionResult = result;
2113
2529
  return {
@@ -2207,7 +2623,11 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2207
2623
  function escapeCsvVal(val) {
2208
2624
  if (val === null || val === void 0) return "";
2209
2625
  let str2 = typeof val === "object" ? JSON.stringify(val) : String(val);
2210
- 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")) {
2211
2631
  return `"${str2.replace(/"/g, '""')}"`;
2212
2632
  }
2213
2633
  return str2;
@@ -2251,7 +2671,7 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
2251
2671
  return {
2252
2672
  success: true,
2253
2673
  filename,
2254
- csvContent: rows.join("\n"),
2674
+ csvContent: "\uFEFF" + rows.join("\r\n") + "\r\n",
2255
2675
  summary: {
2256
2676
  batchId: batch.id,
2257
2677
  status: batch.status,
@@ -3075,23 +3495,34 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3075
3495
  function escapeCsvValue(val) {
3076
3496
  if (val === null || val === void 0) return "";
3077
3497
  let str2 = "";
3078
- if (typeof val === "object") {
3498
+ if (val instanceof Date) {
3499
+ str2 = val.toISOString();
3500
+ } else if (typeof val === "object") {
3079
3501
  str2 = JSON.stringify(val);
3080
3502
  } else {
3081
3503
  str2 = String(val);
3082
3504
  }
3083
- 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")) {
3084
3510
  return `"${str2.replace(/"/g, '""')}"`;
3085
3511
  }
3086
3512
  return str2;
3087
3513
  }
3088
3514
  function parseCsv(csvText) {
3515
+ if (!csvText) return [];
3516
+ let cleanText = csvText;
3517
+ if (cleanText.charCodeAt(0) === 65279) {
3518
+ cleanText = cleanText.slice(1);
3519
+ }
3089
3520
  const lines = [];
3090
3521
  let current = "";
3091
3522
  let inQuotes = false;
3092
- for (let i = 0; i < csvText.length; i++) {
3093
- const char = csvText[i];
3094
- 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];
3095
3526
  if (char === '"') {
3096
3527
  if (inQuotes && nextChar === '"') {
3097
3528
  current += '"';
@@ -3151,6 +3582,103 @@ function parseCsv(csvText) {
3151
3582
  }
3152
3583
  return rows;
3153
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
+ }
3154
3682
  const csvService = ({ strapi: strapi2 }) => ({
3155
3683
  /**
3156
3684
  * Generates a sample template CSV with schema attribute headers and sample values
@@ -3199,9 +3727,7 @@ const csvService = ({ strapi: strapi2 }) => ({
3199
3727
  }
3200
3728
  sampleRow.push(sampleVal);
3201
3729
  }
3202
- const csvContent = `${headers.map(escapeCsvValue).join(",")}
3203
- ${sampleRow.map(escapeCsvValue).join(",")}
3204
- `;
3730
+ const csvContent = "\uFEFF" + headers.map(escapeCsvValue).join(",") + "\r\n" + sampleRow.map(escapeCsvValue).join(",") + "\r\n";
3205
3731
  return {
3206
3732
  filename: `${ct.info?.singularName || "template"}_template.csv`,
3207
3733
  csvContent,
@@ -3209,65 +3735,60 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3209
3735
  };
3210
3736
  },
3211
3737
  /**
3212
- * 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.
3213
3740
  */
3214
3741
  async exportCollectionCsv(uid, options = {}) {
3215
3742
  const ct = strapi2.contentTypes[uid];
3216
3743
  if (!ct) {
3217
3744
  throw new Error(`Content type ${uid} not found`);
3218
3745
  }
3219
- const attrs = ct.attributes || {};
3220
- const excluded = ["createdBy", "updatedBy", "localizations"];
3221
- const exportFields = ["documentId"];
3222
- const compFieldsToPopulate = [];
3223
- for (const [attrName, attrDef] of Object.entries(attrs)) {
3224
- if (excluded.includes(attrName)) continue;
3225
- if (["dynamiczone", "media", "relation"].includes(attrDef.type)) continue;
3226
- if (attrDef.type === "component") {
3227
- if (!attrDef.repeatable && attrDef.component) {
3228
- const comp = strapi2.components[attrDef.component];
3229
- if (comp?.attributes) {
3230
- compFieldsToPopulate.push(attrName);
3231
- for (const [subKey, subDef] of Object.entries(comp.attributes)) {
3232
- if (excluded.includes(subKey)) continue;
3233
- if (["component", "dynamiczone", "media", "relation"].includes(subDef.type)) continue;
3234
- exportFields.push(`${attrName}.${subKey}`);
3235
- }
3236
- }
3237
- }
3238
- continue;
3239
- }
3240
- exportFields.push(attrName);
3241
- }
3746
+ const allComponents = strapi2.components || {};
3747
+ const fields2 = discoverExportFields(ct, allComponents);
3242
3748
  const filters2 = {};
3243
- const { attribute, filterOperator, filterQuery, search } = options;
3244
- if (attribute && filterOperator && filterQuery !== void 0 && filterQuery !== "") {
3245
- switch (filterOperator) {
3246
- case "equals":
3247
- filters2[attribute] = { $eq: filterQuery };
3248
- break;
3249
- case "notEquals":
3250
- filters2[attribute] = { $ne: filterQuery };
3251
- break;
3252
- case "contains":
3253
- filters2[attribute] = { $containsi: filterQuery };
3254
- break;
3255
- case "notContains":
3256
- filters2[attribute] = { $notContainsi: filterQuery };
3257
- break;
3258
- case "startsWith":
3259
- filters2[attribute] = { $startsWith: filterQuery };
3260
- break;
3261
- case "endsWith":
3262
- filters2[attribute] = { $endsWith: filterQuery };
3263
- 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
+ }
3264
3785
  }
3265
3786
  }
3266
3787
  if (search && search.trim() !== "") {
3267
3788
  const q = search.trim();
3268
3789
  const searchConditions = [];
3269
3790
  const searchFields = ["name", "title", "email", "slug", "description"].filter(
3270
- (f) => attrs[f] && ["string", "text", "email"].includes(attrs[f].type)
3791
+ (f) => ct.attributes?.[f] && ["string", "text", "email"].includes(ct.attributes[f].type)
3271
3792
  );
3272
3793
  for (const field of searchFields) {
3273
3794
  searchConditions.push({ [field]: { $containsi: q } });
@@ -3280,34 +3801,37 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3280
3801
  }
3281
3802
  }
3282
3803
  }
3283
- const populateOption = compFieldsToPopulate.length > 0 ? compFieldsToPopulate.reduce((acc, k) => ({ ...acc, [k]: true }), {}) : void 0;
3284
- const entries = await strapi2.documents(uid).findMany({
3285
- filters: Object.keys(filters2).length > 0 ? filters2 : void 0,
3286
- limit: options.limit || 5e3,
3287
- sort: { createdAt: "desc" },
3288
- populate: populateOption
3289
- });
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
+ }
3290
3819
  const lines = [];
3291
- lines.push(exportFields.map(escapeCsvValue).join(","));
3820
+ lines.push(fields2.map((f) => escapeCsvValue(f.key)).join(","));
3292
3821
  for (const entry of entries || []) {
3293
- const rowValues = exportFields.map((field) => {
3294
- let val;
3295
- if (field.includes(".")) {
3296
- const [parent, child] = field.split(".");
3297
- val = entry[parent]?.[child];
3298
- } else {
3299
- val = entry[field];
3300
- }
3822
+ const rowValues = fields2.map((f) => {
3823
+ const val = getNestedExportValue(entry, f.key);
3301
3824
  return escapeCsvValue(val);
3302
3825
  });
3303
3826
  lines.push(rowValues.join(","));
3304
3827
  }
3305
- const csvContent = lines.join("\n");
3828
+ const csvContent = "\uFEFF" + lines.join("\r\n") + "\r\n";
3306
3829
  const filename = `${ct.info?.singularName || uid}_export_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3307
3830
  return {
3308
3831
  filename,
3309
3832
  csvContent,
3310
- count: entries ? entries.length : 0
3833
+ count: entries ? entries.length : 0,
3834
+ fields: fields2.map((f) => f.key)
3311
3835
  };
3312
3836
  },
3313
3837
  /**
@@ -3347,11 +3871,76 @@ ${sampleRow.map(escapeCsvValue).join(",")}
3347
3871
  const filename = `violations_${safeRuleName}_${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.csv`;
3348
3872
  return {
3349
3873
  filename,
3350
- csvContent: lines.join("\n"),
3874
+ csvContent: "\uFEFF" + lines.join("\r\n") + "\r\n",
3351
3875
  count: scanReport.violations.length,
3352
3876
  ruleName: scanReport.rule.name
3353
3877
  };
3354
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
+ },
3355
3944
  /**
3356
3945
  * Imports CSV records to update existing records or insert/override records
3357
3946
  */