@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.221

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 (188) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/actions.d.ts +1 -59
  3. package/src/lib/app-utils/actions.js +7 -72
  4. package/src/lib/app-utils/actions.js.map +1 -1
  5. package/src/lib/app-utils/consent-groups.d.ts +1 -1
  6. package/src/lib/app-utils/consent-groups.js.map +1 -1
  7. package/src/lib/app-utils/console-routes.d.ts +0 -36
  8. package/src/lib/app-utils/console-routes.js +2 -37
  9. package/src/lib/app-utils/console-routes.js.map +1 -1
  10. package/src/lib/app-utils/contacts.d.ts +1 -1
  11. package/src/lib/app-utils/contacts.js.map +1 -1
  12. package/src/lib/app-utils/container-membership.d.ts +93 -0
  13. package/src/lib/app-utils/container-membership.js +165 -0
  14. package/src/lib/app-utils/container-membership.js.map +1 -0
  15. package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
  16. package/src/lib/app-utils/crm-task-reminders.js +32 -11
  17. package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
  18. package/src/lib/app-utils/crm.d.ts +24 -4
  19. package/src/lib/app-utils/crm.js +23 -0
  20. package/src/lib/app-utils/crm.js.map +1 -1
  21. package/src/lib/app-utils/csv-upload.d.ts +1 -1
  22. package/src/lib/app-utils/csv-upload.js +1 -1
  23. package/src/lib/app-utils/csv-upload.js.map +1 -1
  24. package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
  25. package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
  26. package/src/lib/app-utils/csv.js.map +1 -0
  27. package/src/lib/app-utils/datasets.d.ts +0 -19
  28. package/src/lib/app-utils/datasets.js +0 -13
  29. package/src/lib/app-utils/datasets.js.map +1 -1
  30. package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
  31. package/src/lib/app-utils/docs-help.generated.js +5 -0
  32. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  33. package/src/lib/app-utils/docs-index.generated.js +71 -36
  34. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  35. package/src/lib/app-utils/dynamic-list-rule.js +3 -3
  36. package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
  37. package/src/lib/app-utils/email-text.d.ts +80 -0
  38. package/src/lib/app-utils/email-text.js +189 -0
  39. package/src/lib/app-utils/email-text.js.map +1 -0
  40. package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
  41. package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
  42. package/src/lib/app-utils/enrollment-basis.js.map +1 -0
  43. package/src/lib/app-utils/expand-repeatables.js +4 -4
  44. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  45. package/src/lib/app-utils/forms.js +2 -2
  46. package/src/lib/app-utils/forms.js.map +1 -1
  47. package/src/lib/app-utils/health-report.d.ts +2 -3
  48. package/src/lib/app-utils/health-report.js +30 -6
  49. package/src/lib/app-utils/health-report.js.map +1 -1
  50. package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
  51. package/src/lib/app-utils/inbound-mail-domain.js +31 -0
  52. package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
  53. package/src/lib/app-utils/leaving-notice.d.ts +96 -0
  54. package/src/lib/app-utils/leaving-notice.js +157 -0
  55. package/src/lib/app-utils/leaving-notice.js.map +1 -0
  56. package/src/lib/app-utils/lockdown.d.ts +11 -0
  57. package/src/lib/app-utils/lockdown.js +33 -3
  58. package/src/lib/app-utils/lockdown.js.map +1 -1
  59. package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
  60. package/src/lib/app-utils/member-email-aliases.js +29 -0
  61. package/src/lib/app-utils/member-email-aliases.js.map +1 -1
  62. package/src/lib/app-utils/notifications.d.ts +69 -0
  63. package/src/lib/app-utils/notifications.js +119 -0
  64. package/src/lib/app-utils/notifications.js.map +1 -1
  65. package/src/lib/app-utils/operator-alerts.d.ts +15 -1
  66. package/src/lib/app-utils/operator-alerts.js +64 -10
  67. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  68. package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
  69. package/src/lib/app-utils/plan-entitlements.js +200 -224
  70. package/src/lib/app-utils/plan-entitlements.js.map +1 -1
  71. package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
  72. package/src/lib/app-utils/platform-marketing-consent.js +6 -5
  73. package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
  74. package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
  75. package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
  76. package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
  77. package/src/lib/app-utils/repeat-query.d.ts +56 -0
  78. package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
  79. package/src/lib/app-utils/repeat-query.js.map +1 -0
  80. package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
  81. package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
  82. package/src/lib/app-utils/screen-link-context.js +14 -2
  83. package/src/lib/app-utils/screen-link-context.js.map +1 -1
  84. package/src/lib/app-utils/screen-route.d.ts +3 -0
  85. package/src/lib/app-utils/screen-route.js +6 -1
  86. package/src/lib/app-utils/screen-route.js.map +1 -1
  87. package/src/lib/app-utils/server.d.ts +7 -9
  88. package/src/lib/app-utils/server.js +18 -26
  89. package/src/lib/app-utils/server.js.map +1 -1
  90. package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
  91. package/src/lib/app-utils/starter-template-nodes.js +129 -0
  92. package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
  93. package/src/lib/app-utils/starter-templates.d.ts +11 -34
  94. package/src/lib/app-utils/starter-templates.js +19 -230
  95. package/src/lib/app-utils/starter-templates.js.map +1 -1
  96. package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
  97. package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
  98. package/src/lib/app-utils/subscription-topics.js.map +1 -0
  99. package/src/lib/app-utils/usage-budget.d.ts +73 -188
  100. package/src/lib/app-utils/usage-budget.js +58 -194
  101. package/src/lib/app-utils/usage-budget.js.map +1 -1
  102. package/src/lib/app-utils/utm-attribution.d.ts +1 -1
  103. package/src/lib/app-utils/utm-attribution.js +1 -1
  104. package/src/lib/app-utils/utm-attribution.js.map +1 -1
  105. package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
  106. package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
  107. package/src/lib/app-utils/utm-touch.js.map +1 -0
  108. package/src/lib/app-utils/variables.d.ts +9 -9
  109. package/src/lib/app-utils/variables.js.map +1 -1
  110. package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
  111. package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
  112. package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
  113. package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
  114. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  115. package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
  116. package/src/lib/plugin-manager/feature-plugins.js +22 -35
  117. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  118. package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
  119. package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
  120. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  121. package/src/lib/plugin-manager/index.d.ts +0 -1
  122. package/src/lib/plugin-manager/index.js +0 -1
  123. package/src/lib/plugin-manager/index.js.map +1 -1
  124. package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
  125. package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
  126. package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
  127. package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
  128. package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
  129. package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
  130. package/src/lib/plugin-manager/plugin-containers.js +27 -0
  131. package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
  132. package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
  133. package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
  134. package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
  135. package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
  136. package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
  137. package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
  138. package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
  139. package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
  140. package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
  141. package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
  142. package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
  143. package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
  144. package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
  145. package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
  146. package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
  147. package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
  148. package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
  149. package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
  150. package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
  151. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
  152. package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
  153. package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
  154. package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
  155. package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
  156. package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
  157. package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
  158. package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
  159. package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
  160. package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
  161. package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
  162. package/src/lib/app-utils/assist-credits.d.ts +0 -482
  163. package/src/lib/app-utils/assist-credits.js +0 -510
  164. package/src/lib/app-utils/assist-credits.js.map +0 -1
  165. package/src/lib/app-utils/campaign-membership.d.ts +0 -121
  166. package/src/lib/app-utils/campaign-membership.js +0 -189
  167. package/src/lib/app-utils/campaign-membership.js.map +0 -1
  168. package/src/lib/app-utils/campaign-touch.js.map +0 -1
  169. package/src/lib/app-utils/console-record-links.d.ts +0 -84
  170. package/src/lib/app-utils/console-record-links.js +0 -76
  171. package/src/lib/app-utils/console-record-links.js.map +0 -1
  172. package/src/lib/app-utils/crm-booking.d.ts +0 -75
  173. package/src/lib/app-utils/crm-booking.js +0 -133
  174. package/src/lib/app-utils/crm-booking.js.map +0 -1
  175. package/src/lib/app-utils/crm-inbound.d.ts +0 -245
  176. package/src/lib/app-utils/crm-inbound.js +0 -407
  177. package/src/lib/app-utils/crm-inbound.js.map +0 -1
  178. package/src/lib/app-utils/crm-reports.d.ts +0 -479
  179. package/src/lib/app-utils/crm-reports.js +0 -680
  180. package/src/lib/app-utils/crm-reports.js.map +0 -1
  181. package/src/lib/app-utils/dataset-csv.js.map +0 -1
  182. package/src/lib/app-utils/dataset-query.d.ts +0 -57
  183. package/src/lib/app-utils/dataset-query.js.map +0 -1
  184. package/src/lib/app-utils/email-topics.js.map +0 -1
  185. package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
  186. package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
  187. package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
  188. package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { normalizeContactEmail } from "./contacts.js";
