@blamejs/blamejs-shop 0.0.72 → 0.0.75

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 (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,1020 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorHelpCenter
4
+ * @title Operator help center — in-admin help articles indexed by
5
+ * admin-console section
6
+ *
7
+ * @intro
8
+ * `knowledgeBase` is the customer-facing FAQ; this primitive is the
9
+ * operator-facing equivalent. Articles answer "how do I issue a
10
+ * refund?", "where do I edit shipping zones?", "what does the
11
+ * approvals queue do?". Each article is indexed by the admin-console
12
+ * `section` it documents so the help drawer on any screen can
13
+ * surface articles relevant to that screen via
14
+ * `articlesForSection({ section, role })`.
15
+ *
16
+ * Visibility is gated by `audience_roles` — a closed allow-list of
17
+ * `operatorRoles` permission tokens. An article is visible to an
18
+ * operator iff at least one of its `audience_roles` matches a
19
+ * permission token the operator's roles grant (empty `audience_roles`
20
+ * = visible to every operator). The allow-list is closed at THIS
21
+ * primitive layer — `defineArticle` refuses any token outside
22
+ * `operatorRoles.PERMISSIONS` so a typo doesn't silently produce an
23
+ * article that nobody can see.
24
+ *
25
+ * Body is authored in the in-process Markdown subset shared with
26
+ * knowledgeBase / cmsBlocks / storefrontPages. Every text run is
27
+ * HTML-escaped via `b.template.escapeHtml`; every link URL passes
28
+ * through `b.safeUrl.parse` (https-only) OR an allow-list for
29
+ * `/`-rooted absolute paths. Raw HTML in the body never reaches the
30
+ * rendered output. The raw body lives in storage; the rendered HTML
31
+ * is computed on demand at read time.
32
+ *
33
+ * `searchSuggest` ranks candidates with three weighted signals
34
+ * (mirrors knowledgeBase): title-token match (weight 3), section-
35
+ * token match (weight 2), body-token match (weight 1). Token
36
+ * equality is case-insensitive ASCII; tokens come from a non-
37
+ * alphanum split of the query after lower-casing. Archived
38
+ * articles never appear in the ranked output. When `role` is
39
+ * supplied, articles whose `audience_roles` exclude that
40
+ * permission are also filtered.
41
+ *
42
+ * `recordHelpfulVote` is deduped at the (slug, operator_id) UNIQUE
43
+ * — a repeat vote from the same operator collapses to a no-op so
44
+ * the aggregate counters reflect distinct operators rather than
45
+ * refresh-loop noise.
46
+ *
47
+ * Composes:
48
+ * - `b.template.escapeHtml` — render-time text escape
49
+ * - `b.safeUrl.parse` — render-time link gate (https://)
50
+ * - `b.guardUuid` — strict UUID gate on every
51
+ * `operator_id`
52
+ * - `b.uuid.v7` — row ids on votes + views
53
+ * - `b.pagination` — HMAC-tagged tuple cursor for
54
+ * articlesForSection
55
+ * - `shop.operatorRoles` — PERMISSIONS allow-list (closed at
56
+ * this layer)
57
+ *
58
+ * Monotonic per-process clock: two writes in the same millisecond
59
+ * would tie on `updated_at` / `occurred_at` and make a sort-by-
60
+ * timestamp read ambiguous. `_now` bumps to `prior + 1` on
61
+ * collision so the (updated_at DESC, slug DESC) articlesForSection
62
+ * cursor + popularArticles aggregation carry a strict per-process
63
+ * ordering.
64
+ *
65
+ * Surface:
66
+ * - defineArticle({ slug, title, body, section, related_actions?,
67
+ * audience_roles? })
68
+ * - getArticle({ slug })
69
+ * - articlesForSection({ section, role?, cursor?, limit? })
70
+ * - searchSuggest({ query, role?, limit? })
71
+ * - recordView({ slug, operator_id })
72
+ * - recordHelpfulVote({ slug, operator_id, vote })
73
+ * - popularArticles({ from, to, role?, limit? })
74
+ * - archiveArticle(slug)
75
+ * - updateArticle(slug, patch)
76
+ * - listSections()
77
+ *
78
+ * Storage:
79
+ * - operator_help_articles, operator_help_views,
80
+ * operator_help_votes (migration `0200_operator_help_center.sql`).
81
+ *
82
+ * @primitive operatorHelpCenter
83
+ * @related b.template.escapeHtml, b.safeUrl, b.guardUuid, b.uuid.v7,
84
+ * b.pagination, shop.operatorRoles, shop.knowledgeBase
85
+ */
86
+
87
+ var operatorRoles = require("./operator-roles");
88
+
89
+ var MAX_SLUG_LEN = 120;
90
+ var MAX_TITLE_LEN = 200;
91
+ var MAX_BODY_LEN = 32000;
92
+ var MAX_SECTION_LEN = 80;
93
+ var MAX_ACTION_LEN = 200;
94
+ var MAX_ACTION_COUNT = 24;
95
+ var MAX_AUDIENCE_COUNT = 24;
96
+ var MAX_QUERY_LEN = 400;
97
+ var MAX_LIST_LIMIT = 100;
98
+ var DEFAULT_LIST_LIMIT = 25;
99
+ var MAX_POPULAR_LIMIT = 100;
100
+ var DEFAULT_POPULAR_LIMIT = 10;
101
+ var MAX_SUGGEST_LIMIT = 25;
102
+ var DEFAULT_SUGGEST_LIMIT = 5;
103
+
104
+ var ALLOWED_VOTES = ["helpful", "not_helpful"];
105
+
106
+ var LIST_ORDER_KEY = ["updated_at:desc", "slug:desc"];
107
+
108
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
109
+ var SECTION_RE = /^[a-z][a-z0-9_-]*$/;
110
+ var ACTION_RE = /^[a-z0-9](?:[a-z0-9._/:-]*[a-z0-9])?$/;
111
+
112
+ var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
113
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
114
+ var ZERO_WIDTH_RE = new RegExp(
115
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
116
+ );
117
+
118
+ var SEARCH_WEIGHTS = Object.freeze({
119
+ title: 3,
120
+ section: 2,
121
+ body: 1,
122
+ });
123
+
124
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
125
+ "title", "body", "section", "related_actions", "audience_roles",
126
+ ]);
127
+
128
+ var bShop;
129
+ function _b() {
130
+ if (!bShop) bShop = require("./index");
131
+ return bShop.framework;
132
+ }
133
+
134
+ // ---- monotonic clock ---------------------------------------------------
135
+ //
136
+ // Operator-driven writes can land in the same millisecond on fast
137
+ // machines. Bumping by 1ms on a tie keeps the timeline strictly
138
+ // increasing so a sort-by-timestamp read returns events in the
139
+ // order they were issued.
140
+
141
+ var _lastTs = 0;
142
+ function _now() {
143
+ var t = Date.now();
144
+ if (t <= _lastTs) t = _lastTs + 1;
145
+ _lastTs = t;
146
+ return t;
147
+ }
148
+
149
+ // ---- validators --------------------------------------------------------
150
+
151
+ function _slug(s) {
152
+ if (typeof s !== "string" || !s.length) {
153
+ throw new TypeError("operatorHelpCenter: slug must be a non-empty string");
154
+ }
155
+ if (s.length > MAX_SLUG_LEN) {
156
+ throw new TypeError("operatorHelpCenter: slug must be <= " + MAX_SLUG_LEN + " characters");
157
+ }
158
+ if (!SLUG_RE.test(s)) {
159
+ throw new TypeError("operatorHelpCenter: slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
160
+ }
161
+ return s;
162
+ }
163
+
164
+ function _title(s) {
165
+ if (typeof s !== "string") {
166
+ throw new TypeError("operatorHelpCenter: title must be a string");
167
+ }
168
+ var trimmed = s.trim();
169
+ if (!trimmed.length) {
170
+ throw new TypeError("operatorHelpCenter: title must be non-empty after trim");
171
+ }
172
+ if (s.length > MAX_TITLE_LEN) {
173
+ throw new TypeError("operatorHelpCenter: title must be <= " + MAX_TITLE_LEN + " characters");
174
+ }
175
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
176
+ throw new TypeError("operatorHelpCenter: title contains control bytes");
177
+ }
178
+ if (ZERO_WIDTH_RE.test(s)) {
179
+ throw new TypeError("operatorHelpCenter: title contains zero-width / direction-override characters");
180
+ }
181
+ return s;
182
+ }
183
+
184
+ function _body(s) {
185
+ if (typeof s !== "string") {
186
+ throw new TypeError("operatorHelpCenter: body must be a string");
187
+ }
188
+ var trimmed = s.trim();
189
+ if (!trimmed.length) {
190
+ throw new TypeError("operatorHelpCenter: body must be non-empty after trim");
191
+ }
192
+ if (s.length > MAX_BODY_LEN) {
193
+ throw new TypeError("operatorHelpCenter: body must be <= " + MAX_BODY_LEN + " characters");
194
+ }
195
+ if (CONTROL_BYTE_BODY_RE.test(s)) {
196
+ throw new TypeError("operatorHelpCenter: body contains control bytes");
197
+ }
198
+ if (ZERO_WIDTH_RE.test(s)) {
199
+ throw new TypeError("operatorHelpCenter: body contains zero-width / direction-override characters");
200
+ }
201
+ return s;
202
+ }
203
+
204
+ function _section(s) {
205
+ if (typeof s !== "string" || !s.length) {
206
+ throw new TypeError("operatorHelpCenter: section must be a non-empty string");
207
+ }
208
+ if (s.length > MAX_SECTION_LEN) {
209
+ throw new TypeError("operatorHelpCenter: section must be <= " + MAX_SECTION_LEN + " characters");
210
+ }
211
+ if (!SECTION_RE.test(s)) {
212
+ throw new TypeError("operatorHelpCenter: section must match /^[a-z][a-z0-9_-]*$/");
213
+ }
214
+ return s;
215
+ }
216
+
217
+ function _relatedActions(input) {
218
+ if (input == null) return [];
219
+ if (!Array.isArray(input)) {
220
+ throw new TypeError("operatorHelpCenter: related_actions must be an array of strings");
221
+ }
222
+ if (input.length > MAX_ACTION_COUNT) {
223
+ throw new TypeError("operatorHelpCenter: related_actions must contain <= " +
224
+ MAX_ACTION_COUNT + " entries");
225
+ }
226
+ var seen = {};
227
+ var out = [];
228
+ for (var i = 0; i < input.length; i += 1) {
229
+ var a = input[i];
230
+ if (typeof a !== "string" || !a.length) {
231
+ throw new TypeError("operatorHelpCenter: related_actions[" + i + "] must be a non-empty string");
232
+ }
233
+ if (a.length > MAX_ACTION_LEN) {
234
+ throw new TypeError("operatorHelpCenter: related_actions[" + i + "] must be <= " +
235
+ MAX_ACTION_LEN + " characters");
236
+ }
237
+ if (!ACTION_RE.test(a)) {
238
+ throw new TypeError("operatorHelpCenter: related_actions[" + i +
239
+ "] must match /^[a-z0-9](?:[a-z0-9._/:-]*[a-z0-9])?$/");
240
+ }
241
+ if (seen[a]) continue;
242
+ seen[a] = 1;
243
+ out.push(a);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ function _audienceRoles(input) {
249
+ if (input == null) return [];
250
+ if (!Array.isArray(input)) {
251
+ throw new TypeError("operatorHelpCenter: audience_roles must be an array of permission tokens");
252
+ }
253
+ if (input.length > MAX_AUDIENCE_COUNT) {
254
+ throw new TypeError("operatorHelpCenter: audience_roles must contain <= " +
255
+ MAX_AUDIENCE_COUNT + " entries");
256
+ }
257
+ var seen = {};
258
+ var out = [];
259
+ for (var i = 0; i < input.length; i += 1) {
260
+ var p = input[i];
261
+ if (typeof p !== "string" || !p.length) {
262
+ throw new TypeError("operatorHelpCenter: audience_roles[" + i + "] must be a non-empty string");
263
+ }
264
+ if (operatorRoles.PERMISSIONS.indexOf(p) === -1) {
265
+ throw new TypeError("operatorHelpCenter: audience_roles[" + i + "] " +
266
+ JSON.stringify(p) + " is not in the operatorRoles permission allow-list");
267
+ }
268
+ if (seen[p]) continue;
269
+ seen[p] = 1;
270
+ out.push(p);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function _vote(s) {
276
+ if (typeof s !== "string" || ALLOWED_VOTES.indexOf(s) === -1) {
277
+ throw new TypeError("operatorHelpCenter: vote must be one of " + ALLOWED_VOTES.join(", "));
278
+ }
279
+ return s;
280
+ }
281
+
282
+ function _limit(n, max, def, label) {
283
+ if (n == null) return def;
284
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
285
+ throw new TypeError("operatorHelpCenter: " + label + " must be an integer 1..." + max);
286
+ }
287
+ return n;
288
+ }
289
+
290
+ function _timestampRange(from, to, label) {
291
+ if (!Number.isInteger(from) || from < 0) {
292
+ throw new TypeError("operatorHelpCenter." + label +
293
+ ": from must be a non-negative integer (ms epoch)");
294
+ }
295
+ if (!Number.isInteger(to) || to < 0) {
296
+ throw new TypeError("operatorHelpCenter." + label +
297
+ ": to must be a non-negative integer (ms epoch)");
298
+ }
299
+ if (from > to) {
300
+ throw new TypeError("operatorHelpCenter." + label + ": from must be <= to");
301
+ }
302
+ }
303
+
304
+ function _queryStr(s) {
305
+ if (typeof s !== "string") {
306
+ throw new TypeError("operatorHelpCenter: query must be a string");
307
+ }
308
+ if (s.length > MAX_QUERY_LEN) {
309
+ throw new TypeError("operatorHelpCenter: query must be <= " + MAX_QUERY_LEN + " characters");
310
+ }
311
+ if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
312
+ throw new TypeError("operatorHelpCenter: query contains control / zero-width bytes");
313
+ }
314
+ return s;
315
+ }
316
+
317
+ function _operatorId(s) {
318
+ try {
319
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
320
+ } catch (e) {
321
+ throw new TypeError("operatorHelpCenter: operator_id — " + (e && e.message || "invalid UUID"));
322
+ }
323
+ }
324
+
325
+ function _role(s) {
326
+ if (typeof s !== "string" || !s.length) {
327
+ throw new TypeError("operatorHelpCenter: role must be a non-empty permission token");
328
+ }
329
+ if (operatorRoles.PERMISSIONS.indexOf(s) === -1) {
330
+ throw new TypeError("operatorHelpCenter: role " + JSON.stringify(s) +
331
+ " is not in the operatorRoles permission allow-list");
332
+ }
333
+ return s;
334
+ }
335
+
336
+ // ---- Markdown to HTML --------------------------------------------------
337
+ //
338
+ // Minimal in-process Markdown subset mirroring knowledgeBase /
339
+ // cmsBlocks / storefrontPages: paragraphs, headings, lists, links,
340
+ // inline code, emphasis, blockquotes, horizontal rules. Every text
341
+ // run is HTML-escaped via b.template.escapeHtml. Every link URL
342
+ // passes through b.safeUrl.parse (https-only) OR an allow-list for
343
+ // "/"-rooted absolute paths. Any URL that fails the gate is dropped
344
+ // from the rendered HTML; the anchor text falls back to inert
345
+ // escaped text. Raw HTML in the body is never passed through.
346
+
347
+ function _esc(s) {
348
+ return _b().template.escapeHtml(s);
349
+ }
350
+
351
+ function _safeLinkUrl(url) {
352
+ if (typeof url !== "string" || !url.length || url.length > 2048) return null;
353
+ if (CONTROL_BYTE_BODY_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
354
+ if (url.charCodeAt(0) === 47 /* "/" */) {
355
+ if (url.length > 1 && url.charCodeAt(1) === 47) return null;
356
+ if (url.indexOf("..") !== -1) return null;
357
+ return url;
358
+ }
359
+ try {
360
+ _b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
361
+ } catch (_e) {
362
+ return null;
363
+ }
364
+ return url;
365
+ }
366
+
367
+ function _renderInline(line) {
368
+ var out = "";
369
+ var i = 0;
370
+ while (i < line.length) {
371
+ var ch = line.charAt(i);
372
+ if (ch === "`") {
373
+ var end = line.indexOf("`", i + 1);
374
+ if (end !== -1) {
375
+ out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
376
+ i = end + 1;
377
+ continue;
378
+ }
379
+ }
380
+ if (ch === "[") {
381
+ var closeBracket = line.indexOf("]", i + 1);
382
+ if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
383
+ var closeParen = line.indexOf(")", closeBracket + 2);
384
+ if (closeParen !== -1) {
385
+ var text = line.slice(i + 1, closeBracket);
386
+ var url = line.slice(closeBracket + 2, closeParen);
387
+ var safe = _safeLinkUrl(url);
388
+ if (safe) {
389
+ out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
390
+ } else {
391
+ out += _renderInline(text);
392
+ }
393
+ i = closeParen + 1;
394
+ continue;
395
+ }
396
+ }
397
+ }
398
+ if (ch === "*" && line.charAt(i + 1) === "*") {
399
+ var endBold = line.indexOf("**", i + 2);
400
+ if (endBold !== -1) {
401
+ out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
402
+ i = endBold + 2;
403
+ continue;
404
+ }
405
+ }
406
+ if (ch === "*" || ch === "_") {
407
+ var endItalic = line.indexOf(ch, i + 1);
408
+ if (endItalic !== -1 && endItalic !== i + 1) {
409
+ out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
410
+ i = endItalic + 1;
411
+ continue;
412
+ }
413
+ }
414
+ out += _esc(ch);
415
+ i += 1;
416
+ }
417
+ return out;
418
+ }
419
+
420
+ function _renderMarkdown(body) {
421
+ var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
422
+ var lines = normalized.split("\n");
423
+ var out = [];
424
+ var i = 0;
425
+ while (i < lines.length) {
426
+ var line = lines[i];
427
+ if (line.trim() === "") { i += 1; continue; }
428
+ if (/^-{3,}\s*$/.test(line)) {
429
+ out.push("<hr />");
430
+ i += 1;
431
+ continue;
432
+ }
433
+ var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
434
+ if (hMatch) {
435
+ var level = hMatch[1].length;
436
+ out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
437
+ i += 1;
438
+ continue;
439
+ }
440
+ if (/^>\s?/.test(line)) {
441
+ var quoteLines = [];
442
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
443
+ quoteLines.push(lines[i].replace(/^>\s?/, ""));
444
+ i += 1;
445
+ }
446
+ out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
447
+ continue;
448
+ }
449
+ if (/^[-*]\s+/.test(line)) {
450
+ var ulItems = [];
451
+ while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
452
+ ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
453
+ i += 1;
454
+ }
455
+ var ulHtml = ulItems.map(function (item) {
456
+ return "<li>" + _renderInline(item) + "</li>";
457
+ }).join("");
458
+ out.push("<ul>" + ulHtml + "</ul>");
459
+ continue;
460
+ }
461
+ if (/^\d+\.\s+/.test(line)) {
462
+ var olItems = [];
463
+ while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
464
+ olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
465
+ i += 1;
466
+ }
467
+ var olHtml = olItems.map(function (item) {
468
+ return "<li>" + _renderInline(item) + "</li>";
469
+ }).join("");
470
+ out.push("<ol>" + olHtml + "</ol>");
471
+ continue;
472
+ }
473
+ var paraLines = [line];
474
+ i += 1;
475
+ while (
476
+ i < lines.length &&
477
+ lines[i].trim() !== "" &&
478
+ !/^#{1,6}\s+/.test(lines[i]) &&
479
+ !/^[-*]\s+/.test(lines[i]) &&
480
+ !/^\d+\.\s+/.test(lines[i]) &&
481
+ !/^>\s?/.test(lines[i]) &&
482
+ !/^-{3,}\s*$/.test(lines[i])
483
+ ) {
484
+ paraLines.push(lines[i]);
485
+ i += 1;
486
+ }
487
+ out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
488
+ }
489
+ return out.join("\n");
490
+ }
491
+
492
+ // ---- hydration ---------------------------------------------------------
493
+
494
+ function _safeParseArray(json) {
495
+ try {
496
+ var v = JSON.parse(json || "[]");
497
+ return Array.isArray(v) ? v : [];
498
+ } catch (_e) {
499
+ return [];
500
+ }
501
+ }
502
+
503
+ function _hydrateRow(row) {
504
+ if (!row) return null;
505
+ return {
506
+ slug: row.slug,
507
+ title: row.title,
508
+ body: row.body,
509
+ section: row.section,
510
+ related_actions: _safeParseArray(row.related_actions_json),
511
+ audience_roles: _safeParseArray(row.audience_roles_json),
512
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
513
+ view_count: Number(row.view_count) || 0,
514
+ helpful_count: Number(row.helpful_count) || 0,
515
+ not_helpful_count: Number(row.not_helpful_count) || 0,
516
+ created_at: Number(row.created_at),
517
+ updated_at: Number(row.updated_at),
518
+ body_html: _renderMarkdown(row.body),
519
+ };
520
+ }
521
+
522
+ // ---- search tokenizer --------------------------------------------------
523
+
524
+ function _tokenize(s) {
525
+ var lower = String(s).toLowerCase();
526
+ var raw = lower.split(/[^a-z0-9]+/);
527
+ var out = [];
528
+ for (var i = 0; i < raw.length; i += 1) {
529
+ if (raw[i].length >= 2) out.push(raw[i]);
530
+ }
531
+ return out;
532
+ }
533
+
534
+ // Audience-roles match: empty list means visible to every operator;
535
+ // otherwise the operator's role-token must be in the list. The
536
+ // caller resolves the operator's effective permission set; this
537
+ // primitive sees ONE token at a time (the most-privileged one the
538
+ // caller chose to filter by).
539
+ function _audienceVisible(audienceRoles, role) {
540
+ if (!audienceRoles || !audienceRoles.length) return true;
541
+ if (role == null) return true;
542
+ return audienceRoles.indexOf(role) !== -1;
543
+ }
544
+
545
+ // ---- factory -----------------------------------------------------------
546
+
547
+ function create(opts) {
548
+ opts = opts || {};
549
+ var query = opts.query;
550
+ if (!query) {
551
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
552
+ }
553
+
554
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
555
+ if (process.env.NODE_ENV === "production") {
556
+ throw new Error("operatorHelpCenter.create: opts.cursorSecret is required in production");
557
+ }
558
+ opts.cursorSecret = "operator-help-center-cursor-secret-dev-only";
559
+ }
560
+ var cursorSecret = opts.cursorSecret;
561
+
562
+ function _decodeCursor(cursor, label) {
563
+ if (cursor == null) return null;
564
+ if (typeof cursor !== "string") {
565
+ throw new TypeError("operatorHelpCenter." + label +
566
+ ": cursor must be an opaque string or null");
567
+ }
568
+ try {
569
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
570
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
571
+ throw new TypeError("operatorHelpCenter." + label + ": cursor orderKey mismatch");
572
+ }
573
+ return state.vals;
574
+ } catch (e) {
575
+ if (e instanceof TypeError) throw e;
576
+ throw new TypeError("operatorHelpCenter." + label + ": cursor — " +
577
+ (e && e.message || "malformed"));
578
+ }
579
+ }
580
+
581
+ async function _readRow(slug) {
582
+ var r = await query(
583
+ "SELECT * FROM operator_help_articles WHERE slug = ?1",
584
+ [slug],
585
+ );
586
+ return r.rows[0] || null;
587
+ }
588
+
589
+ return {
590
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
591
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
592
+ MAX_BODY_LEN: MAX_BODY_LEN,
593
+ MAX_SECTION_LEN: MAX_SECTION_LEN,
594
+ MAX_ACTION_LEN: MAX_ACTION_LEN,
595
+ MAX_ACTION_COUNT: MAX_ACTION_COUNT,
596
+ MAX_AUDIENCE_COUNT: MAX_AUDIENCE_COUNT,
597
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
598
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
599
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
600
+ MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
601
+ ALLOWED_VOTES: ALLOWED_VOTES.slice(),
602
+ SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
603
+ PERMISSIONS: operatorRoles.PERMISSIONS,
604
+
605
+ // Idempotent insert / update of one article. Subsequent calls
606
+ // for the same slug update title / body / section / related
607
+ // actions / audience roles in place. Archived articles are
608
+ // refused — the operator must author under a fresh slug.
609
+ defineArticle: async function (input) {
610
+ if (!input || typeof input !== "object") {
611
+ throw new TypeError("operatorHelpCenter.defineArticle: input object required");
612
+ }
613
+ var slug = _slug(input.slug);
614
+ var title = _title(input.title);
615
+ var body = _body(input.body);
616
+ var section = _section(input.section);
617
+ var relatedActions = _relatedActions(input.related_actions);
618
+ var audienceRoles = _audienceRoles(input.audience_roles);
619
+ var ts = _now();
620
+
621
+ var existing = await _readRow(slug);
622
+ if (existing && existing.archived_at != null) {
623
+ throw new TypeError(
624
+ "operatorHelpCenter.defineArticle: slug '" + slug + "' is archived"
625
+ );
626
+ }
627
+
628
+ if (existing) {
629
+ await query(
630
+ "UPDATE operator_help_articles " +
631
+ "SET title = ?1, body = ?2, section = ?3, related_actions_json = ?4, " +
632
+ " audience_roles_json = ?5, updated_at = ?6 " +
633
+ "WHERE slug = ?7",
634
+ [title, body, section, JSON.stringify(relatedActions),
635
+ JSON.stringify(audienceRoles), ts, slug],
636
+ );
637
+ } else {
638
+ await query(
639
+ "INSERT INTO operator_help_articles " +
640
+ "(slug, title, body, section, related_actions_json, audience_roles_json, " +
641
+ " archived_at, view_count, helpful_count, not_helpful_count, " +
642
+ " created_at, updated_at) " +
643
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, 0, 0, 0, ?7, ?7)",
644
+ [slug, title, body, section, JSON.stringify(relatedActions),
645
+ JSON.stringify(audienceRoles), ts],
646
+ );
647
+ }
648
+ return _hydrateRow(await _readRow(slug));
649
+ },
650
+
651
+ getArticle: async function (input) {
652
+ if (!input || typeof input !== "object") {
653
+ throw new TypeError("operatorHelpCenter.getArticle: input object required");
654
+ }
655
+ var slug = _slug(input.slug);
656
+ var row = await _readRow(slug);
657
+ if (!row) return null;
658
+ if (row.archived_at != null) return null;
659
+ return _hydrateRow(row);
660
+ },
661
+
662
+ // Section-indexed list. Cursor-paginated by (updated_at DESC,
663
+ // slug DESC) so a tampered cursor can't skip past rows the
664
+ // caller isn't supposed to see. `role` (optional) is a single
665
+ // operatorRoles permission token — articles whose audience_roles
666
+ // is non-empty and excludes that token are filtered.
667
+ articlesForSection: async function (input) {
668
+ if (!input || typeof input !== "object") {
669
+ throw new TypeError("operatorHelpCenter.articlesForSection: input object required");
670
+ }
671
+ var section = _section(input.section);
672
+ var role = input.role == null ? null : _role(input.role);
673
+ var limit = _limit(input.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
674
+ var cursorVals = _decodeCursor(input.cursor, "articlesForSection");
675
+
676
+ var where = ["section = ?1", "archived_at IS NULL"];
677
+ var params = [section];
678
+ var idx = 2;
679
+ if (cursorVals) {
680
+ var a = idx;
681
+ var b = idx + 1;
682
+ where.push(
683
+ "(updated_at < ?" + a + " OR " +
684
+ "(updated_at = ?" + a + " AND slug < ?" + b + "))"
685
+ );
686
+ params.push(cursorVals[0], cursorVals[1]);
687
+ idx += 2;
688
+ }
689
+ // Over-fetch by one extra qualifying row so we can detect
690
+ // "more rows beyond this page" without short-paging the cursor.
691
+ // Role filter can drop rows mid-scan, so when a role is
692
+ // supplied we scan a wider window and keep going until we
693
+ // either fill (limit + 1) visible rows or exhaust the window.
694
+ // Cap is bounded — in-admin help corpora are dozens to hundreds
695
+ // of articles, not millions.
696
+ var scanLimit = role == null
697
+ ? limit + 1
698
+ : Math.min((limit + 1) * 4, MAX_LIST_LIMIT * 4);
699
+ params.push(scanLimit);
700
+ var sql = "SELECT * FROM operator_help_articles WHERE " + where.join(" AND ") +
701
+ " ORDER BY updated_at DESC, slug DESC LIMIT ?" + idx;
702
+ var r = await query(sql, params);
703
+
704
+ var visibleRaw = [];
705
+ var hasMore = false;
706
+ for (var i = 0; i < r.rows.length; i += 1) {
707
+ var row = r.rows[i];
708
+ var audience = _safeParseArray(row.audience_roles_json);
709
+ if (_audienceVisible(audience, role)) {
710
+ if (visibleRaw.length >= limit) { hasMore = true; break; }
711
+ visibleRaw.push(row);
712
+ }
713
+ }
714
+ var hydrated = visibleRaw.map(_hydrateRow);
715
+ var nextCursor = null;
716
+ if (hasMore && visibleRaw.length === limit) {
717
+ var last = visibleRaw[visibleRaw.length - 1];
718
+ nextCursor = _b().pagination.encodeCursor({
719
+ orderKey: LIST_ORDER_KEY,
720
+ vals: [Number(last.updated_at), last.slug],
721
+ forward: true,
722
+ }, cursorSecret);
723
+ }
724
+ return { rows: hydrated, next_cursor: nextCursor };
725
+ },
726
+
727
+ // Ranked search-suggest. Tokenizes the query, then scans every
728
+ // non-archived article and computes a weighted score: title-
729
+ // token match (weight 3) + section-token match (weight 2) +
730
+ // body-token match (weight 1). Articles with score 0 are
731
+ // excluded; ties break on slug DESC. When `role` is supplied,
732
+ // articles whose audience_roles excludes it are filtered before
733
+ // scoring.
734
+ searchSuggest: async function (input) {
735
+ if (!input || typeof input !== "object") {
736
+ throw new TypeError("operatorHelpCenter.searchSuggest: input object required");
737
+ }
738
+ var queryString = _queryStr(input.query);
739
+ var tokens = _tokenize(queryString);
740
+ var limit = _limit(input.limit, MAX_SUGGEST_LIMIT, DEFAULT_SUGGEST_LIMIT, "limit");
741
+ var role = input.role == null ? null : _role(input.role);
742
+
743
+ if (!tokens.length) return [];
744
+
745
+ var r = await query(
746
+ "SELECT * FROM operator_help_articles WHERE archived_at IS NULL",
747
+ [],
748
+ );
749
+
750
+ var ranked = [];
751
+ for (var i = 0; i < r.rows.length; i += 1) {
752
+ var row = r.rows[i];
753
+ var audience = _safeParseArray(row.audience_roles_json);
754
+ if (!_audienceVisible(audience, role)) continue;
755
+
756
+ var titleTokens = _tokenize(row.title);
757
+ var sectionTokens = _tokenize(row.section);
758
+ var bodyTokens = _tokenize(row.body);
759
+
760
+ var score = 0;
761
+ for (var t = 0; t < tokens.length; t += 1) {
762
+ var token = tokens[t];
763
+ if (titleTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.title;
764
+ if (sectionTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.section;
765
+ if (bodyTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.body;
766
+ }
767
+ if (score > 0) {
768
+ ranked.push({
769
+ slug: row.slug,
770
+ title: row.title,
771
+ section: row.section,
772
+ score: score,
773
+ });
774
+ }
775
+ }
776
+
777
+ ranked.sort(function (a, b) {
778
+ if (b.score !== a.score) return b.score - a.score;
779
+ if (a.slug < b.slug) return 1;
780
+ if (a.slug > b.slug) return -1;
781
+ return 0;
782
+ });
783
+
784
+ return ranked.slice(0, limit);
785
+ },
786
+
787
+ // Append-only view log. operator_id is a strict UUID. Bumps the
788
+ // view_count counter on the article row so popularArticles can
789
+ // sort by combined traffic without re-aggregating the views
790
+ // table on every read.
791
+ recordView: async function (input) {
792
+ if (!input || typeof input !== "object") {
793
+ throw new TypeError("operatorHelpCenter.recordView: input object required");
794
+ }
795
+ var slug = _slug(input.slug);
796
+ var operatorId = _operatorId(input.operator_id);
797
+ var row = await _readRow(slug);
798
+ if (!row) {
799
+ var err = new Error("operatorHelpCenter.recordView: slug '" + slug + "' not found");
800
+ err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
801
+ throw err;
802
+ }
803
+ if (row.archived_at != null) {
804
+ var aErr = new Error("operatorHelpCenter.recordView: slug '" + slug + "' is archived");
805
+ aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
806
+ throw aErr;
807
+ }
808
+ var ts = _now();
809
+ await query(
810
+ "INSERT INTO operator_help_views (id, slug, operator_id, occurred_at) " +
811
+ "VALUES (?1, ?2, ?3, ?4)",
812
+ [_b().uuid.v7(), slug, operatorId, ts],
813
+ );
814
+ await query(
815
+ "UPDATE operator_help_articles SET view_count = view_count + 1 WHERE slug = ?1",
816
+ [slug],
817
+ );
818
+ return { slug: slug, operator_id: operatorId, occurred_at: ts };
819
+ },
820
+
821
+ // Vote: helpful / not_helpful. Deduped at (slug, operator_id)
822
+ // via the UNIQUE constraint — a repeat vote from the same
823
+ // operator collapses to a no-op. The first vote wins; flipping
824
+ // requires the operator to clear (not exposed by design — the
825
+ // helpfulness signal is "did the article work for you the first
826
+ // time", not "what's your current opinion").
827
+ recordHelpfulVote: async function (input) {
828
+ if (!input || typeof input !== "object") {
829
+ throw new TypeError("operatorHelpCenter.recordHelpfulVote: input object required");
830
+ }
831
+ var slug = _slug(input.slug);
832
+ var operatorId = _operatorId(input.operator_id);
833
+ var vote = _vote(input.vote);
834
+ var row = await _readRow(slug);
835
+ if (!row) {
836
+ var err = new Error("operatorHelpCenter.recordHelpfulVote: slug '" + slug + "' not found");
837
+ err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
838
+ throw err;
839
+ }
840
+ if (row.archived_at != null) {
841
+ var aErr = new Error("operatorHelpCenter.recordHelpfulVote: slug '" + slug + "' is archived");
842
+ aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
843
+ throw aErr;
844
+ }
845
+ var ts = _now();
846
+ var inserted = await query(
847
+ "INSERT OR IGNORE INTO operator_help_votes (id, slug, operator_id, vote, occurred_at) " +
848
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
849
+ [_b().uuid.v7(), slug, operatorId, vote, ts],
850
+ );
851
+ var changed = inserted && (inserted.rowCount === 1 || inserted.changes === 1);
852
+ if (changed) {
853
+ var col = vote === "helpful" ? "helpful_count" : "not_helpful_count";
854
+ await query(
855
+ "UPDATE operator_help_articles SET " + col + " = " + col + " + 1 WHERE slug = ?1",
856
+ [slug],
857
+ );
858
+ }
859
+ return { slug: slug, operator_id: operatorId, vote: vote, recorded: !!changed };
860
+ },
861
+
862
+ // Top-N most-viewed articles over a closed [from, to] window.
863
+ // Views are counted from operator_help_views.occurred_at.
864
+ // Archived articles are filtered. When `role` is supplied,
865
+ // articles whose audience_roles excludes that token are also
866
+ // filtered. Sorted by view count DESC, slug DESC.
867
+ popularArticles: async function (input) {
868
+ if (!input || typeof input !== "object") {
869
+ throw new TypeError("operatorHelpCenter.popularArticles: input object required");
870
+ }
871
+ var from = input.from;
872
+ var to = input.to;
873
+ _timestampRange(from, to, "popularArticles");
874
+ var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
875
+ var role = input.role == null ? null : _role(input.role);
876
+
877
+ var sql =
878
+ "SELECT v.slug AS slug, a.audience_roles_json AS audience_roles_json, " +
879
+ " COUNT(*) AS views " +
880
+ "FROM operator_help_views v " +
881
+ "JOIN operator_help_articles a ON a.slug = v.slug " +
882
+ "WHERE v.occurred_at >= ?1 AND v.occurred_at <= ?2 " +
883
+ " AND a.archived_at IS NULL " +
884
+ "GROUP BY v.slug " +
885
+ "ORDER BY views DESC, v.slug DESC";
886
+ var r = await query(sql, [from, to]);
887
+ var out = [];
888
+ for (var i = 0; i < r.rows.length; i += 1) {
889
+ var row = r.rows[i];
890
+ var audience = _safeParseArray(row.audience_roles_json);
891
+ if (!_audienceVisible(audience, role)) continue;
892
+ out.push({ slug: row.slug, views: Number(row.views) });
893
+ if (out.length >= limit) break;
894
+ }
895
+ return out;
896
+ },
897
+
898
+ // Archive — sets archived_at tombstone. Archived articles are
899
+ // hidden from every read surface (getArticle, articlesForSection,
900
+ // searchSuggest, popularArticles, recordView, recordHelpfulVote).
901
+ // Terminal in v1 — no de-archive surface; an operator who
902
+ // archives by mistake re-authors under a fresh slug. Operator
903
+ // demand for un-archive can be added later via a separate
904
+ // surface; the storage column is in place.
905
+ archiveArticle: async function (slug) {
906
+ slug = _slug(slug);
907
+ var existing = await _readRow(slug);
908
+ if (!existing) {
909
+ var err = new Error("operatorHelpCenter.archiveArticle: slug '" + slug + "' not found");
910
+ err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
911
+ throw err;
912
+ }
913
+ var ts = _now();
914
+ await query(
915
+ "UPDATE operator_help_articles SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
916
+ [ts, slug],
917
+ );
918
+ return _hydrateRow(await _readRow(slug));
919
+ },
920
+
921
+ // Patch any subset of { title, body, section, related_actions,
922
+ // audience_roles }. Slug is immutable. Refuses on archived rows
923
+ // — operator must re-author under a fresh slug.
924
+ updateArticle: async function (slug, patch) {
925
+ slug = _slug(slug);
926
+ if (!patch || typeof patch !== "object") {
927
+ throw new TypeError("operatorHelpCenter.updateArticle: patch object required");
928
+ }
929
+ var existing = await _readRow(slug);
930
+ if (!existing) {
931
+ var err = new Error("operatorHelpCenter.updateArticle: slug '" + slug + "' not found");
932
+ err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
933
+ throw err;
934
+ }
935
+ if (existing.archived_at != null) {
936
+ var aErr = new Error("operatorHelpCenter.updateArticle: slug '" + slug + "' is archived");
937
+ aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
938
+ throw aErr;
939
+ }
940
+
941
+ // Reject any key outside the allow-list so a typo'd field
942
+ // doesn't silently no-op.
943
+ var patchKeys = Object.keys(patch);
944
+ for (var k = 0; k < patchKeys.length; k += 1) {
945
+ if (ALLOWED_PATCH_COLUMNS.indexOf(patchKeys[k]) === -1) {
946
+ throw new TypeError(
947
+ "operatorHelpCenter.updateArticle: patch key " +
948
+ JSON.stringify(patchKeys[k]) +
949
+ " is not in the allow-list (" + ALLOWED_PATCH_COLUMNS.join(", ") + ")"
950
+ );
951
+ }
952
+ }
953
+
954
+ var sets = [];
955
+ var params = [];
956
+ var idx = 1;
957
+
958
+ if (patch.title !== undefined) {
959
+ sets.push("title = ?" + idx); params.push(_title(patch.title)); idx += 1;
960
+ }
961
+ if (patch.body !== undefined) {
962
+ sets.push("body = ?" + idx); params.push(_body(patch.body)); idx += 1;
963
+ }
964
+ if (patch.section !== undefined) {
965
+ sets.push("section = ?" + idx); params.push(_section(patch.section)); idx += 1;
966
+ }
967
+ if (patch.related_actions !== undefined) {
968
+ sets.push("related_actions_json = ?" + idx);
969
+ params.push(JSON.stringify(_relatedActions(patch.related_actions))); idx += 1;
970
+ }
971
+ if (patch.audience_roles !== undefined) {
972
+ sets.push("audience_roles_json = ?" + idx);
973
+ params.push(JSON.stringify(_audienceRoles(patch.audience_roles))); idx += 1;
974
+ }
975
+
976
+ var ts = _now();
977
+ sets.push("updated_at = ?" + idx); params.push(ts); idx += 1;
978
+ params.push(slug);
979
+
980
+ await query(
981
+ "UPDATE operator_help_articles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
982
+ params,
983
+ );
984
+ return _hydrateRow(await _readRow(slug));
985
+ },
986
+
987
+ // Distinct sections currently in use (excluding archived rows).
988
+ // Drives the help-drawer section picker so operators see only
989
+ // sections that actually have articles.
990
+ listSections: async function () {
991
+ var r = await query(
992
+ "SELECT DISTINCT section FROM operator_help_articles " +
993
+ "WHERE archived_at IS NULL ORDER BY section ASC",
994
+ [],
995
+ );
996
+ var out = [];
997
+ for (var i = 0; i < r.rows.length; i += 1) {
998
+ out.push(r.rows[i].section);
999
+ }
1000
+ return out;
1001
+ },
1002
+ };
1003
+ }
1004
+
1005
+ module.exports = {
1006
+ create: create,
1007
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
1008
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
1009
+ MAX_BODY_LEN: MAX_BODY_LEN,
1010
+ MAX_SECTION_LEN: MAX_SECTION_LEN,
1011
+ MAX_ACTION_LEN: MAX_ACTION_LEN,
1012
+ MAX_ACTION_COUNT: MAX_ACTION_COUNT,
1013
+ MAX_AUDIENCE_COUNT: MAX_AUDIENCE_COUNT,
1014
+ MAX_QUERY_LEN: MAX_QUERY_LEN,
1015
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
1016
+ MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
1017
+ MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
1018
+ ALLOWED_VOTES: ALLOWED_VOTES.slice(),
1019
+ SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
1020
+ };