strapi-plugin-bulk-updater 1.2.9 → 1.2.11

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: "/preview",
52
+ handler: "bulkUpdater.previewBatch",
53
+ config: {
54
+ policies: []
55
+ }
56
+ },
49
57
  {
50
58
  method: "GET",
51
59
  path: "/pending-batches",
@@ -273,10 +281,6 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
273
281
  if (data?.etag) {
274
282
  ctx.set("ETag", data.etag);
275
283
  ctx.set("Cache-Control", "private, no-cache");
276
- if (ctx.request.headers["if-none-match"] === data.etag) {
277
- ctx.status = 304;
278
- return;
279
- }
280
284
  }
281
285
  ctx.body = data;
282
286
  } catch (err) {
@@ -302,10 +306,6 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
302
306
  if (data?.etag) {
303
307
  ctx.set("ETag", data.etag);
304
308
  ctx.set("Cache-Control", "private, no-cache");
305
- if (ctx.request.headers["if-none-match"] === data.etag) {
306
- ctx.status = 304;
307
- return;
308
- }
309
309
  }
310
310
  ctx.body = { success: true, ...data };
311
311
  } catch (err) {
@@ -340,6 +340,16 @@ const bulkUpdaterController = ({ strapi: strapi2 }) => ({
340
340
  ctx.body = { success: false, error: err.message };
341
341
  }
342
342
  },
343
+ async previewBatch(ctx) {
344
+ try {
345
+ const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
346
+ const result = await service.previewBatch(ctx.request.body || {});
347
+ ctx.body = result;
348
+ } catch (err) {
349
+ ctx.status = 400;
350
+ ctx.body = { success: false, error: err.message };
351
+ }
352
+ },
343
353
  async getPendingBatches(ctx) {
344
354
  try {
345
355
  const service = strapi2.plugin("bulk-updater").service("bulkUpdater");
@@ -1372,10 +1382,12 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1372
1382
  }
1373
1383
  let condition = void 0;
1374
1384
  const q = (filterQuery || "").trim();
1385
+ const targetAttrSchema = isNestedAttr ? strapi2.components[attrs[parentKey]?.component]?.attributes?.[childKey] : attrs[attribute];
1386
+ const isStringLike = ["string", "text", "email", "richtext", "uid"].includes(targetAttrSchema?.type);
1375
1387
  if (normOp === "empty") {
1376
- condition = { $or: [{ $null: true }, { $eq: "" }] };
1388
+ condition = isStringLike ? { $or: [{ $null: true }, { $eq: "" }] } : { $null: true };
1377
1389
  } else if (normOp === "notempty") {
1378
- condition = { $and: [{ $notNull: true }, { $ne: "" }] };
1390
+ condition = isStringLike ? { $and: [{ $notNull: true }, { $ne: "" }] } : { $notNull: true };
1379
1391
  } else if (q !== "") {
1380
1392
  switch (normOp) {
1381
1393
  case "equals":
@@ -1404,12 +1416,12 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1404
1416
  [childKey]: condition
1405
1417
  };
1406
1418
  } else {
1407
- if (normOp === "empty") {
1419
+ if (normOp === "empty" && isStringLike) {
1408
1420
  filters2.$or = [
1409
1421
  { [attribute]: { $null: true } },
1410
1422
  { [attribute]: { $eq: "" } }
1411
1423
  ];
1412
- } else if (normOp === "notempty") {
1424
+ } else if (normOp === "notempty" && isStringLike) {
1413
1425
  filters2[attribute] = { $notNull: true, $ne: "" };
1414
1426
  } else {
1415
1427
  filters2[attribute] = condition;
@@ -1544,6 +1556,252 @@ const bulkUpdaterService = ({ strapi: strapi2 }) => {
1544
1556
  queryCache.set(cacheKey, { signature, data: result, timestamp: Date.now() });
1545
1557
  return result;
1546
1558
  },
1559
+ /**
1560
+ * Preview bulk update changes before execution:
1561
+ * Reads current values and computes proposed new values without mutating data.
1562
+ */
1563
+ async previewBatch({
1564
+ attribute,
1565
+ value,
1566
+ selectedEntries = [],
1567
+ selectedCollections = [],
1568
+ isRegex = false,
1569
+ regexPattern = ""
1570
+ }) {
1571
+ if (!attribute) {
1572
+ throw new Error("Attribute name is required.");
1573
+ }
1574
+ const targetsMap = /* @__PURE__ */ new Map();
1575
+ for (const colUid of selectedCollections) {
1576
+ if (!strapi2.contentTypes[colUid]) continue;
1577
+ const entries = await strapi2.documents(colUid).findMany();
1578
+ for (const entry of entries || []) {
1579
+ const key = `${colUid}@@@${entry.documentId}`;
1580
+ targetsMap.set(key, { uid: colUid, documentId: entry.documentId });
1581
+ }
1582
+ }
1583
+ for (const item of selectedEntries) {
1584
+ if (!item.uid || !item.documentId) continue;
1585
+ const key = `${item.uid}@@@${item.documentId}`;
1586
+ targetsMap.set(key, { uid: item.uid, documentId: item.documentId });
1587
+ }
1588
+ if (targetsMap.size === 0) {
1589
+ throw new Error("No collections or entries were selected for preview.");
1590
+ }
1591
+ let regexObj = null;
1592
+ if (isRegex && regexPattern) {
1593
+ try {
1594
+ regexObj = new RegExp(regexPattern, "g");
1595
+ } catch (e) {
1596
+ throw new Error(`Invalid regular expression: ${e.message}`);
1597
+ }
1598
+ }
1599
+ const targets = Array.from(targetsMap.values());
1600
+ const diffs = [];
1601
+ let willUpdateCount = 0;
1602
+ let unchangedCount = 0;
1603
+ let skippedCount = 0;
1604
+ for (const target of targets) {
1605
+ const contentType = strapi2.contentTypes[target.uid];
1606
+ const displayName = contentType?.info?.displayName || target.uid;
1607
+ try {
1608
+ const isNested = attribute.includes(".");
1609
+ let isComponentNested = false;
1610
+ let parentKey = "";
1611
+ let childKey = "";
1612
+ if (isNested) {
1613
+ const parts = attribute.split(".");
1614
+ parentKey = parts[0];
1615
+ childKey = parts[1];
1616
+ const parentAttrDef = contentType?.attributes?.[parentKey];
1617
+ if (parentAttrDef?.type === "component" && !parentAttrDef.repeatable && parentAttrDef.component) {
1618
+ const compSchema = strapi2.components[parentAttrDef.component];
1619
+ if (compSchema?.attributes?.[childKey]) {
1620
+ isComponentNested = true;
1621
+ }
1622
+ }
1623
+ }
1624
+ const attrDef = contentType?.attributes?.[attribute];
1625
+ const hasAttribute = Boolean(attrDef) || isComponentNested;
1626
+ if (!hasAttribute) {
1627
+ skippedCount++;
1628
+ diffs.push({
1629
+ uid: target.uid,
1630
+ collectionName: displayName,
1631
+ documentId: target.documentId,
1632
+ label: `Entry #${target.documentId}`,
1633
+ attribute,
1634
+ oldValue: null,
1635
+ newValue: null,
1636
+ oldDisplay: "—",
1637
+ newDisplay: "—",
1638
+ hasChange: false,
1639
+ status: "will_skip",
1640
+ message: `Attribute "${attribute}" does not exist in schema for ${displayName}`
1641
+ });
1642
+ continue;
1643
+ }
1644
+ let currentEntry;
1645
+ if (isComponentNested) {
1646
+ currentEntry = await strapi2.documents(target.uid).findOne({
1647
+ documentId: target.documentId,
1648
+ populate: [parentKey]
1649
+ });
1650
+ } else if (attrDef?.type === "relation") {
1651
+ currentEntry = await strapi2.documents(target.uid).findOne({
1652
+ documentId: target.documentId,
1653
+ populate: [attribute]
1654
+ });
1655
+ } else {
1656
+ currentEntry = await strapi2.documents(target.uid).findOne({
1657
+ documentId: target.documentId
1658
+ });
1659
+ }
1660
+ const entryLabel = currentEntry?.name || currentEntry?.title || currentEntry?.email || `Entry #${target.documentId}`;
1661
+ if (isComponentNested) {
1662
+ const existingParent = currentEntry?.[parentKey] || {};
1663
+ const currentVal2 = existingParent?.[childKey];
1664
+ let newValue2 = value;
1665
+ if (regexObj && typeof currentVal2 === "string") {
1666
+ newValue2 = currentVal2.replace(regexObj, value);
1667
+ }
1668
+ const hasChange2 = String(currentVal2 ?? "") !== String(newValue2 ?? "");
1669
+ if (hasChange2) {
1670
+ willUpdateCount++;
1671
+ } else {
1672
+ unchangedCount++;
1673
+ }
1674
+ diffs.push({
1675
+ uid: target.uid,
1676
+ collectionName: displayName,
1677
+ documentId: target.documentId,
1678
+ label: entryLabel,
1679
+ attribute,
1680
+ oldValue: currentVal2 ?? null,
1681
+ newValue: newValue2,
1682
+ oldDisplay: currentVal2 !== void 0 && currentVal2 !== null ? String(currentVal2) : "(empty)",
1683
+ newDisplay: String(newValue2 ?? ""),
1684
+ hasChange: hasChange2,
1685
+ status: hasChange2 ? "will_update" : "unchanged",
1686
+ message: hasChange2 ? "Value will be updated" : "Already has target value"
1687
+ });
1688
+ continue;
1689
+ }
1690
+ if (attrDef?.type === "relation") {
1691
+ const isMulti = attrDef.relation === "oneToMany" || attrDef.relation === "manyToMany";
1692
+ const currentRel = currentEntry?.[attribute];
1693
+ let oldDocIds = [];
1694
+ let oldDisplay = "";
1695
+ if (isMulti && Array.isArray(currentRel)) {
1696
+ oldDocIds = currentRel.map((r) => r.documentId).filter(Boolean);
1697
+ oldDisplay = currentRel.map((r) => r.name || r.title || r.email || r.documentId).join(", ") || "(empty)";
1698
+ } else if (currentRel) {
1699
+ oldDocIds = currentRel.documentId ? [currentRel.documentId] : [];
1700
+ oldDisplay = currentRel.name || currentRel.title || currentRel.email || currentRel.documentId || "(empty)";
1701
+ } else {
1702
+ oldDisplay = "(unlinked / none)";
1703
+ }
1704
+ let mode2 = "set";
1705
+ let targetDocIds = [];
1706
+ let newDisplay = "";
1707
+ if (value === "__clear__" || value === "") {
1708
+ mode2 = "clear";
1709
+ newDisplay = "(cleared / unlinked)";
1710
+ } else {
1711
+ try {
1712
+ const parsed = JSON.parse(value);
1713
+ if (parsed && typeof parsed === "object" && parsed.mode) {
1714
+ mode2 = parsed.mode;
1715
+ targetDocIds = Array.isArray(parsed.documentIds) ? parsed.documentIds : parsed.documentId ? [parsed.documentId] : [];
1716
+ newDisplay = parsed.label || targetDocIds.join(", ") || `[${mode2}]`;
1717
+ } else if (Array.isArray(parsed)) {
1718
+ targetDocIds = parsed;
1719
+ newDisplay = targetDocIds.join(", ");
1720
+ } else {
1721
+ targetDocIds = [value];
1722
+ newDisplay = value;
1723
+ }
1724
+ } catch {
1725
+ targetDocIds = [value];
1726
+ newDisplay = value;
1727
+ }
1728
+ }
1729
+ const hasChange2 = oldDisplay !== newDisplay;
1730
+ if (hasChange2) {
1731
+ willUpdateCount++;
1732
+ } else {
1733
+ unchangedCount++;
1734
+ }
1735
+ diffs.push({
1736
+ uid: target.uid,
1737
+ collectionName: displayName,
1738
+ documentId: target.documentId,
1739
+ label: entryLabel,
1740
+ attribute,
1741
+ oldValue: { isRelation: true, documentIds: oldDocIds, display: oldDisplay },
1742
+ newValue: { isRelation: true, mode: mode2, documentIds: targetDocIds, display: newDisplay },
1743
+ oldDisplay,
1744
+ newDisplay,
1745
+ hasChange: hasChange2,
1746
+ status: hasChange2 ? "will_update" : "unchanged",
1747
+ message: hasChange2 ? `Relation will be modified (${mode2})` : "Relation already set to this target"
1748
+ });
1749
+ continue;
1750
+ }
1751
+ let newValue = value;
1752
+ const currentVal = currentEntry?.[attribute];
1753
+ if (regexObj && typeof currentVal === "string") {
1754
+ newValue = currentVal.replace(regexObj, value);
1755
+ }
1756
+ const hasChange = String(currentVal ?? "") !== String(newValue ?? "");
1757
+ if (hasChange) {
1758
+ willUpdateCount++;
1759
+ } else {
1760
+ unchangedCount++;
1761
+ }
1762
+ diffs.push({
1763
+ uid: target.uid,
1764
+ collectionName: displayName,
1765
+ documentId: target.documentId,
1766
+ label: entryLabel,
1767
+ attribute,
1768
+ oldValue: currentVal ?? null,
1769
+ newValue,
1770
+ oldDisplay: currentVal !== void 0 && currentVal !== null ? String(currentVal) : "(empty)",
1771
+ newDisplay: String(newValue ?? ""),
1772
+ hasChange,
1773
+ status: hasChange ? "will_update" : "unchanged",
1774
+ message: hasChange ? "Value will be updated" : "Already matches target value"
1775
+ });
1776
+ } catch (err) {
1777
+ skippedCount++;
1778
+ diffs.push({
1779
+ uid: target.uid,
1780
+ collectionName: displayName,
1781
+ documentId: target.documentId,
1782
+ label: `Entry #${target.documentId}`,
1783
+ attribute,
1784
+ oldValue: null,
1785
+ newValue: null,
1786
+ oldDisplay: "—",
1787
+ newDisplay: "—",
1788
+ hasChange: false,
1789
+ status: "will_skip",
1790
+ message: `Error inspecting entry: ${err.message}`
1791
+ });
1792
+ }
1793
+ }
1794
+ return {
1795
+ success: true,
1796
+ summary: {
1797
+ total: targets.length,
1798
+ willUpdate: willUpdateCount,
1799
+ unchanged: unchangedCount,
1800
+ skipped: skippedCount
1801
+ },
1802
+ diffs
1803
+ };
1804
+ },
1547
1805
  /**
1548
1806
  * Batch Update API with Maker-Checker and Off-Peak Scheduling:
1549
1807
  */