strapi-plugin-bulk-updater 1.3.6 → 1.3.9

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.
Files changed (27) hide show
  1. package/dist/_chunks/{BulkUpdater-D812YfOE.mjs → BulkUpdater-CtEymixf.mjs} +2 -2
  2. package/dist/_chunks/{BulkUpdater-D812YfOE.mjs.map → BulkUpdater-CtEymixf.mjs.map} +1 -1
  3. package/dist/_chunks/{BulkUpdater-CXDs0Dws.js → BulkUpdater-DPEJQYYa.js} +2 -2
  4. package/dist/_chunks/{BulkUpdater-CXDs0Dws.js.map → BulkUpdater-DPEJQYYa.js.map} +1 -1
  5. package/dist/_chunks/{ScopedQuotaSelectInput-ChWrQIBV.mjs → ScopedQuotaSelectInput-Cy3eEj9-.mjs} +50 -23
  6. package/dist/_chunks/ScopedQuotaSelectInput-Cy3eEj9-.mjs.map +1 -0
  7. package/dist/_chunks/{ScopedQuotaSelectInput-s4oUMLgK.js → ScopedQuotaSelectInput-CyGlYa9x.js} +50 -23
  8. package/dist/_chunks/ScopedQuotaSelectInput-CyGlYa9x.js.map +1 -0
  9. package/dist/_chunks/{SettingsPage-BAvuOb7V.js → SettingsPage-BM8B-uUz.js} +2 -2
  10. package/dist/_chunks/{SettingsPage-BAvuOb7V.js.map → SettingsPage-BM8B-uUz.js.map} +1 -1
  11. package/dist/_chunks/{SettingsPage-BWo4l6Gd.mjs → SettingsPage-DLCYx2s4.mjs} +2 -2
  12. package/dist/_chunks/{SettingsPage-BWo4l6Gd.mjs.map → SettingsPage-DLCYx2s4.mjs.map} +1 -1
  13. package/dist/_chunks/{ValidationRulesTab-Dt53h-Ke.mjs → ValidationRulesTab-tT4HIeey.mjs} +99 -19
  14. package/dist/_chunks/ValidationRulesTab-tT4HIeey.mjs.map +1 -0
  15. package/dist/_chunks/{ValidationRulesTab-BLZWPGrZ.js → ValidationRulesTab-vr-y8ntX.js} +98 -18
  16. package/dist/_chunks/ValidationRulesTab-vr-y8ntX.js.map +1 -0
  17. package/dist/admin/index.js +3 -3
  18. package/dist/admin/index.mjs +3 -3
  19. package/dist/server/index.js +214 -66
  20. package/dist/server/index.js.map +1 -1
  21. package/dist/server/index.mjs +214 -66
  22. package/dist/server/index.mjs.map +1 -1
  23. package/package.json +3 -3
  24. package/dist/_chunks/ScopedQuotaSelectInput-ChWrQIBV.mjs.map +0 -1
  25. package/dist/_chunks/ScopedQuotaSelectInput-s4oUMLgK.js.map +0 -1
  26. package/dist/_chunks/ValidationRulesTab-BLZWPGrZ.js.map +0 -1
  27. package/dist/_chunks/ValidationRulesTab-Dt53h-Ke.mjs.map +0 -1