17
+ /**
18
+ * READING A RECEIVED EMAIL (AGL-2657): the address in a header value, the
19
+ * thread a subject belongs to, the words of an HTML part, the message a
20
+ * forward carries and the reply above the quoted history.
21
+ *
22
+ * Pure, and the platform's: every surface that receives mail — a capture
23
+ * address filing a message on a record, a sequence reading a prospect's
24
+ * reply — reads a message the same way, so two of them can never disagree
25
+ * about who wrote it or what they said.
26
+ */ /** The most a subject keeps, which is also what a one-to-one email may send. */ export const EMAIL_SUBJECT_MAX = 200;
27
+ /** The most an excerpt keeps: a letter, not a thread. */ export const EMAIL_EXCERPT_MAX = 4000;
28
+ /**
29
+ * The bare address in a header value — `Ada <ada@example.com>` and
30
+ * `ada@example.com` both answer `ada@example.com` — normalized as every
31
+ * contact address is, or `null` for anything that is not one.
32
+ */ export function emailAddressOf(value) {
33
+ const raw = String(value != null ? value : '').trim();
34
+ const angled = /<([^<>]+)>\s*$/.exec(raw);
35
+ return normalizeContactEmail(angled ? angled[1] : raw);
36
+ }
37
+ /** The part after the `@`, or `null` — what an unmatched message is logged by. */ export function emailDomainOf(value) {
38
+ const address = emailAddressOf(value);
39
+ if (!address) return null;
40
+ const at = address.lastIndexOf('@');
41
+ return at > 0 ? address.slice(at + 1) : null;
42
+ }
43
+ /**
44
+ * The subject with its reply and forward prefixes removed, however many
45
+ * were stacked — `Re: Fwd: RE: Renewal` is the `Renewal` thread — and
46
+ * whitespace collapsed, so two rows of one conversation carry one value.
47
+ */ export function threadSubject(subject) {
48
+ let value = String(subject != null ? subject : '').replace(/\s+/g, ' ').trim();
49
+ const prefix = /^(re|fwd?|aw|wg|tr|sv|vs)\s*(\[\d+\])?\s*:\s*/i;
50
+ while(prefix.test(value))value = value.replace(prefix, '');
51
+ return value.slice(0, EMAIL_SUBJECT_MAX);
52
+ }
53
+ const ENTITIES = {
54
+ amp: '&',
55
+ lt: '<',
56
+ gt: '>',
57
+ quot: '"',
58
+ apos: "'",
59
+ nbsp: ' '
60
+ };
61
+ /**
62
+ * The words in an HTML part, for a message that carried no text part.
63
+ * Block boundaries become line breaks, tags go, the common entities are
64
+ * read back — a best effort over a bounded slice, never a renderer.
65
+ */ export function htmlToPlainText(html) {
66
+ return String(html != null ? html : '').slice(0, 200000).replace(/<(script|style)[\s\S]*?<\/\1>/gi, '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6]|blockquote|pre)>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code)=>{
67
+ var _ENTITIES_code_toLowerCase;
68
+ if (code.startsWith('#x') || code.startsWith('#X')) {
69
+ return String.fromCodePoint(parseInt(code.slice(2), 16));
70
+ }
71
+ if (code.startsWith('#')) return String.fromCodePoint(parseInt(code.slice(1), 10));
72
+ return (_ENTITIES_code_toLowerCase = ENTITIES[code.toLowerCase()]) != null ? _ENTITIES_code_toLowerCase : whole;
73
+ });
74
+ }
75
+ const FORWARD_MARKER = /^\s*(-{2,}\s*Forwarded message\s*-{2,}|Begin forwarded message:)\s*$/i;
76
+ const HEADER_LINE = /^\s*\*?(From|Sent|Date|To|Cc|Subject|Reply-To)\s*:\*?\s*(.*)$/i;
77
+ /**
78
+ * The forwarded message inside a forward — the block a mail client puts
79
+ * under "Forwarded message" or, with no marker, a run of `From:` … `Subject:`
80
+ * header lines — or `null` when the text carries none. The `From:` line is
81
+ * where the correspondent of a forwarded reply is named, since the forward's
82
+ * own headers name only the teammate who forwarded it.
83
+ */ export function forwardedSection(text) {
84
+ const lines = text.split('\n');
85
+ let start = -1;
86
+ for(let index = 0; index < lines.length; index += 1){
87
+ if (FORWARD_MARKER.test(lines[index])) {
88
+ start = index + 1;
89
+ break;
90
+ }
91
+ if (/^\s*\*?From\s*:/i.test(lines[index]) && !lines[index].trimStart().startsWith('>')) {
92
+ const window = lines.slice(index, index + 8);
93
+ if (window.some((line)=>/^\s*\*?Subject\s*:/i.test(line))) {
94
+ start = index;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ if (start < 0) return null;
100
+ let from = null;
101
+ let cursor = start;
102
+ // The header block: header lines, blank lines between them allowed, up to
103
+ // the first line that is neither.
104
+ let sawHeader = false;
105
+ while(cursor < lines.length){
106
+ const line = lines[cursor];
107
+ const header = HEADER_LINE.exec(line);
108
+ if (header) {
109
+ sawHeader = true;
110
+ if (header[1].toLowerCase() === 'from') from = from != null ? from : emailAddressOf(header[2]);
111
+ cursor += 1;
112
+ continue;
113
+ }
114
+ if (!line.trim() && !sawHeader) {
115
+ cursor += 1;
116
+ continue;
117
+ }
118
+ if (!line.trim() && sawHeader) {
119
+ var _lines_;
120
+ // A blank line after a header run may precede more headers (Apple
121
+ // Mail) or the body; look past it.
122
+ const next = (_lines_ = lines[cursor + 1]) != null ? _lines_ : '';
123
+ if (HEADER_LINE.test(next)) {
124
+ cursor += 1;
125
+ continue;
126
+ }
127
+ cursor += 1;
128
+ break;
129
+ }
130
+ break;
131
+ }
132
+ return {
133
+ from,
134
+ body: lines.slice(cursor).join('\n')
135
+ };
136
+ }
137
+ const QUOTE_INTRO = /^\s*On\b.{0,300}$/;
138
+ const QUOTE_END = /wrote:\s*$/i;
139
+ const HISTORY_MARKERS = [
140
+ /^\s*>/,
141
+ /^\s*-{2,}\s*Original Message\s*-{2,}\s*$/i,
142
+ /^\s*_{5,}\s*$/,
143
+ /^\s*From:\s.+$/,
144
+ /^\s*Sent from my\b/i,
145
+ /^\s*Le .{0,200} a écrit\s*:\s*$/i,
146
+ /^\s*Am .{0,200} schrieb .{0,100}:\s*$/i
147
+ ];
148
+ /**
149
+ * The words above the quoted history: everything before the first `On …
150
+ * wrote:` line, quote mark, "Original Message" rule or reply header block.
151
+ * The intro may wrap onto a second line, so a line that begins `On` is
152
+ * read together with the one after it.
153
+ */ export function stripQuotedHistory(text) {
154
+ const lines = text.split('\n');
155
+ let end = lines.length;
156
+ for(let index = 0; index < lines.length; index += 1){
157
+ const line = lines[index];
158
+ if (HISTORY_MARKERS.some((marker)=>marker.test(line))) {
159
+ end = index;
160
+ break;
161
+ }
162
+ if (QUOTE_INTRO.test(line)) {
163
+ var _lines_;
164
+ const joined = `${line} ${(_lines_ = lines[index + 1]) != null ? _lines_ : ''}`;
165
+ if (QUOTE_END.test(line) || QUOTE_END.test(joined)) {
166
+ end = index;
167
+ break;
168
+ }
169
+ }
170
+ }
171
+ return lines.slice(0, end).join('\n');
172
+ }
173
+ /**
174
+ * What is worth keeping of a received message: the text part — or the
175
+ * words of the HTML part when there is none — with a forward's header
176
+ * block and the quoted history below the reply removed, whitespace
177
+ * settled, bounded. Never the whole thread: the thread is in the mailbox,
178
+ * and a record of the message is a note that this exchange happened.
179
+ */ export function emailExcerpt(text, html) {
180
+ let body = String(text != null ? text : '');
181
+ if (!body.trim() && html) body = htmlToPlainText(html);
182
+ body = body.replace(/\r\n?/g, '\n').replace(/\u00a0/g, ' ');
183
+ const forwarded = forwardedSection(body);
184
+ if (forwarded) body = forwarded.body;
185
+ body = stripQuotedHistory(body);
186
+ return body.split('\n').map((line)=>line.replace(/[ \t]+$/g, '')).join('\n').replace(/\n{3,}/g, '\n\n').trim().slice(0, EMAIL_EXCERPT_MAX);
187
+ }
188
+
189
+ //# sourceMappingURL=email-text.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-text.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeContactEmail } from './contacts'\n\n/**\n * READING A RECEIVED EMAIL (AGL-2657): the address in a header value, the\n * thread a subject belongs to, the words of an HTML part, the message a\n * forward carries and the reply above the quoted history.\n *\n * Pure, and the platform's: every surface that receives mail — a capture\n * address filing a message on a record, a sequence reading a prospect's\n * reply — reads a message the same way, so two of them can never disagree\n * about who wrote it or what they said.\n */\n\n/** The most a subject keeps, which is also what a one-to-one email may send. */\nexport const EMAIL_SUBJECT_MAX = 200\n\n/** The most an excerpt keeps: a letter, not a thread. */\nexport const EMAIL_EXCERPT_MAX = 4_000\n\n/**\n * The bare address in a header value — `Ada <ada@example.com>` and\n * `ada@example.com` both answer `ada@example.com` — normalized as every\n * contact address is, or `null` for anything that is not one.\n */\nexport function emailAddressOf(value: unknown): string | null {\n const raw = String(value ?? '').trim()\n const angled = /<([^<>]+)>\\s*$/.exec(raw)\n return normalizeContactEmail(angled ? angled[1] : raw)\n}\n\n/** The part after the `@`, or `null` — what an unmatched message is logged by. */\nexport function emailDomainOf(value: unknown): string | null {\n const address = emailAddressOf(value)\n if (!address) return null\n const at = address.lastIndexOf('@')\n return at > 0 ? address.slice(at + 1) : null\n}\n\n/**\n * The subject with its reply and forward prefixes removed, however many\n * were stacked — `Re: Fwd: RE: Renewal` is the `Renewal` thread — and\n * whitespace collapsed, so two rows of one conversation carry one value.\n */\nexport function threadSubject(subject: unknown): string {\n let value = String(subject ?? '')\n .replace(/\\s+/g, ' ')\n .trim()\n const prefix = /^(re|fwd?|aw|wg|tr|sv|vs)\\s*(\\[\\d+\\])?\\s*:\\s*/i\n while (prefix.test(value)) value = value.replace(prefix, '')\n return value.slice(0, EMAIL_SUBJECT_MAX)\n}\n\nconst ENTITIES: Record<string, string> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n}\n\n/**\n * The words in an HTML part, for a message that carried no text part.\n * Block boundaries become line breaks, tags go, the common entities are\n * read back — a best effort over a bounded slice, never a renderer.\n */\nexport function htmlToPlainText(html: unknown): string {\n return String(html ?? '')\n .slice(0, 200_000)\n .replace(/<(script|style)[\\s\\S]*?<\\/\\1>/gi, '')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<\\/(p|div|li|tr|h[1-6]|blockquote|pre)>/gi, '\\n')\n .replace(/<[^>]+>/g, '')\n .replace(/&(#\\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, code: string) => {\n if (code.startsWith('#x') || code.startsWith('#X')) {\n return String.fromCodePoint(parseInt(code.slice(2), 16))\n }\n if (code.startsWith('#')) return String.fromCodePoint(parseInt(code.slice(1), 10))\n return ENTITIES[code.toLowerCase()] ?? whole\n })\n}\n\n/** What a forwarded message's header block says, and the words after it. */\nexport interface ForwardedSection {\n /** The address on the block's `From:` line, or `null` when it carried none. */\n from: string | null\n /** The forwarded message's own text, header block removed. */\n body: string\n}\n\nconst FORWARD_MARKER = /^\\s*(-{2,}\\s*Forwarded message\\s*-{2,}|Begin forwarded message:)\\s*$/i\nconst HEADER_LINE = /^\\s*\\*?(From|Sent|Date|To|Cc|Subject|Reply-To)\\s*:\\*?\\s*(.*)$/i\n\n/**\n * The forwarded message inside a forward — the block a mail client puts\n * under \"Forwarded message\" or, with no marker, a run of `From:` … `Subject:`\n * header lines — or `null` when the text carries none. The `From:` line is\n * where the correspondent of a forwarded reply is named, since the forward's\n * own headers name only the teammate who forwarded it.\n */\nexport function forwardedSection(text: string): ForwardedSection | null {\n const lines = text.split('\\n')\n let start = -1\n for (let index = 0; index < lines.length; index += 1) {\n if (FORWARD_MARKER.test(lines[index])) {\n start = index + 1\n break\n }\n if (/^\\s*\\*?From\\s*:/i.test(lines[index]) && !lines[index].trimStart().startsWith('>')) {\n const window = lines.slice(index, index + 8)\n if (window.some((line) => /^\\s*\\*?Subject\\s*:/i.test(line))) {\n start = index\n break\n }\n }\n }\n if (start < 0) return null\n let from: string | null = null\n let cursor = start\n // The header block: header lines, blank lines between them allowed, up to\n // the first line that is neither.\n let sawHeader = false\n while (cursor < lines.length) {\n const line = lines[cursor]\n const header = HEADER_LINE.exec(line)\n if (header) {\n sawHeader = true\n if (header[1].toLowerCase() === 'from') from = from ?? emailAddressOf(header[2])\n cursor += 1\n continue\n }\n if (!line.trim() && !sawHeader) {\n cursor += 1\n continue\n }\n if (!line.trim() && sawHeader) {\n // A blank line after a header run may precede more headers (Apple\n // Mail) or the body; look past it.\n const next = lines[cursor + 1] ?? ''\n if (HEADER_LINE.test(next)) {\n cursor += 1\n continue\n }\n cursor += 1\n break\n }\n break\n }\n return { from, body: lines.slice(cursor).join('\\n') }\n}\n\nconst QUOTE_INTRO = /^\\s*On\\b.{0,300}$/\nconst QUOTE_END = /wrote:\\s*$/i\nconst HISTORY_MARKERS = [\n /^\\s*>/,\n /^\\s*-{2,}\\s*Original Message\\s*-{2,}\\s*$/i,\n /^\\s*_{5,}\\s*$/,\n /^\\s*From:\\s.+$/,\n /^\\s*Sent from my\\b/i,\n /^\\s*Le .{0,200} a écrit\\s*:\\s*$/i,\n /^\\s*Am .{0,200} schrieb .{0,100}:\\s*$/i,\n]\n\n/**\n * The words above the quoted history: everything before the first `On …\n * wrote:` line, quote mark, \"Original Message\" rule or reply header block.\n * The intro may wrap onto a second line, so a line that begins `On` is\n * read together with the one after it.\n */\nexport function stripQuotedHistory(text: string): string {\n const lines = text.split('\\n')\n let end = lines.length\n for (let index = 0; index < lines.length; index += 1) {\n const line = lines[index]\n if (HISTORY_MARKERS.some((marker) => marker.test(line))) {\n end = index\n break\n }\n if (QUOTE_INTRO.test(line)) {\n const joined = `${line} ${lines[index + 1] ?? ''}`\n if (QUOTE_END.test(line) || QUOTE_END.test(joined)) {\n end = index\n break\n }\n }\n }\n return lines.slice(0, end).join('\\n')\n}\n\n/**\n * What is worth keeping of a received message: the text part — or the\n * words of the HTML part when there is none — with a forward's header\n * block and the quoted history below the reply removed, whitespace\n * settled, bounded. Never the whole thread: the thread is in the mailbox,\n * and a record of the message is a note that this exchange happened.\n */\nexport function emailExcerpt(text: unknown, html?: unknown): string {\n let body = String(text ?? '')\n if (!body.trim() && html) body = htmlToPlainText(html)\n body = body.replace(/\\r\\n?/g, '\\n').replace(/\\u00a0/g, ' ')\n const forwarded = forwardedSection(body)\n if (forwarded) body = forwarded.body\n body = stripQuotedHistory(body)\n return body\n .split('\\n')\n .map((line) => line.replace(/[ \\t]+$/g, ''))\n .join('\\n')\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n .slice(0, EMAIL_EXCERPT_MAX)\n}\n"],"names":["normalizeContactEmail","EMAIL_SUBJECT_MAX","EMAIL_EXCERPT_MAX","emailAddressOf","value","raw","String","trim","angled","exec","emailDomainOf","address","at","lastIndexOf","slice","threadSubject","subject","replace","prefix","test","ENTITIES","amp","lt","gt","quot","apos","nbsp","htmlToPlainText","html","whole","code","startsWith","fromCodePoint","parseInt","toLowerCase","FORWARD_MARKER","HEADER_LINE","forwardedSection","text","lines","split","start","index","length","trimStart","window","some","line","from","cursor","sawHeader","header","next","body","join","QUOTE_INTRO","QUOTE_END","HISTORY_MARKERS","stripQuotedHistory","end","marker","joined","emailExcerpt","forwarded","map"],"mappings":"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"}
@@ -17,6 +17,10 @@
17
17
  /**
18
18
  * What basis lets somebody be put on a marketing list.
19
19
  *
20
+ * The enrollment-time half of `marketing-consent.ts`: consent to the MARKETING
21
+ * send purpose, which every surface that enrolls a person in an audience owes
22
+ * whichever plugin keeps the audience. It names no plugin's lists.
23
+ *
20
24
  * Pure policy, no Firestore and no network, so the rule that decides whether
21
25
  * somebody joins a marketing audience can be asserted without an enrollment
22
26
  * path.
@@ -16,6 +16,10 @@
16
16
  */ /**
17
17
  * What basis lets somebody be put on a marketing list.
18
18
  *
19
+ * The enrollment-time half of `marketing-consent.ts`: consent to the MARKETING
20
+ * send purpose, which every surface that enrolls a person in an audience owes
21
+ * whichever plugin keeps the audience. It names no plugin's lists.
22
+ *
19
23
  * Pure policy, no Firestore and no network, so the rule that decides whether
20
24
  * somebody joins a marketing audience can be asserted without an enrollment
21
25
  * path.
@@ -167,4 +171,4 @@
167
171
  'no-basis': 'This person has no marketing opt-in on record, so they can only be added if you state that you have their permission.'
168
172
  };
169
173
 
170
- //# sourceMappingURL=list-assignment-policy.js.map
174
+ //# sourceMappingURL=enrollment-basis.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/enrollment-basis.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What basis lets somebody be put on a marketing list.\n *\n * The enrollment-time half of `marketing-consent.ts`: consent to the MARKETING\n * send purpose, which every surface that enrolls a person in an audience owes\n * whichever plugin keeps the audience. It names no plugin's lists.\n *\n * Pure policy, no Firestore and no network, so the rule that decides whether\n * somebody joins a marketing audience can be asserted without an enrollment\n * path.\n *\n * ## Why it is here and not beside one of its callers\n *\n * There are two enrollment surfaces and neither can own this: the Inbox\n * assignment route (`@aglyn/plugins-inbox`) enrolls the sender of a form\n * submission, and the Emails console's audience card enrolls an address a\n * merchant types or pastes. `plugins-inbox` already depends on\n * `plugins-email`, so the second could not have imported the first, and a\n * copy in each is the shape that ends with one surface honoring a stored\n * refusal and the other not. The basis a membership carries has to be one\n * rule with one set of words, so it lives in the framework both import.\n *\n * ## A reply is transactional; a list is marketing\n *\n * These are two acts and this module exists because they are constantly\n * mistaken for one. Answering a form submission is the transaction the person\n * started, so the Inbox's `reply-policy.ts` requires no consent record.\n * Putting them on a list is a standing invitation to mail them about things\n * they never asked about, and it requires a basis. Nothing in the reply path\n * reaches this module and nothing here sends anything.\n *\n * ## An enrollment is not a license to send\n *\n * Passing this rule writes a membership and nothing else. It meters nothing,\n * sends nothing, and is not a promise the person is still mailable when a\n * campaign eventually runs — suppression is consulted again at send time, in\n * `filterSendableForHost`, because an address can be suppressed the day after\n * it is enrolled. `apps/console/specs/an-enrollment-is-not-a-license-to-send.spec.ts`\n * holds the two halves together end to end.\n *\n * ## A basis is DECLARED, never inferred\n *\n * The act that produced the address is not a basis. Somebody who filled in a\n * contact form asked to be answered; that is the whole of what they asked for,\n * and an address a merchant has in a spreadsheet says less than that. Neither\n * is writing to the merchant, buying, booking or holding an account — the\n * shared `marketing-consent` module makes the same point for the send-time\n * join, and this is the enrollment-time half of it. So there are exactly two\n * ways in:\n *\n * 1. the person's own record already carries a stored opt-in, which is\n * carried across unchanged, timestamp and all; or\n * 2. the merchant states that they have this person's permission, and THAT\n * STATEMENT IS ITSELF THE RECORDED BASIS — stored with the account that\n * made it and the moment they did.\n *\n * There is no third way and, in particular, no unattributed one. The absence\n * of a record is reported to the merchant as an absence rather than resolved\n * quietly in either direction.\n *\n * ## A stored refusal ends it\n *\n * `declined` is checked first and answers before anything else is considered,\n * including an attestation. A merchant cannot attest their way past somebody\n * who said no; that is the one thing an attestation must not be able to buy,\n * because if it could there would be no difference between recording a\n * refusal and discarding it.\n */\n\nimport {\n OPERATOR_ATTESTED_CONSENT_BASIS,\n type MarketingConsentRecord,\n} from './marketing-consent'\n\n/**\n * What is wrong with the ADDRESS, before anybody asks what is wrong with the\n * consent.\n *\n * Four failures that are properties of the address rather than of the act, so\n * every surface that touches an address shares them: a form with no email\n * field has nobody to enroll for the same reason it has nobody to answer, and\n * a bounced address is unmailable whichever route reached it. The Inbox's\n * `ReplyRefusal` is this type — one union, aliased there — because a reply\n * and an enrollment refuse an address for the same four reasons and a second\n * copy would eventually list three.\n */\nexport type AddressRefusal =\n /** No field the resolver recognizes as an email — nothing to enroll. */\n | 'no-address'\n /** The value is not a routable address. */\n | 'unroutable-address'\n /** The address is on the platform-wide bounce/complaint list. */\n | 'suppressed-platform'\n /** The address unsubscribed from, or bounced on, this site. */\n | 'suppressed-host'\n\n/** Why an address cannot be put on a list. Each maps to one refusal message. */\nexport type AssignmentRefusal =\n | AddressRefusal\n /** A stored refusal. Never enrollable, and there is no override. */\n | 'declined'\n /** No record either way, and the merchant asserted nothing. */\n | 'no-basis'\n\n/** The basis an enrollment would carry, resolved server-side. */\nexport interface AssignmentBasisDecision {\n /*\n * The attested value is the EXPORTED CONSTANT and not a literal spelled\n * again here. `readMarketingBasis` recognizes an attestation by comparing\n * the stored field against that constant, so a second spelling on the\n * deciding side is a rename away from writing a basis the reader reports as\n * the person's own act — the one conflation this whole area exists to stop.\n */\n basis: 'contact-opt-in' | typeof OPERATOR_ATTESTED_CONSENT_BASIS\n /** When the basis was recorded — the person's own moment, or the merchant's. */\n atMs: number\n /** The account answerable for an attestation; `null` for a pass-through. */\n byUid: string | null\n}\n\n/**\n * Decides what basis, if any, an enrollment may carry.\n *\n * The client never names the basis. It says only whether the merchant is\n * asserting one, and the pass-through is derived here from the person's own\n * record — a caller that could claim `contact-opt-in` would be a caller that\n * can manufacture an opt-in for anybody by naming it.\n *\n * @param stored the person's consent facts, read off their own record.\n * @param attested whether the merchant asserted permission in this request.\n */\nexport function assignmentBasis(input: {\n stored: MarketingConsentRecord\n attested: boolean\n actingUid: string\n nowMs: number\n}): AssignmentBasisDecision | { refusal: AssignmentRefusal } {\n // First, and above the attestation on purpose. See the module note.\n if (input.stored.basis === 'declined') return { refusal: 'declined' }\n if (input.stored.basis === 'granted') {\n return {\n basis: 'contact-opt-in',\n /*\n * The person's own timestamp, and `nowMs` only when their record\n * carries none. A pass-through that restamped the moment would report\n * every historical opt-in as having happened when a merchant pressed a\n * button, which moves records across the enforcement cutoff the consent\n * policy grandfathers on and makes the audit answer the wrong question.\n */\n atMs: input.stored.basisAtMs ?? input.nowMs,\n byUid: null,\n }\n }\n if (!input.attested) return { refusal: 'no-basis' }\n return {\n basis: OPERATOR_ATTESTED_CONSENT_BASIS,\n atMs: input.nowMs,\n // Never optional for this basis: an attestation nobody is named for is\n // indistinguishable from an opt-in, which is the conflation the whole\n // module exists to prevent.\n byUid: input.actingUid,\n }\n}\n\n/** What the merchant is shown before they decide. */\nexport interface AssignmentReadout {\n /** Can this person be enrolled at all, by any answer the merchant gives? */\n enrollable: boolean\n /**\n * Must the merchant assert permission for the enrollment to proceed?\n *\n * False both when a stored opt-in already carries it and when nothing can\n * carry it, so the UI reads this alone to decide whether to show the\n * assertion control — a surface that showed it on a refusal would offer an\n * override that does not exist.\n */\n requiresAttestation: boolean\n /** One sentence of consent facts, in the merchant's terms. */\n summary: string\n}\n\n/**\n * The readout for one address, from the same inputs the decision uses.\n *\n * It lives here rather than in the component so the sentence on screen and\n * the rule that runs on the server cannot drift: a UI that computed its own\n * summary would eventually tell a merchant that somebody may be added and\n * then refuse them, or — far worse — say a check was performed that was not.\n *\n * @param suppression a suppression refusal for the address, when there is\n * one. It outranks consent in the readout because it is\n * the answer regardless of what any consent record says.\n */\nexport function assignmentReadout(input: {\n stored: MarketingConsentRecord\n suppression: AddressRefusal | null\n}): AssignmentReadout {\n if (input.suppression) {\n return {\n enrollable: false,\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES[input.suppression],\n }\n }\n if (input.stored.basis === 'declined') {\n return {\n enrollable: false,\n requiresAttestation: false,\n summary: ASSIGNMENT_REFUSAL_MESSAGES['declined'],\n }\n }\n if (input.stored.basis === 'granted') {\n const when = input.stored.basisAtMs\n ? ` on ${new Date(input.stored.basisAtMs).toLocaleDateString()}`\n : ''\n return {\n enrollable: true,\n requiresAttestation: false,\n summary: `This person opted in to marketing email${when}. Adding them carries that opt-in across.`,\n }\n }\n return {\n enrollable: true,\n requiresAttestation: true,\n summary:\n 'There is no marketing opt-in on record for this person. Sending this ' +\n 'form does not create one. To add them you have to state that you have ' +\n 'their permission, and that statement is recorded against your account.',\n }\n}\n\n/** What a refusal says to the merchant. One line, naming the cause. */\nexport const ASSIGNMENT_REFUSAL_MESSAGES: Record<AssignmentRefusal, string> = {\n 'no-address':\n 'This submission has no email field, so there is nobody to add to a list.',\n 'unroutable-address':\n 'The email address on this submission is not a valid address.',\n 'suppressed-platform':\n 'This address bounced or reported a message as spam, so it cannot be mailed or added to a list.',\n 'suppressed-host':\n 'This address unsubscribed from this site, so it cannot be mailed or added to a list.',\n declined:\n 'This person declined marketing email. They cannot be added to a list, and there is no way to override that.',\n 'no-basis':\n 'This person has no marketing opt-in on record, so they can only be added if you state that you have their permission.',\n}\n"],"names":["OPERATOR_ATTESTED_CONSENT_BASIS","assignmentBasis","input","stored","basis","refusal","atMs","basisAtMs","nowMs","byUid","attested","actingUid","assignmentReadout","suppression","enrollable","requiresAttestation","summary","ASSIGNMENT_REFUSAL_MESSAGES","when","Date","toLocaleDateString","declined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmEC,GAED,SACEA,+BAA+B,QAE1B,yBAAqB;AAgD5B;;;;;;;;;;CAUC,GACD,OAAO,SAASC,gBAAgBC,KAK/B;IACC,oEAAoE;IACpE,IAAIA,MAAMC,MAAM,CAACC,KAAK,KAAK,YAAY,OAAO;QAAEC,SAAS;IAAW;IACpE,IAAIH,MAAMC,MAAM,CAACC,KAAK,KAAK,WAAW;YAU5BF;QATR,OAAO;YACLE,OAAO;YACP;;;;;;OAMC,GACDE,IAAI,GAAEJ,0BAAAA,MAAMC,MAAM,CAACI,SAAS,YAAtBL,0BAA0BA,MAAMM,KAAK;YAC3CC,OAAO;QACT;IACF;IACA,IAAI,CAACP,MAAMQ,QAAQ,EAAE,OAAO;QAAEL,SAAS;IAAW;IAClD,OAAO;QACLD,OAAOJ;QACPM,MAAMJ,MAAMM,KAAK;QACjB,uEAAuE;QACvE,sEAAsE;QACtE,4BAA4B;QAC5BC,OAAOP,MAAMS,SAAS;IACxB;AACF;AAmBA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBAAkBV,KAGjC;IACC,IAAIA,MAAMW,WAAW,EAAE;QACrB,OAAO;YACLC,YAAY;YACZC,qBAAqB;YACrBC,SAASC,2BAA2B,CAACf,MAAMW,WAAW,CAAC;QACzD;IACF;IACA,IAAIX,MAAMC,MAAM,CAACC,KAAK,KAAK,YAAY;QACrC,OAAO;YACLU,YAAY;YACZC,qBAAqB;YACrBC,SAASC,2BAA2B,CAAC,WAAW;QAClD;IACF;IACA,IAAIf,MAAMC,MAAM,CAACC,KAAK,KAAK,WAAW;QACpC,MAAMc,OAAOhB,MAAMC,MAAM,CAACI,SAAS,GAC/B,CAAC,IAAI,EAAE,IAAIY,KAAKjB,MAAMC,MAAM,CAACI,SAAS,EAAEa,kBAAkB,IAAI,GAC9D;QACJ,OAAO;YACLN,YAAY;YACZC,qBAAqB;YACrBC,SAAS,CAAC,uCAAuC,EAAEE,KAAK,yCAAyC,CAAC;QACpG;IACF;IACA,OAAO;QACLJ,YAAY;QACZC,qBAAqB;QACrBC,SACE,0EACA,2EACA;IACJ;AACF;AAEA,qEAAqE,GACrE,OAAO,MAAMC,8BAAiE;IAC5E,cACE;IACF,sBACE;IACF,uBACE;IACF,mBACE;IACFI,UACE;IACF,YACE;AACJ,EAAC"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
- import { applyDatasetQuery, parseDatasetFilter, parseDatasetSort } from "./dataset-query.js";
17
+ import { applyRepeatQuery, parseRepeatFilter, parseRepeatSort } from "./repeat-query.js";
18
18
  /** Namespaces cloned template ids per container/record. */ export const REPEAT_NODE_ID_PREFIX = 'rep__';
19
19
  /**
20
20
  * `{{item.field}}` token, plus an optional single reference hop
@@ -155,10 +155,10 @@ function substituteValue(value, context) {
155
155
  */ export function repeatedRecords(node, dataset) {
156
156
  var _ref, _props_repeatFilter, _props_repeatSort, _ref1;
157
157
  const props = (_ref = node == null ? void 0 : node.props) != null ? _ref : {};
158
- const where = parseDatasetFilter(String((_props_repeatFilter = props['repeatFilter']) != null ? _props_repeatFilter : ''));
159
- const orderBy = parseDatasetSort(String((_props_repeatSort = props['repeatSort']) != null ? _props_repeatSort : ''));
158
+ const where = parseRepeatFilter(String((_props_repeatFilter = props['repeatFilter']) != null ? _props_repeatFilter : ''));
159
+ const orderBy = parseRepeatSort(String((_props_repeatSort = props['repeatSort']) != null ? _props_repeatSort : ''));
160
160
  const limit = Number(props['repeatLimit']);
161
- return applyDatasetQuery(dataset == null ? void 0 : dataset.model, (_ref1 = dataset == null ? void 0 : dataset.records) != null ? _ref1 : [], _extends({}, where ? {
161
+ return applyRepeatQuery((_ref1 = dataset == null ? void 0 : dataset.records) != null ? _ref1 : [], _extends({}, where ? {
162
162
  where: [
163
163
  where
164
164
  ]
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/expand-repeatables.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation'\nimport {\n applyDatasetQuery,\n parseDatasetFilter,\n parseDatasetSort,\n} from './dataset-query'\n\n/** Namespaces cloned template ids per container/record. */\nexport const REPEAT_NODE_ID_PREFIX = 'rep__'\n\n/**\n * `{{item.field}}` token, plus an optional single reference hop\n * (`{{item.author.name}}`, AGL-180). One hop only — the pattern itself is\n * the depth guard, so cycles can't recurse.\n */\nconst ITEM_TOKEN_PATTERN =\n /\\{\\{\\s*item\\.([A-Za-z][A-Za-z0-9_]*)(?:\\.([A-Za-z][A-Za-z0-9_]*))?\\s*\\}\\}/g\n\n/** Hard bound on rows a single repeatable renders, before `repeatLimit`. */\nexport const REPEAT_MAX_RECORDS = 100\n\nexport interface RepeatableDataset {\n /**\n * Row value maps, in display order. Rows carry `$id` (needed to resolve\n * incoming references); values may be typed (AGL-177), stringified at\n * substitution.\n */\n records: Array<Record<string, unknown>>\n /** Typed model (AGL-177); required for reference-hop bindings. */\n model?: import('./dataset-models').DatasetModel\n}\n\ninterface SubstituteContext {\n record: Record<string, unknown>\n /** Model of the repeated dataset (reference hops need field configs). */\n model?: import('./dataset-models').DatasetModel\n /** All host datasets keyed by id (and name) for hop resolution. */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n}\n\nconst displayValue = (value: unknown): string =>\n Array.isArray(value) ? value.join(', ') : String(value)\n\n/** Resolves `{{item.ref.field}}`: FKey(s) → target document field value. */\nfunction resolveReferenceHop(\n context: SubstituteContext,\n fieldId: string,\n targetFieldId: string,\n): string | null {\n const field = context.model?.fields?.[fieldId]\n if (field?.type !== 'reference' || !field.reference?.datasetId) return null\n const target = context.datasetsByKey?.[field.reference.datasetId]\n if (!target) return null\n const keys = context.record[fieldId]\n const ids = Array.isArray(keys) ? keys : keys != null ? [keys] : []\n const resolved = ids\n .map((id) => {\n const match = target.records.find((row) => row['$id'] === id)\n const value = match?.[targetFieldId]\n return value != null ? displayValue(value) : null\n })\n .filter((value): value is string => value != null)\n return resolved.length ? resolved.join(', ') : null\n}\n\nfunction substituteValue(\n value: unknown,\n context: SubstituteContext,\n): unknown {\n const { record } = context\n if (typeof value === 'string') {\n return value.replace(\n ITEM_TOKEN_PATTERN,\n (token, field: string, hop?: string) => {\n if (hop) {\n const resolved = resolveReferenceHop(context, field, hop)\n return resolved != null ? resolved : token\n }\n return record[field] != null ? displayValue(record[field]) : token\n },\n )\n }\n if (Array.isArray(value)) {\n return value.map((entry) => substituteValue(entry, context))\n }\n if (value && typeof value === 'object') {\n const next: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(value)) {\n next[key] = substituteValue(entry, context)\n }\n return next\n }\n return value\n}\n\n/**\n * The key a node repeats over, trimmed, or `''` when it repeats over nothing.\n *\n * The one predicate every repeat question is asked through: the expansion, the\n * datasets-read gate below, the reusable-component graft that moves a\n * placement's repeat onto the element it becomes, and the besigner's preview\n * and badge. Two copies of it are how a `.trim()` on one side and not the\n * other becomes an empty list on a customer's page.\n */\nexport const repeatKey = (node: unknown): string => {\n const key = (node as { props?: { repeatDataset?: unknown } })?.props\n ?.repeatDataset\n return typeof key === 'string' ? key.trim() : ''\n}\n\n/**\n * Makes an element that holds children repeat ITSELF, once per record, instead\n * of what is inside it (AGL-3111): a card whose contents are the design, rather\n * than a list whose contents are the item.\n *\n * Persisted in screen, layout and component documents — never rename. `'true'`\n * counts too, because a switch round-tripped through a text-shaped store comes\n * back as the string.\n */\nexport const REPEAT_SELF_PROP = 'repeatSelf'\n\n/**\n * Every prop that directs a repeat. They are instructions to the composition,\n * never attributes of the element: the expansion consumes them from every node\n * it expands and every copy it makes, so a published element never carries\n * one. Persisted — never rename.\n */\nexport const REPEAT_DIRECTIVE_PROPS: readonly string[] = [\n 'repeatDataset',\n 'repeatLimit',\n 'repeatFilter',\n 'repeatSort',\n REPEAT_SELF_PROP,\n]\n\n/**\n * What a repeating node copies once per record (AGL-3111).\n *\n * - `children` — what is inside it. The node stays one element around the\n * copies: the list, grid or gallery they fill. This is how a Stack has\n * always repeated (AGL-103), and a stored Stack names neither scope.\n * - `self` — the node itself, with everything inside it. What a leaf and a\n * component instance repeat, and what any element carrying\n * {@link REPEAT_SELF_PROP} repeats.\n */\nexport type RepeatScope = 'self' | 'children'\n\n/**\n * Which scope a repeating node has, read off the node alone so the published\n * page's composition and the besigner decide it identically.\n *\n * {@link REPEAT_SELF_PROP} decides it whenever the node carries one, which is\n * every repeat the Attributes panel writes. Without one the node's own shape\n * decides, and it decides CONSERVATIVELY: anything that holds a child list —\n * a Stack, empty or not — keeps repeating its children, and only a node with\n * no child list at all repeats itself. A stored repeat predates the prop, so\n * this branch is the one that has to render it unchanged, and an author who\n * named a dataset before filling the container in would otherwise come back to\n * a hundred empty copies of it.\n *\n * A component instance never reaches that branch: the graft stamps the prop on\n * the element it becomes, whose children are the component's own.\n */\nexport function repeatScope(node: unknown): RepeatScope {\n const { props, nodes } = (node ?? {}) as {\n props?: Record<string, unknown>\n nodes?: unknown\n }\n // `'true'`/`'false'` too: a switch round-tripped through a text-shaped\n // store comes back as the string it was written as.\n const self = props?.[REPEAT_SELF_PROP]\n if (self === true || self === 'true') return 'self'\n if (self === false || self === 'false') return 'children'\n return Array.isArray(nodes) ? 'children' : 'self'\n}\n\n/**\n * One record's values put into `{{item.field}}` tokens, anywhere in a props\n * map — the substitution {@link expandRepeatables} makes on every copy it\n * builds, for a caller that already knows which record it is rendering.\n *\n * The besigner's canvas is that caller (AGL-3111): the element an author\n * edits IS the first copy, so it draws with the first record's values while\n * the node it saves keeps the tokens. Exported rather than reimplemented\n * because a canvas that resolved a token differently from the page would be\n * showing the author a page that does not exist.\n */\nexport function substituteRecordTokens<T>(\n props: T,\n context: {\n record: Record<string, unknown>\n /** Model of the repeated rows; reference hops need field configs. */\n model?: import('./dataset-models').DatasetModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n },\n): T {\n return substituteValue(props, context) as T\n}\n\n/** The node minus its repeat directives, or the node itself when it has none. */\nexport function withoutRepeatDirective<N>(node: N): N {\n const props = (node as { props?: Record<string, unknown> })?.props\n if (!props || !REPEAT_DIRECTIVE_PROPS.some((key) => key in props)) {\n return node\n }\n const kept = { ...props }\n for (const key of REPEAT_DIRECTIVE_PROPS) delete kept[key]\n return { ...node, props: kept }\n}\n\n/**\n * The records a repeating node renders, in order (AGL-181): its filter and sort\n * evaluated over the rows, then its limit, never past\n * {@link REPEAT_MAX_RECORDS}. An unparseable filter or sort fails open.\n *\n * Shared by the expansion and the besigner's canvas preview and badge, so the\n * canvas can never show a copy or a count the published page does not.\n */\nexport function repeatedRecords(\n node: unknown,\n dataset: RepeatableDataset | undefined,\n): Array<Record<string, unknown>> {\n const props = ((node as { props?: unknown })?.props ?? {}) as Record<\n string,\n unknown\n >\n const where = parseDatasetFilter(String(props['repeatFilter'] ?? ''))\n const orderBy = parseDatasetSort(String(props['repeatSort'] ?? ''))\n const limit = Number(props['repeatLimit'])\n return applyDatasetQuery(dataset?.model, dataset?.records ?? [], {\n ...(where ? { where: [where] } : {}),\n ...(orderBy ? { orderBy } : {}),\n }).slice(\n 0,\n Number.isFinite(limit) && limit > 0\n ? Math.min(limit, REPEAT_MAX_RECORDS)\n : REPEAT_MAX_RECORDS,\n )\n}\n\n/**\n * Does this tree contain anything {@link expandRepeatables} would expand?\n *\n * The rows read is the largest on the render path — up to two pages of\n * {@link REPEAT_MAX_RECORDS} records for every key a page repeats over — and a\n * page that repeats over nothing should pay none of it (AGL-1440).\n *\n * Any gate on that read has to ask EXACTLY the question the expansion asks,\n * which is why this and {@link repeatKeys} share the one predicate\n * `expandRepeatables` looks keys up with, {@link repeatKey}. A gate that is\n * even slightly stricter than the expansion is not a saving: it is a published\n * page that quietly renders one template row where the author put a list.\n */\nexport function hasRepeatableNodes(\n nodes: Record<NodeId, unknown> | null | undefined,\n): boolean {\n if (!nodes) return false\n return Object.values(nodes).some((node) => repeatKey(node) !== '')\n}\n\n/**\n * Every key this tree repeats over, exactly as {@link expandRepeatables} looks\n * it up — sorted and without repeats.\n *\n * What the tenant compose asks the repeat-rows contract for\n * (`plugin-manager/repeat-rows.ts`), so a page loads the rows it renders and\n * not everything its site may see. Built on the same predicate as\n * {@link hasRepeatableNodes} for the reason given there.\n */\nexport function repeatKeys(\n nodes: Record<NodeId, unknown> | null | undefined,\n): string[] {\n const keys = new Set<string>()\n for (const node of Object.values(nodes ?? {})) {\n const key = repeatKey(node)\n if (key) keys.add(key)\n }\n return [...keys].sort()\n}\n\n/**\n * Repeats (AGL-103, AGL-3111): any node carrying `props.repeatDataset` (a\n * dataset id or display name) renders once per record of that dataset, with\n * `{{item.field}}` tokens in the copied string props replaced by the record's\n * values (unknown fields keep the literal token, like variable bindings).\n *\n * What is copied is the node's {@link repeatScope}:\n *\n * - `children` — its children are the item template. The node stays, and its\n * child list becomes the copies, record by record. Its own props are not the\n * template, so no token in them is substituted.\n * - `self` — the node and everything inside it are the template. The copies\n * take the node's place in its parent's child list, and every prop in the\n * subtree is substituted, the node's own included.\n *\n * - Copy ids are namespaced `rep__{repeatId}__{index}__…` — the repeat's own\n * id, then the template node's — so repeats never collide, including inside\n * grafted reusable components. A self-scoped node's first copy is\n * `rep__{id}__0__{id}`. Run this AFTER `composeReusableComponentNodes` and\n * BEFORE binding resolution.\n * - Rows are the node's {@link repeatedRecords}.\n * - The repeat directives ({@link REPEAT_DIRECTIVE_PROPS}) are consumed: every\n * node this expands, and every copy it makes, comes out without them.\n * - Repeats do not nest. A repeat inside another repeat's template is copied\n * with its directives consumed, so it renders once in each copy.\n * - An unknown dataset, no matching records, an empty template, or a\n * self-scoped node its parent does not list (the document root, say) leave\n * the node rendering once, as written (fail-open: a deleted dataset must\n * never take a published screen down).\n * - Inputs are never mutated; templates stay in the map unreferenced.\n */\nexport function expandRepeatables<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N>,\n datasetsByKey: Record<string, RepeatableDataset | undefined> | undefined,\n): Record<NodeId, N> {\n if (!datasetsByKey) return nodes\n const repeatIds = Object.entries(nodes).filter(\n ([, node]) => repeatKey(node) !== '',\n )\n if (!repeatIds.length) return nodes\n\n const next: Record<NodeId, N> = { ...nodes }\n for (const [repeatId, repeated] of repeatIds) {\n const dataset = datasetsByKey[repeatKey(repeated)]\n const records = repeatedRecords(repeated, dataset)\n const self = repeatScope(repeated) === 'self'\n // The copies' parent: the node itself, or — for a self-scoped node — the\n // parent whose child list the copies take its place in, read from `next`\n // so two self-scoped siblings both land in the list the other has left.\n const parentId = (self ? repeated.parentId : repeatId) as NodeId\n const siblings = next[parentId]?.nodes\n const slot = Array.isArray(siblings)\n ? (siblings as NodeId[]).indexOf(repeatId)\n : -1\n const templateIds = self\n ? [repeatId]\n : Array.isArray(repeated.nodes)\n ? (repeated.nodes as NodeId[])\n : []\n next[repeatId] = withoutRepeatDirective(next[repeatId])\n if (!records.length || !templateIds.length || (self && slot < 0)) continue\n\n const copyIds: NodeId[] = []\n records.forEach((record, index) => {\n const prefix = `${REPEAT_NODE_ID_PREFIX}${repeatId}__${index}__`\n const prefixId = (id: NodeId) => `${prefix}${id}`\n const cloneSubtree = (id: NodeId, clonedParentId: NodeId) => {\n const node = nodes[id]\n if (!node) return\n const clonedChildren = Array.isArray(node.nodes)\n ? (node.nodes as NodeId[])\n : undefined\n next[prefixId(id)] = withoutRepeatDirective({\n ...node,\n $id: prefixId(id),\n parentId: clonedParentId,\n props: substituteValue(node.props ?? {}, {\n record,\n model: dataset?.model,\n datasetsByKey,\n }) as any,\n ...(clonedChildren && {\n nodes: clonedChildren.map((childId) => prefixId(childId)),\n }),\n })\n clonedChildren?.forEach((childId) =>\n cloneSubtree(childId, prefixId(id)),\n )\n }\n for (const templateId of templateIds) {\n cloneSubtree(templateId, parentId)\n copyIds.push(prefixId(templateId))\n }\n })\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1, ...copyIds)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: copyIds }\n }\n }\n return next\n}\n\nexport default expandRepeatables\n"],"names":["applyDatasetQuery","parseDatasetFilter","parseDatasetSort","REPEAT_NODE_ID_PREFIX","ITEM_TOKEN_PATTERN","REPEAT_MAX_RECORDS","displayValue","value","Array","isArray","join","String","resolveReferenceHop","context","fieldId","targetFieldId","field","model","fields","type","reference","datasetId","target","datasetsByKey","keys","record","ids","resolved","map","id","match","records","find","row","filter","length","substituteValue","replace","token","hop","entry","next","key","Object","entries","repeatKey","node","props","repeatDataset","trim","REPEAT_SELF_PROP","REPEAT_DIRECTIVE_PROPS","repeatScope","nodes","self","substituteRecordTokens","withoutRepeatDirective","some","kept","repeatedRecords","dataset","where","orderBy","limit","Number","slice","isFinite","Math","min","hasRepeatableNodes","values","repeatKeys","Set","add","sort","expandRepeatables","repeatIds","repeatId","repeated","parentId","siblings","slot","indexOf","templateIds","copyIds","forEach","index","prefix","prefixId","cloneSubtree","clonedParentId","clonedChildren","undefined","$id","childId","templateId","push","listed","splice"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/expand-repeatables.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation'\nimport {\n applyRepeatQuery,\n parseRepeatFilter,\n parseRepeatSort,\n} from './repeat-query'\n\n/** Namespaces cloned template ids per container/record. */\nexport const REPEAT_NODE_ID_PREFIX = 'rep__'\n\n/**\n * `{{item.field}}` token, plus an optional single reference hop\n * (`{{item.author.name}}`, AGL-180). One hop only — the pattern itself is\n * the depth guard, so cycles can't recurse.\n */\nconst ITEM_TOKEN_PATTERN =\n /\\{\\{\\s*item\\.([A-Za-z][A-Za-z0-9_]*)(?:\\.([A-Za-z][A-Za-z0-9_]*))?\\s*\\}\\}/g\n\n/** Hard bound on rows a single repeatable renders, before `repeatLimit`. */\nexport const REPEAT_MAX_RECORDS = 100\n\nexport interface RepeatableDataset {\n /**\n * Row value maps, in display order. Rows carry `$id` (needed to resolve\n * incoming references); values may be typed (AGL-177), stringified at\n * substitution.\n */\n records: Array<Record<string, unknown>>\n /** Typed model (AGL-177); required for reference-hop bindings. */\n model?: import('./dataset-models').DatasetModel\n}\n\ninterface SubstituteContext {\n record: Record<string, unknown>\n /** Model of the repeated dataset (reference hops need field configs). */\n model?: import('./dataset-models').DatasetModel\n /** All host datasets keyed by id (and name) for hop resolution. */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n}\n\nconst displayValue = (value: unknown): string =>\n Array.isArray(value) ? value.join(', ') : String(value)\n\n/** Resolves `{{item.ref.field}}`: FKey(s) → target document field value. */\nfunction resolveReferenceHop(\n context: SubstituteContext,\n fieldId: string,\n targetFieldId: string,\n): string | null {\n const field = context.model?.fields?.[fieldId]\n if (field?.type !== 'reference' || !field.reference?.datasetId) return null\n const target = context.datasetsByKey?.[field.reference.datasetId]\n if (!target) return null\n const keys = context.record[fieldId]\n const ids = Array.isArray(keys) ? keys : keys != null ? [keys] : []\n const resolved = ids\n .map((id) => {\n const match = target.records.find((row) => row['$id'] === id)\n const value = match?.[targetFieldId]\n return value != null ? displayValue(value) : null\n })\n .filter((value): value is string => value != null)\n return resolved.length ? resolved.join(', ') : null\n}\n\nfunction substituteValue(\n value: unknown,\n context: SubstituteContext,\n): unknown {\n const { record } = context\n if (typeof value === 'string') {\n return value.replace(\n ITEM_TOKEN_PATTERN,\n (token, field: string, hop?: string) => {\n if (hop) {\n const resolved = resolveReferenceHop(context, field, hop)\n return resolved != null ? resolved : token\n }\n return record[field] != null ? displayValue(record[field]) : token\n },\n )\n }\n if (Array.isArray(value)) {\n return value.map((entry) => substituteValue(entry, context))\n }\n if (value && typeof value === 'object') {\n const next: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(value)) {\n next[key] = substituteValue(entry, context)\n }\n return next\n }\n return value\n}\n\n/**\n * The key a node repeats over, trimmed, or `''` when it repeats over nothing.\n *\n * The one predicate every repeat question is asked through: the expansion, the\n * datasets-read gate below, the reusable-component graft that moves a\n * placement's repeat onto the element it becomes, and the besigner's preview\n * and badge. Two copies of it are how a `.trim()` on one side and not the\n * other becomes an empty list on a customer's page.\n */\nexport const repeatKey = (node: unknown): string => {\n const key = (node as { props?: { repeatDataset?: unknown } })?.props\n ?.repeatDataset\n return typeof key === 'string' ? key.trim() : ''\n}\n\n/**\n * Makes an element that holds children repeat ITSELF, once per record, instead\n * of what is inside it (AGL-3111): a card whose contents are the design, rather\n * than a list whose contents are the item.\n *\n * Persisted in screen, layout and component documents — never rename. `'true'`\n * counts too, because a switch round-tripped through a text-shaped store comes\n * back as the string.\n */\nexport const REPEAT_SELF_PROP = 'repeatSelf'\n\n/**\n * Every prop that directs a repeat. They are instructions to the composition,\n * never attributes of the element: the expansion consumes them from every node\n * it expands and every copy it makes, so a published element never carries\n * one. Persisted — never rename.\n */\nexport const REPEAT_DIRECTIVE_PROPS: readonly string[] = [\n 'repeatDataset',\n 'repeatLimit',\n 'repeatFilter',\n 'repeatSort',\n REPEAT_SELF_PROP,\n]\n\n/**\n * What a repeating node copies once per record (AGL-3111).\n *\n * - `children` — what is inside it. The node stays one element around the\n * copies: the list, grid or gallery they fill. This is how a Stack has\n * always repeated (AGL-103), and a stored Stack names neither scope.\n * - `self` — the node itself, with everything inside it. What a leaf and a\n * component instance repeat, and what any element carrying\n * {@link REPEAT_SELF_PROP} repeats.\n */\nexport type RepeatScope = 'self' | 'children'\n\n/**\n * Which scope a repeating node has, read off the node alone so the published\n * page's composition and the besigner decide it identically.\n *\n * {@link REPEAT_SELF_PROP} decides it whenever the node carries one, which is\n * every repeat the Attributes panel writes. Without one the node's own shape\n * decides, and it decides CONSERVATIVELY: anything that holds a child list —\n * a Stack, empty or not — keeps repeating its children, and only a node with\n * no child list at all repeats itself. A stored repeat predates the prop, so\n * this branch is the one that has to render it unchanged, and an author who\n * named a dataset before filling the container in would otherwise come back to\n * a hundred empty copies of it.\n *\n * A component instance never reaches that branch: the graft stamps the prop on\n * the element it becomes, whose children are the component's own.\n */\nexport function repeatScope(node: unknown): RepeatScope {\n const { props, nodes } = (node ?? {}) as {\n props?: Record<string, unknown>\n nodes?: unknown\n }\n // `'true'`/`'false'` too: a switch round-tripped through a text-shaped\n // store comes back as the string it was written as.\n const self = props?.[REPEAT_SELF_PROP]\n if (self === true || self === 'true') return 'self'\n if (self === false || self === 'false') return 'children'\n return Array.isArray(nodes) ? 'children' : 'self'\n}\n\n/**\n * One record's values put into `{{item.field}}` tokens, anywhere in a props\n * map — the substitution {@link expandRepeatables} makes on every copy it\n * builds, for a caller that already knows which record it is rendering.\n *\n * The besigner's canvas is that caller (AGL-3111): the element an author\n * edits IS the first copy, so it draws with the first record's values while\n * the node it saves keeps the tokens. Exported rather than reimplemented\n * because a canvas that resolved a token differently from the page would be\n * showing the author a page that does not exist.\n */\nexport function substituteRecordTokens<T>(\n props: T,\n context: {\n record: Record<string, unknown>\n /** Model of the repeated rows; reference hops need field configs. */\n model?: import('./dataset-models').DatasetModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n },\n): T {\n return substituteValue(props, context) as T\n}\n\n/** The node minus its repeat directives, or the node itself when it has none. */\nexport function withoutRepeatDirective<N>(node: N): N {\n const props = (node as { props?: Record<string, unknown> })?.props\n if (!props || !REPEAT_DIRECTIVE_PROPS.some((key) => key in props)) {\n return node\n }\n const kept = { ...props }\n for (const key of REPEAT_DIRECTIVE_PROPS) delete kept[key]\n return { ...node, props: kept }\n}\n\n/**\n * The records a repeating node renders, in order (AGL-181): its filter and sort\n * evaluated over the rows, then its limit, never past\n * {@link REPEAT_MAX_RECORDS}. An unparseable filter or sort fails open.\n *\n * Shared by the expansion and the besigner's canvas preview and badge, so the\n * canvas can never show a copy or a count the published page does not.\n */\nexport function repeatedRecords(\n node: unknown,\n dataset: RepeatableDataset | undefined,\n): Array<Record<string, unknown>> {\n const props = ((node as { props?: unknown })?.props ?? {}) as Record<\n string,\n unknown\n >\n const where = parseRepeatFilter(String(props['repeatFilter'] ?? ''))\n const orderBy = parseRepeatSort(String(props['repeatSort'] ?? ''))\n const limit = Number(props['repeatLimit'])\n return applyRepeatQuery(dataset?.records ?? [], {\n ...(where ? { where: [where] } : {}),\n ...(orderBy ? { orderBy } : {}),\n }).slice(\n 0,\n Number.isFinite(limit) && limit > 0\n ? Math.min(limit, REPEAT_MAX_RECORDS)\n : REPEAT_MAX_RECORDS,\n )\n}\n\n/**\n * Does this tree contain anything {@link expandRepeatables} would expand?\n *\n * The rows read is the largest on the render path — up to two pages of\n * {@link REPEAT_MAX_RECORDS} records for every key a page repeats over — and a\n * page that repeats over nothing should pay none of it (AGL-1440).\n *\n * Any gate on that read has to ask EXACTLY the question the expansion asks,\n * which is why this and {@link repeatKeys} share the one predicate\n * `expandRepeatables` looks keys up with, {@link repeatKey}. A gate that is\n * even slightly stricter than the expansion is not a saving: it is a published\n * page that quietly renders one template row where the author put a list.\n */\nexport function hasRepeatableNodes(\n nodes: Record<NodeId, unknown> | null | undefined,\n): boolean {\n if (!nodes) return false\n return Object.values(nodes).some((node) => repeatKey(node) !== '')\n}\n\n/**\n * Every key this tree repeats over, exactly as {@link expandRepeatables} looks\n * it up — sorted and without repeats.\n *\n * What the tenant compose asks the repeat-rows contract for\n * (`plugin-manager/repeat-rows.ts`), so a page loads the rows it renders and\n * not everything its site may see. Built on the same predicate as\n * {@link hasRepeatableNodes} for the reason given there.\n */\nexport function repeatKeys(\n nodes: Record<NodeId, unknown> | null | undefined,\n): string[] {\n const keys = new Set<string>()\n for (const node of Object.values(nodes ?? {})) {\n const key = repeatKey(node)\n if (key) keys.add(key)\n }\n return [...keys].sort()\n}\n\n/**\n * Repeats (AGL-103, AGL-3111): any node carrying `props.repeatDataset` (a\n * dataset id or display name) renders once per record of that dataset, with\n * `{{item.field}}` tokens in the copied string props replaced by the record's\n * values (unknown fields keep the literal token, like variable bindings).\n *\n * What is copied is the node's {@link repeatScope}:\n *\n * - `children` — its children are the item template. The node stays, and its\n * child list becomes the copies, record by record. Its own props are not the\n * template, so no token in them is substituted.\n * - `self` — the node and everything inside it are the template. The copies\n * take the node's place in its parent's child list, and every prop in the\n * subtree is substituted, the node's own included.\n *\n * - Copy ids are namespaced `rep__{repeatId}__{index}__…` — the repeat's own\n * id, then the template node's — so repeats never collide, including inside\n * grafted reusable components. A self-scoped node's first copy is\n * `rep__{id}__0__{id}`. Run this AFTER `composeReusableComponentNodes` and\n * BEFORE binding resolution.\n * - Rows are the node's {@link repeatedRecords}.\n * - The repeat directives ({@link REPEAT_DIRECTIVE_PROPS}) are consumed: every\n * node this expands, and every copy it makes, comes out without them.\n * - Repeats do not nest. A repeat inside another repeat's template is copied\n * with its directives consumed, so it renders once in each copy.\n * - An unknown dataset, no matching records, an empty template, or a\n * self-scoped node its parent does not list (the document root, say) leave\n * the node rendering once, as written (fail-open: a deleted dataset must\n * never take a published screen down).\n * - Inputs are never mutated; templates stay in the map unreferenced.\n */\nexport function expandRepeatables<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N>,\n datasetsByKey: Record<string, RepeatableDataset | undefined> | undefined,\n): Record<NodeId, N> {\n if (!datasetsByKey) return nodes\n const repeatIds = Object.entries(nodes).filter(\n ([, node]) => repeatKey(node) !== '',\n )\n if (!repeatIds.length) return nodes\n\n const next: Record<NodeId, N> = { ...nodes }\n for (const [repeatId, repeated] of repeatIds) {\n const dataset = datasetsByKey[repeatKey(repeated)]\n const records = repeatedRecords(repeated, dataset)\n const self = repeatScope(repeated) === 'self'\n // The copies' parent: the node itself, or — for a self-scoped node — the\n // parent whose child list the copies take its place in, read from `next`\n // so two self-scoped siblings both land in the list the other has left.\n const parentId = (self ? repeated.parentId : repeatId) as NodeId\n const siblings = next[parentId]?.nodes\n const slot = Array.isArray(siblings)\n ? (siblings as NodeId[]).indexOf(repeatId)\n : -1\n const templateIds = self\n ? [repeatId]\n : Array.isArray(repeated.nodes)\n ? (repeated.nodes as NodeId[])\n : []\n next[repeatId] = withoutRepeatDirective(next[repeatId])\n if (!records.length || !templateIds.length || (self && slot < 0)) continue\n\n const copyIds: NodeId[] = []\n records.forEach((record, index) => {\n const prefix = `${REPEAT_NODE_ID_PREFIX}${repeatId}__${index}__`\n const prefixId = (id: NodeId) => `${prefix}${id}`\n const cloneSubtree = (id: NodeId, clonedParentId: NodeId) => {\n const node = nodes[id]\n if (!node) return\n const clonedChildren = Array.isArray(node.nodes)\n ? (node.nodes as NodeId[])\n : undefined\n next[prefixId(id)] = withoutRepeatDirective({\n ...node,\n $id: prefixId(id),\n parentId: clonedParentId,\n props: substituteValue(node.props ?? {}, {\n record,\n model: dataset?.model,\n datasetsByKey,\n }) as any,\n ...(clonedChildren && {\n nodes: clonedChildren.map((childId) => prefixId(childId)),\n }),\n })\n clonedChildren?.forEach((childId) =>\n cloneSubtree(childId, prefixId(id)),\n )\n }\n for (const templateId of templateIds) {\n cloneSubtree(templateId, parentId)\n copyIds.push(prefixId(templateId))\n }\n })\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1, ...copyIds)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: copyIds }\n }\n }\n return next\n}\n\nexport default expandRepeatables\n"],"names":["applyRepeatQuery","parseRepeatFilter","parseRepeatSort","REPEAT_NODE_ID_PREFIX","ITEM_TOKEN_PATTERN","REPEAT_MAX_RECORDS","displayValue","value","Array","isArray","join","String","resolveReferenceHop","context","fieldId","targetFieldId","field","model","fields","type","reference","datasetId","target","datasetsByKey","keys","record","ids","resolved","map","id","match","records","find","row","filter","length","substituteValue","replace","token","hop","entry","next","key","Object","entries","repeatKey","node","props","repeatDataset","trim","REPEAT_SELF_PROP","REPEAT_DIRECTIVE_PROPS","repeatScope","nodes","self","substituteRecordTokens","withoutRepeatDirective","some","kept","repeatedRecords","dataset","where","orderBy","limit","Number","slice","isFinite","Math","min","hasRepeatableNodes","values","repeatKeys","Set","add","sort","expandRepeatables","repeatIds","repeatId","repeated","parentId","siblings","slot","indexOf","templateIds","copyIds","forEach","index","prefix","prefixId","cloneSubtree","clonedParentId","clonedChildren","undefined","$id","childId","templateId","push","listed","splice"],"mappings":"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"}
@@ -29,7 +29,7 @@
29
29
  * Everything that needs Firestore or `node:crypto` lives at the call sites.
30
30
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
31
31
  import { FORMS_PLUGIN_ID } from "../plugin-manager/enabled-plugins.js";
32
- import { campaignMembershipValue } from "./campaign-membership.js";
32
+ import { containerMembershipValue } from "./container-membership.js";
33
33
  import { submissionMonthKey } from "./form-abuse-ceiling.js";
34
34
  import { displayNameSearchFields, nameSearchTokens } from "./name-search.js";
35
35
  /**
@@ -168,7 +168,7 @@ export const FORM_FIELD_COMPONENT_ID = 'formField';
168
168
  * refuses one. The campaign deletion pass removes an id with `arrayRemove`
169
169
  * and restamps `inCampaign` from the form as it then stands.
170
170
  */ export function formCampaignFields(selected) {
171
- const campaignIds = campaignMembershipValue(Array.isArray(selected) ? selected : []);
171
+ const campaignIds = containerMembershipValue(Array.isArray(selected) ? selected : []);
172
172
  return {
173
173
  campaignIds,
174
174
  inCampaign: campaignIds.length > 0