@@ -3580,7 +3580,8 @@ const DEFAULT_RULES = [
3580
3580
  {
3581
3581
  id: "rule_seo_heading_h1",
3582
3582
  name: "SEO Single H1 Page Rule",
3583
- description: "Enforces that only one H1 heading tag can be selected across all dynamic zone heading blocks within an entry.",
3583
+ description: "Enforces that only one H1 heading tag can be selected across all dynamic zone heading blocks within an entry, combined with rich text HTML.",
3584
+ collectionUid: "*",
3584
3585
  targetField: "headingLevel",
3585
3586
  quotas: {
3586
3587
  h1: 1,
@@ -3589,10 +3590,59 @@ const DEFAULT_RULES = [
3589
3590
  exceededAction: "disable",
3590
3591
  enabled: true,
3591
3592
  scope: "entry",
3593
+ includeRichTextHtml: true,
3594
+ richTextFields: [],
3592
3595
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3593
3596
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3594
3597
  }
3595
3598
  ];
3599
+ function extractHeadingsFromRichText(content) {
3600
+ const counts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
3601
+ if (!content) return counts;
3602
+ if (Array.isArray(content)) {
3603
+ for (const node of content) {
3604
+ if (node && typeof node === "object") {
3605
+ if (node.type === "heading" && typeof node.level === "number") {
3606
+ const tag = `h${node.level}`;
3607
+ if (counts[tag] !== void 0) counts[tag]++;
3608
+ }
3609
+ if (Array.isArray(node.children)) {
3610
+ const sub = extractHeadingsFromRichText(node.children);
3611
+ for (const k of Object.keys(counts)) counts[k] += sub[k];
3612
+ }
3613
+ }
3614
+ }
3615
+ return counts;
3616
+ }
3617
+ if (typeof content === "string") {
3618
+ for (let level = 1; level <= 6; level++) {
3619
+ const tag = `h${level}`;
3620
+ const htmlMatches = content.match(new RegExp(`<${tag}\\b[^>]*>`, "gi"));
3621
+ if (htmlMatches) {
3622
+ counts[tag] += htmlMatches.length;
3623
+ }
3624
+ const mdRegex = new RegExp(`(?:^|\\n)${"#".repeat(level)}\\s+[^\\n]+`, "g");
3625
+ const mdMatches = content.match(mdRegex);
3626
+ if (mdMatches) {
3627
+ counts[tag] += mdMatches.length;
3628
+ }
3629
+ }
3630
+ }
3631
+ return counts;
3632
+ }
3633
+ function cleanKey(str2) {
3634
+ return (str2 || "").replace(/[-_]/g, "").toLowerCase().trim();
3635
+ }
3636
+ function matchesPath(normalizedPath, leafKey, targetField) {
3637
+ if (!targetField) return false;
3638
+ const pClean = cleanKey(normalizedPath);
3639
+ const kClean = cleanKey(leafKey);
3640
+ const tClean = cleanKey(targetField);
3641
+ if (!tClean.includes(".")) {
3642
+ return kClean === tClean || pClean === tClean || pClean.endsWith(`.${tClean}`);
3643
+ }
3644
+ return pClean === tClean || pClean.endsWith(`.${tClean}`) || pClean.endsWith(tClean) || pClean.includes(`.${tClean}`);
3645
+ }
3596
3646
  const validationRulesService = ({ strapi: strapi2 }) => ({
3597
3647
  async getStore() {
3598
3648
  return strapi2.store({
@@ -3621,6 +3671,9 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3621
3671
  savedRule = {
3622
3672
  ...rules[index2],
3623
3673
  ...ruleData,
3674
+ collectionUid: ruleData.collectionUid !== void 0 ? ruleData.collectionUid : rules[index2].collectionUid || "*",
3675
+ includeRichTextHtml: ruleData.includeRichTextHtml !== void 0 ? Boolean(ruleData.includeRichTextHtml) : Boolean(rules[index2].includeRichTextHtml),
3676
+ richTextFields: Array.isArray(ruleData.richTextFields) ? ruleData.richTextFields : rules[index2].richTextFields || [],
3624
3677
  updatedAt: now
3625
3678
  };
3626
3679
  rules[index2] = savedRule;
@@ -3629,11 +3682,14 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3629
3682
  id: ruleData.id,
3630
3683
  name: ruleData.name || "New Rule",
3631
3684
  description: ruleData.description || "",
3685
+ collectionUid: ruleData.collectionUid || "*",
3632
3686
  targetField: ruleData.targetField || "headingLevel",
3633
3687
  quotas: ruleData.quotas || { h1: 1 },
3634
3688
  exceededAction: ruleData.exceededAction || "disable",
3635
3689
  enabled: ruleData.enabled ?? true,
3636
3690
  scope: ruleData.scope || "entry",
3691
+ includeRichTextHtml: Boolean(ruleData.includeRichTextHtml),
3692
+ richTextFields: Array.isArray(ruleData.richTextFields) ? ruleData.richTextFields : [],
3637
3693
  createdAt: now,
3638
3694
  updatedAt: now
3639
3695
  };
@@ -3645,11 +3701,14 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3645
3701
  id,
3646
3702
  name: ruleData.name || "Custom Quota Rule",
3647
3703
  description: ruleData.description || "",
3704
+ collectionUid: ruleData.collectionUid || "*",
3648
3705
  targetField: ruleData.targetField || "headingLevel",
3649
3706
  quotas: ruleData.quotas || { h1: 1 },
3650
3707
  exceededAction: ruleData.exceededAction || "disable",
3651
3708
  enabled: ruleData.enabled ?? true,
3652
3709
  scope: ruleData.scope || "entry",
3710
+ includeRichTextHtml: Boolean(ruleData.includeRichTextHtml),
3711
+ richTextFields: Array.isArray(ruleData.richTextFields) ? ruleData.richTextFields : [],
3653
3712
  createdAt: now,
3654
3713
  updatedAt: now
3655
3714
  };
@@ -3693,21 +3752,35 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3693
3752
  let totalScanned = 0;
3694
3753
  const allContentTypes = strapi2.contentTypes || {};
3695
3754
  const allComponents = strapi2.components || {};
3755
+ function componentHasField(compUid, targetField2) {
3756
+ const comp = allComponents[compUid];
3757
+ if (!comp?.attributes) return false;
3758
+ if (matchesPath(compUid, compUid, targetField2)) return true;
3759
+ for (const [attrName, attrDef] of Object.entries(comp.attributes)) {
3760
+ if (matchesPath(attrName, attrName, targetField2) || matchesPath(`${compUid}.${attrName}`, attrName, targetField2)) return true;
3761
+ if (attrDef.type === "component" && attrDef.component) {
3762
+ if (componentHasField(attrDef.component, targetField2)) return true;
3763
+ }
3764
+ }
3765
+ return false;
3766
+ }
3696
3767
  function schemaHasField(ctSchema) {
3697
3768
  if (!ctSchema || !ctSchema.attributes) return { hasField: false, populateAttrs: {} };
3698
3769
  const rawAttrs = ctSchema.attributes;
3699
3770
  const populateAttrs = {};
3700
- let hasField = Boolean(rawAttrs[targetField]);
3771
+ let hasField = false;
3701
3772
  for (const [attrKey, attrDef] of Object.entries(rawAttrs)) {
3773
+ if (matchesPath(attrKey, attrKey, targetField)) {
3774
+ hasField = true;
3775
+ }
3702
3776
  if (attrDef.type === "component" && attrDef.component) {
3703
- const comp = allComponents[attrDef.component];
3704
- if (comp?.attributes?.[targetField]) {
3777
+ if (componentHasField(attrDef.component, targetField)) {
3705
3778
  hasField = true;
3706
3779
  populateAttrs[attrKey] = true;
3707
3780
  }
3708
3781
  } else if (attrDef.type === "dynamiczone" && Array.isArray(attrDef.components)) {
3709
3782
  const matchingComp = attrDef.components.some(
3710
- (compUid) => allComponents[compUid]?.attributes?.[targetField]
3783
+ (compUid) => componentHasField(compUid, targetField) || matchesPath(`${attrKey}.${compUid}`, compUid, targetField)
3711
3784
  );
3712
3785
  if (matchingComp) {
3713
3786
  hasField = true;
@@ -3717,19 +3790,23 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3717
3790
  }
3718
3791
  return { hasField, populateAttrs };
3719
3792
  }
3720
- const candidateUids = Object.keys(allContentTypes).filter(
3721
- (uid) => uid.startsWith("api::") && !uid.includes("bulk-updater")
3722
- );
3793
+ const candidateUids = Object.keys(allContentTypes).filter((uid) => {
3794
+ if (!uid.startsWith("api::") || uid.includes("bulk-updater")) return false;
3795
+ if (rule.collectionUid && rule.collectionUid !== "*" && rule.collectionUid !== uid) {
3796
+ return false;
3797
+ }
3798
+ return true;
3799
+ });
3723
3800
  const relevantCollections = [];
3724
3801
  for (const uid of candidateUids) {
3725
3802
  const ct = allContentTypes[uid];
3726
3803
  if (!ct) continue;
3727
3804
  const { hasField, populateAttrs } = schemaHasField(ct);
3728
- if (hasField) {
3805
+ if (hasField || rule.collectionUid && rule.collectionUid === uid) {
3729
3806
  relevantCollections.push({
3730
3807
  uid,
3731
3808
  displayName: ct.info?.displayName || uid,
3732
- populateOption: Object.keys(populateAttrs).length > 0 ? populateAttrs : void 0
3809
+ populateOption: Object.keys(populateAttrs).length > 0 ? populateAttrs : "*"
3733
3810
  });
3734
3811
  }
3735
3812
  }
@@ -3746,35 +3823,73 @@ const validationRulesService = ({ strapi: strapi2 }) => ({
3746
3823
  });
3747
3824
  if (!entries || entries.length === 0) break;
3748
3825
  for (const entry of entries) {
3749
- let traverse = function(obj) {
3826
+ let traverse = function(obj, currentPath = "") {
3750
3827
  if (!obj || typeof obj !== "object") return;
3828
+ const compName = typeof obj.__component === "string" ? obj.__component : "";
3751
3829
  for (const key of Object.keys(obj)) {
3830
+ if (key === "__component") continue;
3752
3831
  const val = obj[key];
3753
- if (key === targetField && typeof val === "string" && val) {
3754
- const lower = val.toLowerCase();
3755
- counts[lower] = (counts[lower] || 0) + 1;
3832
+ const newPath = currentPath ? `${currentPath}.${key}` : key;
3833
+ const normalizedPath = newPath.replace(/\.\d+/g, "");
3834
+ const compPath = compName ? (currentPath ? `${currentPath}.${compName}.${key}` : `${compName}.${key}`).replace(/\.\d+/g, "") : "";
3835
+ const isMatch = matchesPath(normalizedPath, key, targetField) || (compPath ? matchesPath(compPath, key, targetField) : false);
3836
+ if (isMatch && typeof val === "string" && val.trim()) {
3837
+ const lower = val.toLowerCase().trim();
3838
+ structuredCounts[lower] = (structuredCounts[lower] || 0) + 1;
3839
+ }
3840
+ if (rule.includeRichTextHtml) {
3841
+ if (typeof val === "string" && (val.includes("<h") || val.includes("#"))) {
3842
+ const headings = extractHeadingsFromRichText(val);
3843
+ for (const [tag, count] of Object.entries(headings)) {
3844
+ if (count > 0) {
3845
+ richTextCounts[tag] = (richTextCounts[tag] || 0) + count;
3846
+ }
3847
+ }
3848
+ } else if (Array.isArray(val) && val.length > 0 && typeof val[0] === "object" && val[0]?.type) {
3849
+ const headings = extractHeadingsFromRichText(val);
3850
+ for (const [tag, count] of Object.entries(headings)) {
3851
+ if (count > 0) {
3852
+ richTextCounts[tag] = (richTextCounts[tag] || 0) + count;
3853
+ }
3854
+ }
3855
+ continue;
3856
+ }
3756
3857
  }
3757
3858
  if (typeof val === "object" && val !== null) {
3758
- traverse(val);
3859
+ traverse(val, newPath);
3759
3860
  }
3760
3861
  }
3761
3862
  };
3762
3863
  totalScanned++;
3763
- const counts = {};
3864
+ const structuredCounts = {};
3865
+ const richTextCounts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
3764
3866
  traverse(entry);
3765
3867
  for (const [optKey, max] of Object.entries(quotas)) {
3766
- const lowerOpt = optKey.toLowerCase();
3767
- const actualCount = counts[lowerOpt] || 0;
3768
- if (actualCount > max) {
3868
+ const lowerOpt = optKey.toLowerCase().trim();
3869
+ const structCount = structuredCounts[lowerOpt] || 0;
3870
+ const rtCount = rule.includeRichTextHtml ? richTextCounts[lowerOpt] || 0 : 0;
3871
+ const combinedCount = structCount + rtCount;
3872
+ if (combinedCount > max) {
3769
3873
  const entryLabel = entry.title || entry.name || entry.email || `Entry #${entry.documentId}`;
3874
+ let detail = "";
3875
+ if (rtCount > 0 && structCount > 0) {
3876
+ detail = `${structCount} structured + ${rtCount} in rich text HTML`;
3877
+ } else if (rtCount > 0) {
3878
+ detail = `${rtCount} in rich text HTML / Markdown`;
3879
+ } else {
3880
+ detail = `${structCount} in structured component`;
3881
+ }
3770
3882
  violations.push({
3771
3883
  uid,
3772
3884
  collectionDisplayName: displayName,
3773
3885
  documentId: entry.documentId,
3774
3886
  entryLabel,
3775
3887
  violatingOption: optKey.toUpperCase(),
3776
- foundCount: actualCount,
3888
+ foundCount: combinedCount,
3889
+ structuredCount: structCount,
3890
+ richTextCount: rtCount,
3777
3891
  maxAllowed: max,
3892
+ detail,
3778
3893
  editUrl: `/admin/content-manager/collection-types/${uid}/${entry.documentId}`
3779
3894
  });
3780
3895
  }
@@ -34049,17 +34164,41 @@ const index = {
34049
34164
  if (["create", "update"].includes(context.action)) {
34050
34165
  const data = params?.data;
34051
34166
  if (data && typeof data === "object") {
34052
- let scanDataTree = function(obj) {
34167
+ let scanDataTree = function(obj, currentPath = "") {
34053
34168
  if (!obj || typeof obj !== "object") return;
34169
+ const compName = typeof obj.__component === "string" ? obj.__component : "";
34054
34170
  for (const key of Object.keys(obj)) {
34171
+ if (key === "__component") continue;
34055
34172
  const val = obj[key];
34056
- if (fieldQuotas[key] && typeof val === "string" && val) {
34057
- const lowerVal = val.toLowerCase();
34058
- counts[key] = counts[key] || {};
34059
- counts[key][lowerVal] = (counts[key][lowerVal] || 0) + 1;
34173
+ const newPath = currentPath ? `${currentPath}.${key}` : key;
34174
+ const normalizedPath = newPath.replace(/\.\d+/g, "");
34175
+ const compPath = compName ? (currentPath ? `${currentPath}.${compName}.${key}` : `${compName}.${key}`).replace(/\.\d+/g, "") : "";
34176
+ for (const rule of effectiveRules) {
34177
+ const isMatch = matchesPath(normalizedPath, key, rule.targetField) || (compPath ? matchesPath(compPath, key, rule.targetField) : false);
34178
+ if (isMatch && typeof val === "string" && val.trim()) {
34179
+ const lowerVal = val.toLowerCase().trim();
34180
+ structuredCounts[rule.id] = structuredCounts[rule.id] || {};
34181
+ structuredCounts[rule.id][lowerVal] = (structuredCounts[rule.id][lowerVal] || 0) + 1;
34182
+ }
34183
+ }
34184
+ if (typeof val === "string" && (val.includes("<h") || val.includes("#"))) {
34185
+ const headings = extractHeadingsFromRichText(val);
34186
+ for (const [tag, count] of Object.entries(headings)) {
34187
+ if (count > 0) {
34188
+ richTextCounts[tag] = (richTextCounts[tag] || 0) + count;
34189
+ }
34190
+ }
34191
+ } else if (Array.isArray(val) && val.length > 0 && typeof val[0] === "object" && val[0]?.type) {
34192
+ const headings = extractHeadingsFromRichText(val);
34193
+ for (const [tag, count] of Object.entries(headings)) {
34194
+ if (count > 0) {
34195
+ richTextCounts[tag] = (richTextCounts[tag] || 0) + count;
34196
+ }
34197
+ }
34198
+ continue;
34060
34199
  }
34061
34200
  if (typeof val === "object" && val !== null) {
34062
- scanDataTree(val);
34201
+ scanDataTree(val, newPath);
34063
34202
  }
34064
34203
  }
34065
34204
  };
@@ -34073,20 +34212,22 @@ const index = {
34073
34212
  } catch (err) {
34074
34213
  strapi2.log.warn("[bulk-updater] Failed to fetch active UI validation rules:", err);
34075
34214
  }
34076
- const fieldQuotas = {};
34077
- for (const rule of activeUiRules) {
34078
- if (rule.targetField && rule.quotas) {
34079
- fieldQuotas[rule.targetField] = {
34080
- quotas: rule.quotas,
34081
- ruleName: rule.name
34082
- };
34083
- }
34084
- }
34215
+ const effectiveRules = [
34216
+ ...activeUiRules.filter((r) => !r.collectionUid || r.collectionUid === "*" || r.collectionUid === context.uid).map((r) => ({
34217
+ id: r.id || r.targetField,
34218
+ name: r.name || r.targetField,
34219
+ collectionUid: r.collectionUid || "*",
34220
+ targetField: r.targetField,
34221
+ quotas: r.quotas || {},
34222
+ includeRichTextHtml: Boolean(r.includeRichTextHtml),
34223
+ richTextFields: r.richTextFields || []
34224
+ }))
34225
+ ];
34085
34226
  const allComponents = strapi2.components || {};
34086
34227
  for (const comp of Object.values(allComponents)) {
34087
34228
  const attrs = comp.attributes || {};
34088
34229
  for (const [attrName, attrDef] of Object.entries(attrs)) {
34089
- if (attrDef.type === "customField" && (attrDef.customField === "plugin::bulk-updater.scoped-select" || attrDef.customField === "global::scoped-select") && !fieldQuotas[attrName]) {
34230
+ if (attrDef.type === "customField" && (attrDef.customField === "plugin::bulk-updater.scoped-select" || attrDef.customField === "global::scoped-select") && !effectiveRules.some((r) => matchesPath(attrName, attrName, r.targetField))) {
34090
34231
  const quotas = attrDef.options?.quotas || {};
34091
34232
  const items = attrDef.options?.items || [];
34092
34233
  const itemQuotas = {};
@@ -34095,35 +34236,33 @@ const index = {
34095
34236
  itemQuotas[item.value] = item.maxAllowed;
34096
34237
  }
34097
34238
  }
34098
- const mergedQuotas = {
34099
- h1: 1,
34100
- h2: 3,
34101
- ...itemQuotas,
34102
- ...quotas
34103
- };
34104
- fieldQuotas[attrName] = {
34105
- quotas: mergedQuotas,
34106
- ruleName: `${attrName} quota rule`
34107
- };
34239
+ effectiveRules.push({
34240
+ id: `schema_${attrName}`,
34241
+ name: `${attrName} quota rule`,
34242
+ collectionUid: "*",
34243
+ targetField: attrName,
34244
+ quotas: {
34245
+ h1: 1,
34246
+ h2: 3,
34247
+ ...itemQuotas,
34248
+ ...quotas
34249
+ },
34250
+ includeRichTextHtml: true,
34251
+ richTextFields: []
34252
+ });
34108
34253
  }
34109
34254
  }
34110
34255
  }
34111
- if (!fieldQuotas.headingLevel) {
34112
- fieldQuotas.headingLevel = {
34113
- quotas: { h1: 1, h2: 3 },
34114
- ruleName: "Default SEO Heading Rule"
34115
- };
34116
- }
34117
- const counts = {};
34256
+ const structuredCounts = {};
34257
+ const richTextCounts = { h1: 0, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 };
34118
34258
  let dataToScan = data;
34119
34259
  if (context.action === "update" && (params?.documentId || params?.where?.documentId)) {
34120
34260
  const docId = params?.documentId || params?.where?.documentId;
34121
- const hasQuotaFieldInPayload = Object.keys(fieldQuotas).some((k) => k in data);
34122
- if (hasQuotaFieldInPayload && context.uid) {
34261
+ if (context.uid && docId) {
34123
34262
  try {
34124
- const existing = await strapi2.documents(context.uid).findOne({
34125
- documentId: docId,
34126
- populate: "*"
34263
+ const existing = await strapi2.db?.query?.(context.uid)?.findOne?.({
34264
+ where: { documentId: docId },
34265
+ populate: true
34127
34266
  });
34128
34267
  if (existing) {
34129
34268
  dataToScan = { ...existing, ...data };
@@ -34133,14 +34272,23 @@ const index = {
34133
34272
  }
34134
34273
  }
34135
34274
  scanDataTree(dataToScan);
34136
- for (const [fieldKey, ruleMeta] of Object.entries(fieldQuotas)) {
34137
- for (const [val, maxAllowed] of Object.entries(ruleMeta.quotas)) {
34138
- const lowerVal = val.toLowerCase();
34139
- const actualCount = counts[fieldKey]?.[lowerVal] || 0;
34140
- if (actualCount > maxAllowed) {
34141
- throw new ApplicationError(
34142
- `Validation Error (${ruleMeta.ruleName}): Option "${val.toUpperCase()}" on field "${fieldKey}" cannot appear more than ${maxAllowed} time(s) across this entry (found ${actualCount}).`
34143
- );
34275
+ for (const rule of effectiveRules) {
34276
+ const ruleCounts = structuredCounts[rule.id] || {};
34277
+ for (const [quotaKey, maxAllowed] of Object.entries(rule.quotas || {})) {
34278
+ const lowerVal = quotaKey.toLowerCase().trim();
34279
+ const structCount = ruleCounts[lowerVal] || 0;
34280
+ const rtCount = rule.includeRichTextHtml ? richTextCounts[lowerVal] || 0 : 0;
34281
+ const totalFound = structCount + rtCount;
34282
+ if (totalFound > maxAllowed) {
34283
+ let detailMsg = `Option "${quotaKey.toUpperCase()}" cannot appear more than ${maxAllowed} time(s) across this entry.`;
34284
+ if (rtCount > 0 && structCount > 0) {
34285
+ detailMsg += ` Found ${totalFound} occurrences combined: ${structCount} in structured component ('${rule.targetField}') and ${rtCount} in rich text HTML / Markdown.`;
34286
+ } else if (rtCount > 0) {
34287
+ detailMsg += ` Found ${totalFound} in rich text HTML / Markdown content.`;
34288
+ } else {
34289
+ detailMsg += ` Found ${totalFound} in structured field '${rule.targetField}'.`;
34290
+ }
34291
+ throw new ApplicationError(`Validation Error (${rule.name}): ${detailMsg}`);
34144
34292
  }
34145
34293
  }
34146
34294
  }