@aglyn/aglyn 1.0.0-beta.199 → 1.0.0-beta.200

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 (42) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/activity-search.d.ts +39 -0
  3. package/src/lib/app-utils/activity-search.js +85 -0
  4. package/src/lib/app-utils/activity-search.js.map +1 -0
  5. package/src/lib/app-utils/admin-audit-index.d.ts +104 -0
  6. package/src/lib/app-utils/admin-audit-index.js +177 -0
  7. package/src/lib/app-utils/admin-audit-index.js.map +1 -0
  8. package/src/lib/app-utils/artifact-list-keys.d.ts +51 -0
  9. package/src/lib/app-utils/artifact-list-keys.js +144 -0
  10. package/src/lib/app-utils/artifact-list-keys.js.map +1 -0
  11. package/src/lib/app-utils/content-query-fields.d.ts +69 -0
  12. package/src/lib/app-utils/content-query-fields.js +79 -0
  13. package/src/lib/app-utils/content-query-fields.js.map +1 -0
  14. package/src/lib/app-utils/crm.d.ts +151 -1
  15. package/src/lib/app-utils/crm.js +388 -1
  16. package/src/lib/app-utils/crm.js.map +1 -1
  17. package/src/lib/app-utils/dataset-models.d.ts +75 -24
  18. package/src/lib/app-utils/dataset-models.js +107 -40
  19. package/src/lib/app-utils/dataset-models.js.map +1 -1
  20. package/src/lib/app-utils/docs-index.generated.js +40 -40
  21. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  22. package/src/lib/app-utils/email-search.d.ts +36 -0
  23. package/src/lib/app-utils/email-search.js +53 -0
  24. package/src/lib/app-utils/email-search.js.map +1 -0
  25. package/src/lib/app-utils/forms.d.ts +83 -3
  26. package/src/lib/app-utils/forms.js +67 -0
  27. package/src/lib/app-utils/forms.js.map +1 -1
  28. package/src/lib/app-utils/marketplace-listing-visibility.d.ts +7 -6
  29. package/src/lib/app-utils/marketplace-listing-visibility.js +12 -10
  30. package/src/lib/app-utils/marketplace-listing-visibility.js.map +1 -1
  31. package/src/lib/app-utils/media-metadata.d.ts +79 -0
  32. package/src/lib/app-utils/media-metadata.js +74 -0
  33. package/src/lib/app-utils/media-metadata.js.map +1 -1
  34. package/src/lib/app-utils/message-search.d.ts +40 -0
  35. package/src/lib/app-utils/message-search.js +168 -0
  36. package/src/lib/app-utils/message-search.js.map +1 -0
  37. package/src/lib/app-utils/name-search.d.ts +83 -0
  38. package/src/lib/app-utils/name-search.js +97 -0
  39. package/src/lib/app-utils/name-search.js.map +1 -1
  40. package/src/lib/app-utils/server.d.ts +2 -0
  41. package/src/lib/app-utils/server.js +2 -0
  42. package/src/lib/app-utils/server.js.map +1 -1
@@ -0,0 +1,168 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from "./name-search.js";
18
+ /*
19
+ * THE SEARCH KEYS OF A MESSAGE A VISITOR SENT (AGL-3321).
20
+ *
21
+ * A message is a free-form map of the fields its author declared — `name`,
22
+ * `Email Address`, `message`, `Company size` — with no guaranteed shape. A
23
+ * list of them is searched on its Firestore query, which cannot look inside
24
+ * a string, so every writer stamps the words as word-prefix token arrays the
25
+ * query asks with `array-contains`:
26
+ *
27
+ * senderTokens the sender's name and address — who wrote in. What the
28
+ * list's From filter asks.
29
+ * searchTokens the sender's tokens, then the words of every value the
30
+ * message carries. What the list's search box asks.
31
+ *
32
+ * The words are the platform's search keys (`nameSearchKey`, prefixes up to
33
+ * `NAME_TOKEN_MAX_PREFIX`), so a typed word becomes the token the query asks
34
+ * through the same `nameSearchNormalizers` every list uses. A word that joins
35
+ * parts with `@ . + _ -` — an address, a domain, a hyphenated name — is also
36
+ * its parts, so `acme` finds `dana@acme.com` and `dana` does too.
37
+ *
38
+ * ## What is capped
39
+ *
40
+ * A message is free text, so the arrays are bounded rather than complete:
41
+ * the first {@link MESSAGE_SEARCH_WORDS_MAX} distinct words of the values
42
+ * contribute, and each array stops at its own ceiling
43
+ * ({@link MESSAGE_SENDER_TOKENS_MAX}, {@link MESSAGE_SEARCH_TOKENS_MAX}) —
44
+ * the sender first, so a long message loses the tail of its text before it
45
+ * loses who sent it. A word past the cap is still in the message; the search
46
+ * box just does not find it.
47
+ *
48
+ * ## Deterministic whatever the map's key order
49
+ *
50
+ * A map's keys come back in a different order from different readers (the
51
+ * Admin SDK sorts them), and a cap makes the result order-sensitive. So the
52
+ * values are read in SORTED key order, and a backfill that re-derives the
53
+ * arrays from a stored message computes exactly what the writer stamped.
54
+ *
55
+ * `tools/scripts/backfill-form-submission-filters.mjs` restates this for the
56
+ * rows written before it, held to `tools/scripts/lib/message-search.fixtures.json`
57
+ * with the spec beside this file.
58
+ */ /** The most tokens the sender contributes. A name and an address are a few dozen. */ export const MESSAGE_SENDER_TOKENS_MAX = 60;
59
+ /** The most tokens one message's search array holds, the sender's included. */ export const MESSAGE_SEARCH_TOKENS_MAX = 200;
60
+ /** The most distinct words of the message's values that contribute prefixes. */ export const MESSAGE_SEARCH_WORDS_MAX = 40;
61
+ /**
62
+ * Field names that mean "this is who wrote in", most specific first,
63
+ * compared against a key REDUCED to lowercase letters and digits — the same
64
+ * field arrives as `Full Name`, `full_name` and `fullname`.
65
+ */ const SENDER_NAME_KEYS = [
66
+ 'name',
67
+ 'fullname',
68
+ 'yourname',
69
+ 'firstname',
70
+ 'contactname'
71
+ ];
72
+ const SENDER_EMAIL_KEYS = [
73
+ 'email',
74
+ 'emailaddress'
75
+ ];
76
+ /** The joins a word is also split on, so an address is also its parts. */ const WORD_JOINS = /[@.+_-]/;
77
+ const WORD_PARTS = /[@.+_-]+/;
78
+ /** Punctuation around a word — `cake?`, `(june)` — is not part of it. */ const WORD_EDGES = RegExp("^[^\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376-\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0560-\\u0588\\u05D0-\\u05EA\\u05EF-\\u05F2\\u0620-\\u064A\\u066E-\\u066F\\u0671-\\u06D3\\u06D5\\u06E5-\\u06E6\\u06EE-\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4-\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u0860-\\u086A\\u0870-\\u0887\\u0889-\\u088F\\u08A0-\\u08C9\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0980\\u0985-\\u098C\\u098F-\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC-\\u09DD\\u09DF-\\u09E1\\u09F0-\\u09F1\\u09FC\\u0A05-\\u0A0A\\u0A0F-\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32-\\u0A33\\u0A35-\\u0A36\\u0A38-\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2-\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0-\\u0AE1\\u0AF9\\u0B05-\\u0B0C\\u0B0F-\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32-\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C-\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99-\\u0B9A\\u0B9C\\u0B9E-\\u0B9F\\u0BA3-\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D\\u0C58-\\u0C5A\\u0C5C-\\u0C5D\\u0C60-\\u0C61\\u0C80\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDC-\\u0CDE\\u0CE0-\\u0CE1\\u0CF1-\\u0CF2\\u0D04-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D54-\\u0D56\\u0D5F-\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32-\\u0E33\\u0E40-\\u0E46\\u0E81-\\u0E82\\u0E84\\u0E86-\\u0E8A\\u0E8C-\\u0EA3\\u0EA5\\u0EA7-\\u0EB0\\u0EB2-\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065-\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F5\\u13F8-\\u13FD\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16F1-\\u16F8\\u1700-\\u1711\\u171F-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1878\\u1880-\\u1884\\u1887-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19B0-\\u19C9\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4C\\u1B83-\\u1BA0\\u1BAE-\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1C80-\\u1C8A\\u1C90-\\u1CBA\\u1CBD-\\u1CBF\\u1CE9-\\u1CEC\\u1CEE-\\u1CF3\\u1CF5-\\u1CF6\\u1CFA\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183-\\u2184\\u2C00-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2-\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3006\\u3031-\\u3035\\u303B-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312F\\u3131-\\u318E\\u31A0-\\u31BF\\u31F0-\\u31FF\\u3400-\\u4DBF\\u4E00-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A-\\uA62B\\uA640-\\uA66E\\uA67F-\\uA69D\\uA6A0-\\uA6E5\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA7DC\\uA7F1-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA8FD-\\uA8FE\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uA9E0-\\uA9E4\\uA9E6-\\uA9EF\\uA9FA-\\uA9FE\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA7E-\\uAAAF\\uAAB1\\uAAB5-\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB69\\uAB70-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40-\\uFB41\\uFB43-\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC\\u{10000}-\\u{1000B}\\u{1000D}-\\u{10026}\\u{10028}-\\u{1003A}\\u{1003C}-\\u{1003D}\\u{1003F}-\\u{1004D}\\u{10050}-\\u{1005D}\\u{10080}-\\u{100FA}\\u{10280}-\\u{1029C}\\u{102A0}-\\u{102D0}\\u{10300}-\\u{1031F}\\u{1032D}-\\u{10340}\\u{10342}-\\u{10349}\\u{10350}-\\u{10375}\\u{10380}-\\u{1039D}\\u{103A0}-\\u{103C3}\\u{103C8}-\\u{103CF}\\u{10400}-\\u{1049D}\\u{104B0}-\\u{104D3}\\u{104D8}-\\u{104FB}\\u{10500}-\\u{10527}\\u{10530}-\\u{10563}\\u{10570}-\\u{1057A}\\u{1057C}-\\u{1058A}\\u{1058C}-\\u{10592}\\u{10594}-\\u{10595}\\u{10597}-\\u{105A1}\\u{105A3}-\\u{105B1}\\u{105B3}-\\u{105B9}\\u{105BB}-\\u{105BC}\\u{105C0}-\\u{105F3}\\u{10600}-\\u{10736}\\u{10740}-\\u{10755}\\u{10760}-\\u{10767}\\u{10780}-\\u{10785}\\u{10787}-\\u{107B0}\\u{107B2}-\\u{107BA}\\u{10800}-\\u{10805}\\u{10808}\\u{1080A}-\\u{10835}\\u{10837}-\\u{10838}\\u{1083C}\\u{1083F}-\\u{10855}\\u{10860}-\\u{10876}\\u{10880}-\\u{1089E}\\u{108E0}-\\u{108F2}\\u{108F4}-\\u{108F5}\\u{10900}-\\u{10915}\\u{10920}-\\u{10939}\\u{10940}-\\u{10959}\\u{10980}-\\u{109B7}\\u{109BE}-\\u{109BF}\\u{10A00}\\u{10A10}-\\u{10A13}\\u{10A15}-\\u{10A17}\\u{10A19}-\\u{10A35}\\u{10A60}-\\u{10A7C}\\u{10A80}-\\u{10A9C}\\u{10AC0}-\\u{10AC7}\\u{10AC9}-\\u{10AE4}\\u{10B00}-\\u{10B35}\\u{10B40}-\\u{10B55}\\u{10B60}-\\u{10B72}\\u{10B80}-\\u{10B91}\\u{10C00}-\\u{10C48}\\u{10C80}-\\u{10CB2}\\u{10CC0}-\\u{10CF2}\\u{10D00}-\\u{10D23}\\u{10D4A}-\\u{10D65}\\u{10D6F}-\\u{10D85}\\u{10E80}-\\u{10EA9}\\u{10EB0}-\\u{10EB1}\\u{10EC2}-\\u{10EC7}\\u{10F00}-\\u{10F1C}\\u{10F27}\\u{10F30}-\\u{10F45}\\u{10F70}-\\u{10F81}\\u{10FB0}-\\u{10FC4}\\u{10FE0}-\\u{10FF6}\\u{11003}-\\u{11037}\\u{11071}-\\u{11072}\\u{11075}\\u{11083}-\\u{110AF}\\u{110D0}-\\u{110E8}\\u{11103}-\\u{11126}\\u{11144}\\u{11147}\\u{11150}-\\u{11172}\\u{11176}\\u{11183}-\\u{111B2}\\u{111C1}-\\u{111C4}\\u{111DA}\\u{111DC}\\u{11200}-\\u{11211}\\u{11213}-\\u{1122B}\\u{1123F}-\\u{11240}\\u{11280}-\\u{11286}\\u{11288}\\u{1128A}-\\u{1128D}\\u{1128F}-\\u{1129D}\\u{1129F}-\\u{112A8}\\u{112B0}-\\u{112DE}\\u{11305}-\\u{1130C}\\u{1130F}-\\u{11310}\\u{11313}-\\u{11328}\\u{1132A}-\\u{11330}\\u{11332}-\\u{11333}\\u{11335}-\\u{11339}\\u{1133D}\\u{11350}\\u{1135D}-\\u{11361}\\u{11380}-\\u{11389}\\u{1138B}\\u{1138E}\\u{11390}-\\u{113B5}\\u{113B7}\\u{113D1}\\u{113D3}\\u{11400}-\\u{11434}\\u{11447}-\\u{1144A}\\u{1145F}-\\u{11461}\\u{11480}-\\u{114AF}\\u{114C4}-\\u{114C5}\\u{114C7}\\u{11580}-\\u{115AE}\\u{115D8}-\\u{115DB}\\u{11600}-\\u{1162F}\\u{11644}\\u{11680}-\\u{116AA}\\u{116B8}\\u{11700}-\\u{1171A}\\u{11740}-\\u{11746}\\u{11800}-\\u{1182B}\\u{118A0}-\\u{118DF}\\u{118FF}-\\u{11906}\\u{11909}\\u{1190C}-\\u{11913}\\u{11915}-\\u{11916}\\u{11918}-\\u{1192F}\\u{1193F}\\u{11941}\\u{119A0}-\\u{119A7}\\u{119AA}-\\u{119D0}\\u{119E1}\\u{119E3}\\u{11A00}\\u{11A0B}-\\u{11A32}\\u{11A3A}\\u{11A50}\\u{11A5C}-\\u{11A89}\\u{11A9D}\\u{11AB0}-\\u{11AF8}\\u{11BC0}-\\u{11BE0}\\u{11C00}-\\u{11C08}\\u{11C0A}-\\u{11C2E}\\u{11C40}\\u{11C72}-\\u{11C8F}\\u{11D00}-\\u{11D06}\\u{11D08}-\\u{11D09}\\u{11D0B}-\\u{11D30}\\u{11D46}\\u{11D60}-\\u{11D65}\\u{11D67}-\\u{11D68}\\u{11D6A}-\\u{11D89}\\u{11D98}\\u{11DB0}-\\u{11DDB}\\u{11EE0}-\\u{11EF2}\\u{11F02}\\u{11F04}-\\u{11F10}\\u{11F12}-\\u{11F33}\\u{11FB0}\\u{12000}-\\u{12399}\\u{12480}-\\u{12543}\\u{12F90}-\\u{12FF0}\\u{13000}-\\u{1342F}\\u{13441}-\\u{13446}\\u{13460}-\\u{143FA}\\u{14400}-\\u{14646}\\u{16100}-\\u{1611D}\\u{16800}-\\u{16A38}\\u{16A40}-\\u{16A5E}\\u{16A70}-\\u{16ABE}\\u{16AD0}-\\u{16AED}\\u{16B00}-\\u{16B2F}\\u{16B40}-\\u{16B43}\\u{16B63}-\\u{16B77}\\u{16B7D}-\\u{16B8F}\\u{16D40}-\\u{16D6C}\\u{16E40}-\\u{16E7F}\\u{16EA0}-\\u{16EB8}\\u{16EBB}-\\u{16ED3}\\u{16F00}-\\u{16F4A}\\u{16F50}\\u{16F93}-\\u{16F9F}\\u{16FE0}-\\u{16FE1}\\u{16FE3}\\u{16FF2}-\\u{16FF3}\\u{17000}-\\u{18CD5}\\u{18CFF}-\\u{18D1E}\\u{18D80}-\\u{18DF2}\\u{1AFF0}-\\u{1AFF3}\\u{1AFF5}-\\u{1AFFB}\\u{1AFFD}-\\u{1AFFE}\\u{1B000}-\\u{1B122}\\u{1B132}\\u{1B150}-\\u{1B152}\\u{1B155}\\u{1B164}-\\u{1B167}\\u{1B170}-\\u{1B2FB}\\u{1BC00}-\\u{1BC6A}\\u{1BC70}-\\u{1BC7C}\\u{1BC80}-\\u{1BC88}\\u{1BC90}-\\u{1BC99}\\u{1D400}-\\u{1D454}\\u{1D456}-\\u{1D49C}\\u{1D49E}-\\u{1D49F}\\u{1D4A2}\\u{1D4A5}-\\u{1D4A6}\\u{1D4A9}-\\u{1D4AC}\\u{1D4AE}-\\u{1D4B9}\\u{1D4BB}\\u{1D4BD}-\\u{1D4C3}\\u{1D4C5}-\\u{1D505}\\u{1D507}-\\u{1D50A}\\u{1D50D}-\\u{1D514}\\u{1D516}-\\u{1D51C}\\u{1D51E}-\\u{1D539}\\u{1D53B}-\\u{1D53E}\\u{1D540}-\\u{1D544}\\u{1D546}\\u{1D54A}-\\u{1D550}\\u{1D552}-\\u{1D6A5}\\u{1D6A8}-\\u{1D6C0}\\u{1D6C2}-\\u{1D6DA}\\u{1D6DC}-\\u{1D6FA}\\u{1D6FC}-\\u{1D714}\\u{1D716}-\\u{1D734}\\u{1D736}-\\u{1D74E}\\u{1D750}-\\u{1D76E}\\u{1D770}-\\u{1D788}\\u{1D78A}-\\u{1D7A8}\\u{1D7AA}-\\u{1D7C2}\\u{1D7C4}-\\u{1D7CB}\\u{1DF00}-\\u{1DF1E}\\u{1DF25}-\\u{1DF2A}\\u{1E030}-\\u{1E06D}\\u{1E100}-\\u{1E12C}\\u{1E137}-\\u{1E13D}\\u{1E14E}\\u{1E290}-\\u{1E2AD}\\u{1E2C0}-\\u{1E2EB}\\u{1E4D0}-\\u{1E4EB}\\u{1E5D0}-\\u{1E5ED}\\u{1E5F0}\\u{1E6C0}-\\u{1E6DE}\\u{1E6E0}-\\u{1E6E2}\\u{1E6E4}-\\u{1E6E5}\\u{1E6E7}-\\u{1E6ED}\\u{1E6F0}-\\u{1E6F4}\\u{1E6FE}-\\u{1E6FF}\\u{1E7E0}-\\u{1E7E6}\\u{1E7E8}-\\u{1E7EB}\\u{1E7ED}-\\u{1E7EE}\\u{1E7F0}-\\u{1E7FE}\\u{1E800}-\\u{1E8C4}\\u{1E900}-\\u{1E943}\\u{1E94B}\\u{1EE00}-\\u{1EE03}\\u{1EE05}-\\u{1EE1F}\\u{1EE21}-\\u{1EE22}\\u{1EE24}\\u{1EE27}\\u{1EE29}-\\u{1EE32}\\u{1EE34}-\\u{1EE37}\\u{1EE39}\\u{1EE3B}\\u{1EE42}\\u{1EE47}\\u{1EE49}\\u{1EE4B}\\u{1EE4D}-\\u{1EE4F}\\u{1EE51}-\\u{1EE52}\\u{1EE54}\\u{1EE57}\\u{1EE59}\\u{1EE5B}\\u{1EE5D}\\u{1EE5F}\\u{1EE61}-\\u{1EE62}\\u{1EE64}\\u{1EE67}-\\u{1EE6A}\\u{1EE6C}-\\u{1EE72}\\u{1EE74}-\\u{1EE77}\\u{1EE79}-\\u{1EE7C}\\u{1EE7E}\\u{1EE80}-\\u{1EE89}\\u{1EE8B}-\\u{1EE9B}\\u{1EEA1}-\\u{1EEA3}\\u{1EEA5}-\\u{1EEA9}\\u{1EEAB}-\\u{1EEBB}\\u{20000}-\\u{2A6DF}\\u{2A700}-\\u{2B81D}\\u{2B820}-\\u{2CEAD}\\u{2CEB0}-\\u{2EBE0}\\u{2EBF0}-\\u{2EE5D}\\u{2F800}-\\u{2FA1D}\\u{30000}-\\u{3134A}\\u{31350}-\\u{33479}\\u0030-\\u0039\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE\\u0660-\\u0669\\u06F0-\\u06F9\\u07C0-\\u07C9\\u0966-\\u096F\\u09E6-\\u09EF\\u09F4-\\u09F9\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0B72-\\u0B77\\u0BE6-\\u0BF2\\u0C66-\\u0C6F\\u0C78-\\u0C7E\\u0CE6-\\u0CEF\\u0D58-\\u0D5E\\u0D66-\\u0D78\\u0DE6-\\u0DEF\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F33\\u1040-\\u1049\\u1090-\\u1099\\u1369-\\u137C\\u16EE-\\u16F0\\u17E0-\\u17E9\\u17F0-\\u17F9\\u1810-\\u1819\\u1946-\\u194F\\u19D0-\\u19DA\\u1A80-\\u1A89\\u1A90-\\u1A99\\u1B50-\\u1B59\\u1BB0-\\u1BB9\\u1C40-\\u1C49\\u1C50-\\u1C59\\u2070\\u2074-\\u2079\\u2080-\\u2089\\u2150-\\u2182\\u2185-\\u2189\\u2460-\\u249B\\u24EA-\\u24FF\\u2776-\\u2793\\u2CFD\\u3007\\u3021-\\u3029\\u3038-\\u303A\\u3192-\\u3195\\u3220-\\u3229\\u3248-\\u324F\\u3251-\\u325F\\u3280-\\u3289\\u32B1-\\u32BF\\uA620-\\uA629\\uA6E6-\\uA6EF\\uA830-\\uA835\\uA8D0-\\uA8D9\\uA900-\\uA909\\uA9D0-\\uA9D9\\uA9F0-\\uA9F9\\uAA50-\\uAA59\\uABF0-\\uABF9\\uFF10-\\uFF19\\u{10107}-\\u{10133}\\u{10140}-\\u{10178}\\u{1018A}-\\u{1018B}\\u{102E1}-\\u{102FB}\\u{10320}-\\u{10323}\\u{10341}\\u{1034A}\\u{103D1}-\\u{103D5}\\u{104A0}-\\u{104A9}\\u{10858}-\\u{1085F}\\u{10879}-\\u{1087F}\\u{108A7}-\\u{108AF}\\u{108FB}-\\u{108FF}\\u{10916}-\\u{1091B}\\u{109BC}-\\u{109BD}\\u{109C0}-\\u{109CF}\\u{109D2}-\\u{109FF}\\u{10A40}-\\u{10A48}\\u{10A7D}-\\u{10A7E}\\u{10A9D}-\\u{10A9F}\\u{10AEB}-\\u{10AEF}\\u{10B58}-\\u{10B5F}\\u{10B78}-\\u{10B7F}\\u{10BA9}-\\u{10BAF}\\u{10CFA}-\\u{10CFF}\\u{10D30}-\\u{10D39}\\u{10D40}-\\u{10D49}\\u{10E60}-\\u{10E7E}\\u{10F1D}-\\u{10F26}\\u{10F51}-\\u{10F54}\\u{10FC5}-\\u{10FCB}\\u{11052}-\\u{1106F}\\u{110F0}-\\u{110F9}\\u{11136}-\\u{1113F}\\u{111D0}-\\u{111D9}\\u{111E1}-\\u{111F4}\\u{112F0}-\\u{112F9}\\u{11450}-\\u{11459}\\u{114D0}-\\u{114D9}\\u{11650}-\\u{11659}\\u{116C0}-\\u{116C9}\\u{116D0}-\\u{116E3}\\u{11730}-\\u{1173B}\\u{118E0}-\\u{118F2}\\u{11950}-\\u{11959}\\u{11BF0}-\\u{11BF9}\\u{11C50}-\\u{11C6C}\\u{11D50}-\\u{11D59}\\u{11DA0}-\\u{11DA9}\\u{11DE0}-\\u{11DE9}\\u{11F50}-\\u{11F59}\\u{11FC0}-\\u{11FD4}\\u{12400}-\\u{1246E}\\u{16130}-\\u{16139}\\u{16A60}-\\u{16A69}\\u{16AC0}-\\u{16AC9}\\u{16B50}-\\u{16B59}\\u{16B5B}-\\u{16B61}\\u{16D70}-\\u{16D79}\\u{16E80}-\\u{16E96}\\u{16FF4}-\\u{16FF6}\\u{1CCF0}-\\u{1CCF9}\\u{1D2C0}-\\u{1D2D3}\\u{1D2E0}-\\u{1D2F3}\\u{1D360}-\\u{1D378}\\u{1D7CE}-\\u{1D7FF}\\u{1E140}-\\u{1E149}\\u{1E2F0}-\\u{1E2F9}\\u{1E4F0}-\\u{1E4F9}\\u{1E5F1}-\\u{1E5FA}\\u{1E8C7}-\\u{1E8CF}\\u{1E950}-\\u{1E959}\\u{1EC71}-\\u{1ECAB}\\u{1ECAD}-\\u{1ECAF}\\u{1ECB1}-\\u{1ECB4}\\u{1ED01}-\\u{1ED2D}\\u{1ED2F}-\\u{1ED3D}\\u{1F100}-\\u{1F10C}\\u{1FBF0}-\\u{1FBF9}]+|[^\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376-\\u0377\\u037A-\\u037D\\u037F\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u052F\\u0531-\\u0556\\u0559\\u0560-\\u0588\\u05D0-\\u05EA\\u05EF-\\u05F2\\u0620-\\u064A\\u066E-\\u066F\\u0671-\\u06D3\\u06D5\\u06E5-\\u06E6\\u06EE-\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4-\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u0860-\\u086A\\u0870-\\u0887\\u0889-\\u088F\\u08A0-\\u08C9\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0980\\u0985-\\u098C\\u098F-\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC-\\u09DD\\u09DF-\\u09E1\\u09F0-\\u09F1\\u09FC\\u0A05-\\u0A0A\\u0A0F-\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32-\\u0A33\\u0A35-\\u0A36\\u0A38-\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2-\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0-\\u0AE1\\u0AF9\\u0B05-\\u0B0C\\u0B0F-\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32-\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C-\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99-\\u0B9A\\u0B9C\\u0B9E-\\u0B9F\\u0BA3-\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C39\\u0C3D\\u0C58-\\u0C5A\\u0C5C-\\u0C5D\\u0C60-\\u0C61\\u0C80\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDC-\\u0CDE\\u0CE0-\\u0CE1\\u0CF1-\\u0CF2\\u0D04-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D54-\\u0D56\\u0D5F-\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32-\\u0E33\\u0E40-\\u0E46\\u0E81-\\u0E82\\u0E84\\u0E86-\\u0E8A\\u0E8C-\\u0EA3\\u0EA5\\u0EA7-\\u0EB0\\u0EB2-\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065-\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F5\\u13F8-\\u13FD\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16F1-\\u16F8\\u1700-\\u1711\\u171F-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1878\\u1880-\\u1884\\u1887-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191E\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19B0-\\u19C9\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4C\\u1B83-\\u1BA0\\u1BAE-\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1C80-\\u1C8A\\u1C90-\\u1CBA\\u1CBD-\\u1CBF\\u1CE9-\\u1CEC\\u1CEE-\\u1CF3\\u1CF5-\\u1CF6\\u1CFA\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183-\\u2184\\u2C00-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2-\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3006\\u3031-\\u3035\\u303B-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312F\\u3131-\\u318E\\u31A0-\\u31BF\\u31F0-\\u31FF\\u3400-\\u4DBF\\u4E00-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A-\\uA62B\\uA640-\\uA66E\\uA67F-\\uA69D\\uA6A0-\\uA6E5\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA7DC\\uA7F1-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA8FD-\\uA8FE\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uA9E0-\\uA9E4\\uA9E6-\\uA9EF\\uA9FA-\\uA9FE\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA7E-\\uAAAF\\uAAB1\\uAAB5-\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uAB30-\\uAB5A\\uAB5C-\\uAB69\\uAB70-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40-\\uFB41\\uFB43-\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC\\u{10000}-\\u{1000B}\\u{1000D}-\\u{10026}\\u{10028}-\\u{1003A}\\u{1003C}-\\u{1003D}\\u{1003F}-\\u{1004D}\\u{10050}-\\u{1005D}\\u{10080}-\\u{100FA}\\u{10280}-\\u{1029C}\\u{102A0}-\\u{102D0}\\u{10300}-\\u{1031F}\\u{1032D}-\\u{10340}\\u{10342}-\\u{10349}\\u{10350}-\\u{10375}\\u{10380}-\\u{1039D}\\u{103A0}-\\u{103C3}\\u{103C8}-\\u{103CF}\\u{10400}-\\u{1049D}\\u{104B0}-\\u{104D3}\\u{104D8}-\\u{104FB}\\u{10500}-\\u{10527}\\u{10530}-\\u{10563}\\u{10570}-\\u{1057A}\\u{1057C}-\\u{1058A}\\u{1058C}-\\u{10592}\\u{10594}-\\u{10595}\\u{10597}-\\u{105A1}\\u{105A3}-\\u{105B1}\\u{105B3}-\\u{105B9}\\u{105BB}-\\u{105BC}\\u{105C0}-\\u{105F3}\\u{10600}-\\u{10736}\\u{10740}-\\u{10755}\\u{10760}-\\u{10767}\\u{10780}-\\u{10785}\\u{10787}-\\u{107B0}\\u{107B2}-\\u{107BA}\\u{10800}-\\u{10805}\\u{10808}\\u{1080A}-\\u{10835}\\u{10837}-\\u{10838}\\u{1083C}\\u{1083F}-\\u{10855}\\u{10860}-\\u{10876}\\u{10880}-\\u{1089E}\\u{108E0}-\\u{108F2}\\u{108F4}-\\u{108F5}\\u{10900}-\\u{10915}\\u{10920}-\\u{10939}\\u{10940}-\\u{10959}\\u{10980}-\\u{109B7}\\u{109BE}-\\u{109BF}\\u{10A00}\\u{10A10}-\\u{10A13}\\u{10A15}-\\u{10A17}\\u{10A19}-\\u{10A35}\\u{10A60}-\\u{10A7C}\\u{10A80}-\\u{10A9C}\\u{10AC0}-\\u{10AC7}\\u{10AC9}-\\u{10AE4}\\u{10B00}-\\u{10B35}\\u{10B40}-\\u{10B55}\\u{10B60}-\\u{10B72}\\u{10B80}-\\u{10B91}\\u{10C00}-\\u{10C48}\\u{10C80}-\\u{10CB2}\\u{10CC0}-\\u{10CF2}\\u{10D00}-\\u{10D23}\\u{10D4A}-\\u{10D65}\\u{10D6F}-\\u{10D85}\\u{10E80}-\\u{10EA9}\\u{10EB0}-\\u{10EB1}\\u{10EC2}-\\u{10EC7}\\u{10F00}-\\u{10F1C}\\u{10F27}\\u{10F30}-\\u{10F45}\\u{10F70}-\\u{10F81}\\u{10FB0}-\\u{10FC4}\\u{10FE0}-\\u{10FF6}\\u{11003}-\\u{11037}\\u{11071}-\\u{11072}\\u{11075}\\u{11083}-\\u{110AF}\\u{110D0}-\\u{110E8}\\u{11103}-\\u{11126}\\u{11144}\\u{11147}\\u{11150}-\\u{11172}\\u{11176}\\u{11183}-\\u{111B2}\\u{111C1}-\\u{111C4}\\u{111DA}\\u{111DC}\\u{11200}-\\u{11211}\\u{11213}-\\u{1122B}\\u{1123F}-\\u{11240}\\u{11280}-\\u{11286}\\u{11288}\\u{1128A}-\\u{1128D}\\u{1128F}-\\u{1129D}\\u{1129F}-\\u{112A8}\\u{112B0}-\\u{112DE}\\u{11305}-\\u{1130C}\\u{1130F}-\\u{11310}\\u{11313}-\\u{11328}\\u{1132A}-\\u{11330}\\u{11332}-\\u{11333}\\u{11335}-\\u{11339}\\u{1133D}\\u{11350}\\u{1135D}-\\u{11361}\\u{11380}-\\u{11389}\\u{1138B}\\u{1138E}\\u{11390}-\\u{113B5}\\u{113B7}\\u{113D1}\\u{113D3}\\u{11400}-\\u{11434}\\u{11447}-\\u{1144A}\\u{1145F}-\\u{11461}\\u{11480}-\\u{114AF}\\u{114C4}-\\u{114C5}\\u{114C7}\\u{11580}-\\u{115AE}\\u{115D8}-\\u{115DB}\\u{11600}-\\u{1162F}\\u{11644}\\u{11680}-\\u{116AA}\\u{116B8}\\u{11700}-\\u{1171A}\\u{11740}-\\u{11746}\\u{11800}-\\u{1182B}\\u{118A0}-\\u{118DF}\\u{118FF}-\\u{11906}\\u{11909}\\u{1190C}-\\u{11913}\\u{11915}-\\u{11916}\\u{11918}-\\u{1192F}\\u{1193F}\\u{11941}\\u{119A0}-\\u{119A7}\\u{119AA}-\\u{119D0}\\u{119E1}\\u{119E3}\\u{11A00}\\u{11A0B}-\\u{11A32}\\u{11A3A}\\u{11A50}\\u{11A5C}-\\u{11A89}\\u{11A9D}\\u{11AB0}-\\u{11AF8}\\u{11BC0}-\\u{11BE0}\\u{11C00}-\\u{11C08}\\u{11C0A}-\\u{11C2E}\\u{11C40}\\u{11C72}-\\u{11C8F}\\u{11D00}-\\u{11D06}\\u{11D08}-\\u{11D09}\\u{11D0B}-\\u{11D30}\\u{11D46}\\u{11D60}-\\u{11D65}\\u{11D67}-\\u{11D68}\\u{11D6A}-\\u{11D89}\\u{11D98}\\u{11DB0}-\\u{11DDB}\\u{11EE0}-\\u{11EF2}\\u{11F02}\\u{11F04}-\\u{11F10}\\u{11F12}-\\u{11F33}\\u{11FB0}\\u{12000}-\\u{12399}\\u{12480}-\\u{12543}\\u{12F90}-\\u{12FF0}\\u{13000}-\\u{1342F}\\u{13441}-\\u{13446}\\u{13460}-\\u{143FA}\\u{14400}-\\u{14646}\\u{16100}-\\u{1611D}\\u{16800}-\\u{16A38}\\u{16A40}-\\u{16A5E}\\u{16A70}-\\u{16ABE}\\u{16AD0}-\\u{16AED}\\u{16B00}-\\u{16B2F}\\u{16B40}-\\u{16B43}\\u{16B63}-\\u{16B77}\\u{16B7D}-\\u{16B8F}\\u{16D40}-\\u{16D6C}\\u{16E40}-\\u{16E7F}\\u{16EA0}-\\u{16EB8}\\u{16EBB}-\\u{16ED3}\\u{16F00}-\\u{16F4A}\\u{16F50}\\u{16F93}-\\u{16F9F}\\u{16FE0}-\\u{16FE1}\\u{16FE3}\\u{16FF2}-\\u{16FF3}\\u{17000}-\\u{18CD5}\\u{18CFF}-\\u{18D1E}\\u{18D80}-\\u{18DF2}\\u{1AFF0}-\\u{1AFF3}\\u{1AFF5}-\\u{1AFFB}\\u{1AFFD}-\\u{1AFFE}\\u{1B000}-\\u{1B122}\\u{1B132}\\u{1B150}-\\u{1B152}\\u{1B155}\\u{1B164}-\\u{1B167}\\u{1B170}-\\u{1B2FB}\\u{1BC00}-\\u{1BC6A}\\u{1BC70}-\\u{1BC7C}\\u{1BC80}-\\u{1BC88}\\u{1BC90}-\\u{1BC99}\\u{1D400}-\\u{1D454}\\u{1D456}-\\u{1D49C}\\u{1D49E}-\\u{1D49F}\\u{1D4A2}\\u{1D4A5}-\\u{1D4A6}\\u{1D4A9}-\\u{1D4AC}\\u{1D4AE}-\\u{1D4B9}\\u{1D4BB}\\u{1D4BD}-\\u{1D4C3}\\u{1D4C5}-\\u{1D505}\\u{1D507}-\\u{1D50A}\\u{1D50D}-\\u{1D514}\\u{1D516}-\\u{1D51C}\\u{1D51E}-\\u{1D539}\\u{1D53B}-\\u{1D53E}\\u{1D540}-\\u{1D544}\\u{1D546}\\u{1D54A}-\\u{1D550}\\u{1D552}-\\u{1D6A5}\\u{1D6A8}-\\u{1D6C0}\\u{1D6C2}-\\u{1D6DA}\\u{1D6DC}-\\u{1D6FA}\\u{1D6FC}-\\u{1D714}\\u{1D716}-\\u{1D734}\\u{1D736}-\\u{1D74E}\\u{1D750}-\\u{1D76E}\\u{1D770}-\\u{1D788}\\u{1D78A}-\\u{1D7A8}\\u{1D7AA}-\\u{1D7C2}\\u{1D7C4}-\\u{1D7CB}\\u{1DF00}-\\u{1DF1E}\\u{1DF25}-\\u{1DF2A}\\u{1E030}-\\u{1E06D}\\u{1E100}-\\u{1E12C}\\u{1E137}-\\u{1E13D}\\u{1E14E}\\u{1E290}-\\u{1E2AD}\\u{1E2C0}-\\u{1E2EB}\\u{1E4D0}-\\u{1E4EB}\\u{1E5D0}-\\u{1E5ED}\\u{1E5F0}\\u{1E6C0}-\\u{1E6DE}\\u{1E6E0}-\\u{1E6E2}\\u{1E6E4}-\\u{1E6E5}\\u{1E6E7}-\\u{1E6ED}\\u{1E6F0}-\\u{1E6F4}\\u{1E6FE}-\\u{1E6FF}\\u{1E7E0}-\\u{1E7E6}\\u{1E7E8}-\\u{1E7EB}\\u{1E7ED}-\\u{1E7EE}\\u{1E7F0}-\\u{1E7FE}\\u{1E800}-\\u{1E8C4}\\u{1E900}-\\u{1E943}\\u{1E94B}\\u{1EE00}-\\u{1EE03}\\u{1EE05}-\\u{1EE1F}\\u{1EE21}-\\u{1EE22}\\u{1EE24}\\u{1EE27}\\u{1EE29}-\\u{1EE32}\\u{1EE34}-\\u{1EE37}\\u{1EE39}\\u{1EE3B}\\u{1EE42}\\u{1EE47}\\u{1EE49}\\u{1EE4B}\\u{1EE4D}-\\u{1EE4F}\\u{1EE51}-\\u{1EE52}\\u{1EE54}\\u{1EE57}\\u{1EE59}\\u{1EE5B}\\u{1EE5D}\\u{1EE5F}\\u{1EE61}-\\u{1EE62}\\u{1EE64}\\u{1EE67}-\\u{1EE6A}\\u{1EE6C}-\\u{1EE72}\\u{1EE74}-\\u{1EE77}\\u{1EE79}-\\u{1EE7C}\\u{1EE7E}\\u{1EE80}-\\u{1EE89}\\u{1EE8B}-\\u{1EE9B}\\u{1EEA1}-\\u{1EEA3}\\u{1EEA5}-\\u{1EEA9}\\u{1EEAB}-\\u{1EEBB}\\u{20000}-\\u{2A6DF}\\u{2A700}-\\u{2B81D}\\u{2B820}-\\u{2CEAD}\\u{2CEB0}-\\u{2EBE0}\\u{2EBF0}-\\u{2EE5D}\\u{2F800}-\\u{2FA1D}\\u{30000}-\\u{3134A}\\u{31350}-\\u{33479}\\u0030-\\u0039\\u00B2-\\u00B3\\u00B9\\u00BC-\\u00BE\\u0660-\\u0669\\u06F0-\\u06F9\\u07C0-\\u07C9\\u0966-\\u096F\\u09E6-\\u09EF\\u09F4-\\u09F9\\u0A66-\\u0A6F\\u0AE6-\\u0AEF\\u0B66-\\u0B6F\\u0B72-\\u0B77\\u0BE6-\\u0BF2\\u0C66-\\u0C6F\\u0C78-\\u0C7E\\u0CE6-\\u0CEF\\u0D58-\\u0D5E\\u0D66-\\u0D78\\u0DE6-\\u0DEF\\u0E50-\\u0E59\\u0ED0-\\u0ED9\\u0F20-\\u0F33\\u1040-\\u1049\\u1090-\\u1099\\u1369-\\u137C\\u16EE-\\u16F0\\u17E0-\\u17E9\\u17F0-\\u17F9\\u1810-\\u1819\\u1946-\\u194F\\u19D0-\\u19DA\\u1A80-\\u1A89\\u1A90-\\u1A99\\u1B50-\\u1B59\\u1BB0-\\u1BB9\\u1C40-\\u1C49\\u1C50-\\u1C59\\u2070\\u2074-\\u2079\\u2080-\\u2089\\u2150-\\u2182\\u2185-\\u2189\\u2460-\\u249B\\u24EA-\\u24FF\\u2776-\\u2793\\u2CFD\\u3007\\u3021-\\u3029\\u3038-\\u303A\\u3192-\\u3195\\u3220-\\u3229\\u3248-\\u324F\\u3251-\\u325F\\u3280-\\u3289\\u32B1-\\u32BF\\uA620-\\uA629\\uA6E6-\\uA6EF\\uA830-\\uA835\\uA8D0-\\uA8D9\\uA900-\\uA909\\uA9D0-\\uA9D9\\uA9F0-\\uA9F9\\uAA50-\\uAA59\\uABF0-\\uABF9\\uFF10-\\uFF19\\u{10107}-\\u{10133}\\u{10140}-\\u{10178}\\u{1018A}-\\u{1018B}\\u{102E1}-\\u{102FB}\\u{10320}-\\u{10323}\\u{10341}\\u{1034A}\\u{103D1}-\\u{103D5}\\u{104A0}-\\u{104A9}\\u{10858}-\\u{1085F}\\u{10879}-\\u{1087F}\\u{108A7}-\\u{108AF}\\u{108FB}-\\u{108FF}\\u{10916}-\\u{1091B}\\u{109BC}-\\u{109BD}\\u{109C0}-\\u{109CF}\\u{109D2}-\\u{109FF}\\u{10A40}-\\u{10A48}\\u{10A7D}-\\u{10A7E}\\u{10A9D}-\\u{10A9F}\\u{10AEB}-\\u{10AEF}\\u{10B58}-\\u{10B5F}\\u{10B78}-\\u{10B7F}\\u{10BA9}-\\u{10BAF}\\u{10CFA}-\\u{10CFF}\\u{10D30}-\\u{10D39}\\u{10D40}-\\u{10D49}\\u{10E60}-\\u{10E7E}\\u{10F1D}-\\u{10F26}\\u{10F51}-\\u{10F54}\\u{10FC5}-\\u{10FCB}\\u{11052}-\\u{1106F}\\u{110F0}-\\u{110F9}\\u{11136}-\\u{1113F}\\u{111D0}-\\u{111D9}\\u{111E1}-\\u{111F4}\\u{112F0}-\\u{112F9}\\u{11450}-\\u{11459}\\u{114D0}-\\u{114D9}\\u{11650}-\\u{11659}\\u{116C0}-\\u{116C9}\\u{116D0}-\\u{116E3}\\u{11730}-\\u{1173B}\\u{118E0}-\\u{118F2}\\u{11950}-\\u{11959}\\u{11BF0}-\\u{11BF9}\\u{11C50}-\\u{11C6C}\\u{11D50}-\\u{11D59}\\u{11DA0}-\\u{11DA9}\\u{11DE0}-\\u{11DE9}\\u{11F50}-\\u{11F59}\\u{11FC0}-\\u{11FD4}\\u{12400}-\\u{1246E}\\u{16130}-\\u{16139}\\u{16A60}-\\u{16A69}\\u{16AC0}-\\u{16AC9}\\u{16B50}-\\u{16B59}\\u{16B5B}-\\u{16B61}\\u{16D70}-\\u{16D79}\\u{16E80}-\\u{16E96}\\u{16FF4}-\\u{16FF6}\\u{1CCF0}-\\u{1CCF9}\\u{1D2C0}-\\u{1D2D3}\\u{1D2E0}-\\u{1D2F3}\\u{1D360}-\\u{1D378}\\u{1D7CE}-\\u{1D7FF}\\u{1E140}-\\u{1E149}\\u{1E2F0}-\\u{1E2F9}\\u{1E4F0}-\\u{1E4F9}\\u{1E5F1}-\\u{1E5FA}\\u{1E8C7}-\\u{1E8CF}\\u{1E950}-\\u{1E959}\\u{1EC71}-\\u{1ECAB}\\u{1ECAD}-\\u{1ECAF}\\u{1ECB1}-\\u{1ECB4}\\u{1ED01}-\\u{1ED2D}\\u{1ED2F}-\\u{1ED3D}\\u{1F100}-\\u{1F10C}\\u{1FBF0}-\\u{1FBF9}]+$", "gu");
79
+ /** A value as the text it reads as, or '' for anything that is not text. */ const textOf = (value)=>{
80
+ if (typeof value === 'string') return value;
81
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
82
+ if (typeof value === 'boolean') return String(value);
83
+ if (Array.isArray(value)) return value.map(textOf).filter(Boolean).join(' ');
84
+ return '';
85
+ };
86
+ /** Who wrote in, from the message's entries in the order given. */ function senderOf(entries) {
87
+ const reduced = new Map();
88
+ for (const [key, value] of entries){
89
+ const text = textOf(value).trim();
90
+ if (!text) continue;
91
+ const at = key.toLowerCase().replace(/[^a-z0-9]/g, '');
92
+ if (!reduced.has(at)) reduced.set(at, text);
93
+ }
94
+ const name = SENDER_NAME_KEYS.map((key)=>reduced.get(key)).find(Boolean);
95
+ const email = SENDER_EMAIL_KEYS.map((key)=>reduced.get(key)).find(Boolean);
96
+ return _extends({}, name ? {
97
+ name
98
+ } : {}, email ? {
99
+ email
100
+ } : {});
101
+ }
102
+ /**
103
+ * Who wrote in, by the field-name convention: the first non-empty value of
104
+ * a name-like field and of an address-like field. Reads the map in the
105
+ * order it is handed, so the first spelling of a reduced key wins.
106
+ */ export function messageSender(fields) {
107
+ return senderOf(Object.entries(fields != null ? fields : {}));
108
+ }
109
+ /** The words of a text: its key's words without edge punctuation, and their parts. */ function wordsOf(text) {
110
+ const words = [];
111
+ for (const raw of nameSearchKey(text).split(' ')){
112
+ const word = raw.replace(WORD_EDGES, '');
113
+ if (!word) continue;
114
+ words.push(word);
115
+ if (WORD_JOINS.test(word)) {
116
+ for (const part of word.split(WORD_PARTS))if (part && part !== word) words.push(part);
117
+ }
118
+ }
119
+ return words;
120
+ }
121
+ /**
122
+ * Every prefix of each word, up to the prefix cap, into `tokens` until `max`
123
+ * — cut exactly as `nameSearchTokens` cuts, so the one token a typed word
124
+ * becomes (`nameSearchToken`) is one of these.
125
+ */ function addPrefixes(tokens, words, max) {
126
+ for (const word of words){
127
+ const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX);
128
+ for(let end = 1; end <= capped.length; end += 1){
129
+ if (tokens.size >= max) return;
130
+ tokens.add(capped.slice(0, end));
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * The two search arrays a message is stamped with — see the block above.
136
+ * Spread onto every write that creates one; a message's values never change
137
+ * after it arrives, so no other write touches them.
138
+ */ export function messageSearchFields(fields) {
139
+ var _sender_name, _sender_email;
140
+ // Sorted entries, never an object rebuilt from them: an object puts
141
+ // integer-like keys first whatever order they were added in.
142
+ const sorted = Object.entries(fields != null ? fields : {}).sort(([left], [right])=>left < right ? -1 : left > right ? 1 : 0);
143
+ const sender = senderOf(sorted);
144
+ const senderSet = new Set();
145
+ addPrefixes(senderSet, [
146
+ ...wordsOf((_sender_name = sender.name) != null ? _sender_name : ''),
147
+ ...wordsOf((_sender_email = sender.email) != null ? _sender_email : '')
148
+ ], MESSAGE_SENDER_TOKENS_MAX);
149
+ const words = new Set();
150
+ for (const word of sorted.flatMap(([, value])=>wordsOf(textOf(value)))){
151
+ if (words.size >= MESSAGE_SEARCH_WORDS_MAX) break;
152
+ words.add(word);
153
+ }
154
+ const search = new Set(senderSet);
155
+ addPrefixes(search, [
156
+ ...words
157
+ ], MESSAGE_SEARCH_TOKENS_MAX);
158
+ return {
159
+ senderTokens: [
160
+ ...senderSet
161
+ ],
162
+ searchTokens: [
163
+ ...search
164
+ ]
165
+ };
166
+ }
167
+
168
+ //# sourceMappingURL=message-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/message-search.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 { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from './name-search'\n\n/*\n * THE SEARCH KEYS OF A MESSAGE A VISITOR SENT (AGL-3321).\n *\n * A message is a free-form map of the fields its author declared — `name`,\n * `Email Address`, `message`, `Company size` — with no guaranteed shape. A\n * list of them is searched on its Firestore query, which cannot look inside\n * a string, so every writer stamps the words as word-prefix token arrays the\n * query asks with `array-contains`:\n *\n * senderTokens the sender's name and address — who wrote in. What the\n * list's From filter asks.\n * searchTokens the sender's tokens, then the words of every value the\n * message carries. What the list's search box asks.\n *\n * The words are the platform's search keys (`nameSearchKey`, prefixes up to\n * `NAME_TOKEN_MAX_PREFIX`), so a typed word becomes the token the query asks\n * through the same `nameSearchNormalizers` every list uses. A word that joins\n * parts with `@ . + _ -` — an address, a domain, a hyphenated name — is also\n * its parts, so `acme` finds `dana@acme.com` and `dana` does too.\n *\n * ## What is capped\n *\n * A message is free text, so the arrays are bounded rather than complete:\n * the first {@link MESSAGE_SEARCH_WORDS_MAX} distinct words of the values\n * contribute, and each array stops at its own ceiling\n * ({@link MESSAGE_SENDER_TOKENS_MAX}, {@link MESSAGE_SEARCH_TOKENS_MAX}) —\n * the sender first, so a long message loses the tail of its text before it\n * loses who sent it. A word past the cap is still in the message; the search\n * box just does not find it.\n *\n * ## Deterministic whatever the map's key order\n *\n * A map's keys come back in a different order from different readers (the\n * Admin SDK sorts them), and a cap makes the result order-sensitive. So the\n * values are read in SORTED key order, and a backfill that re-derives the\n * arrays from a stored message computes exactly what the writer stamped.\n *\n * `tools/scripts/backfill-form-submission-filters.mjs` restates this for the\n * rows written before it, held to `tools/scripts/lib/message-search.fixtures.json`\n * with the spec beside this file.\n */\n\n/** The most tokens the sender contributes. A name and an address are a few dozen. */\nexport const MESSAGE_SENDER_TOKENS_MAX = 60\n\n/** The most tokens one message's search array holds, the sender's included. */\nexport const MESSAGE_SEARCH_TOKENS_MAX = 200\n\n/** The most distinct words of the message's values that contribute prefixes. */\nexport const MESSAGE_SEARCH_WORDS_MAX = 40\n\n/**\n * Field names that mean \"this is who wrote in\", most specific first,\n * compared against a key REDUCED to lowercase letters and digits — the same\n * field arrives as `Full Name`, `full_name` and `fullname`.\n */\nconst SENDER_NAME_KEYS = ['name', 'fullname', 'yourname', 'firstname', 'contactname']\nconst SENDER_EMAIL_KEYS = ['email', 'emailaddress']\n\n/** The joins a word is also split on, so an address is also its parts. */\nconst WORD_JOINS = /[@.+_-]/\nconst WORD_PARTS = /[@.+_-]+/\n/** Punctuation around a word — `cake?`, `(june)` — is not part of it. */\nconst WORD_EDGES = /^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu\n\n/** A value as the text it reads as, or '' for anything that is not text. */\nconst textOf = (value: unknown): string => {\n if (typeof value === 'string') return value\n if (typeof value === 'number' && Number.isFinite(value)) return String(value)\n if (typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map(textOf).filter(Boolean).join(' ')\n return ''\n}\n\n/** Who wrote in, from the message's entries in the order given. */\nfunction senderOf(entries: ReadonlyArray<readonly [string, unknown]>): {\n name?: string\n email?: string\n} {\n const reduced = new Map<string, string>()\n for (const [key, value] of entries) {\n const text = textOf(value).trim()\n if (!text) continue\n const at = key.toLowerCase().replace(/[^a-z0-9]/g, '')\n if (!reduced.has(at)) reduced.set(at, text)\n }\n const name = SENDER_NAME_KEYS.map((key) => reduced.get(key)).find(Boolean)\n const email = SENDER_EMAIL_KEYS.map((key) => reduced.get(key)).find(Boolean)\n return { ...(name ? { name } : {}), ...(email ? { email } : {}) }\n}\n\n/**\n * Who wrote in, by the field-name convention: the first non-empty value of\n * a name-like field and of an address-like field. Reads the map in the\n * order it is handed, so the first spelling of a reduced key wins.\n */\nexport function messageSender(\n fields: Record<string, unknown> | null | undefined,\n): { name?: string; email?: string } {\n return senderOf(Object.entries(fields ?? {}))\n}\n\n/** The words of a text: its key's words without edge punctuation, and their parts. */\nfunction wordsOf(text: string): string[] {\n const words: string[] = []\n for (const raw of nameSearchKey(text).split(' ')) {\n const word = raw.replace(WORD_EDGES, '')\n if (!word) continue\n words.push(word)\n if (WORD_JOINS.test(word)) {\n for (const part of word.split(WORD_PARTS)) if (part && part !== word) words.push(part)\n }\n }\n return words\n}\n\n/**\n * Every prefix of each word, up to the prefix cap, into `tokens` until `max`\n * — cut exactly as `nameSearchTokens` cuts, so the one token a typed word\n * becomes (`nameSearchToken`) is one of these.\n */\nfunction addPrefixes(tokens: Set<string>, words: readonly string[], max: number): void {\n for (const word of words) {\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n if (tokens.size >= max) return\n tokens.add(capped.slice(0, end))\n }\n }\n}\n\n/**\n * The two search arrays a message is stamped with — see the block above.\n * Spread onto every write that creates one; a message's values never change\n * after it arrives, so no other write touches them.\n */\nexport function messageSearchFields(fields: Record<string, unknown> | null | undefined): {\n senderTokens: string[]\n searchTokens: string[]\n} {\n // Sorted entries, never an object rebuilt from them: an object puts\n // integer-like keys first whatever order they were added in.\n const sorted = Object.entries(fields ?? {}).sort(([left], [right]) =>\n left < right ? -1 : left > right ? 1 : 0,\n )\n const sender = senderOf(sorted)\n const senderSet = new Set<string>()\n addPrefixes(\n senderSet,\n [...wordsOf(sender.name ?? ''), ...wordsOf(sender.email ?? '')],\n MESSAGE_SENDER_TOKENS_MAX,\n )\n const words = new Set<string>()\n for (const word of sorted.flatMap(([, value]) => wordsOf(textOf(value)))) {\n if (words.size >= MESSAGE_SEARCH_WORDS_MAX) break\n words.add(word)\n }\n const search = new Set<string>(senderSet)\n addPrefixes(search, [...words], MESSAGE_SEARCH_TOKENS_MAX)\n return { senderTokens: [...senderSet], searchTokens: [...search] }\n}\n"],"names":["NAME_TOKEN_MAX_PREFIX","nameSearchKey","MESSAGE_SENDER_TOKENS_MAX","MESSAGE_SEARCH_TOKENS_MAX","MESSAGE_SEARCH_WORDS_MAX","SENDER_NAME_KEYS","SENDER_EMAIL_KEYS","WORD_JOINS","WORD_PARTS","WORD_EDGES","textOf","value","Number","isFinite","String","Array","isArray","map","filter","Boolean","join","senderOf","entries","reduced","Map","key","text","trim","at","toLowerCase","replace","has","set","name","get","find","email","messageSender","fields","Object","wordsOf","words","raw","split","word","push","test","part","addPrefixes","tokens","max","capped","slice","end","length","size","add","messageSearchFields","sender","sorted","sort","left","right","senderSet","Set","flatMap","search","senderTokens","searchTokens"],"mappings":"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"}
@@ -115,6 +115,33 @@ export declare function nameSearchFields(name: string): {
115
115
  nameTokens: string[];
116
116
  nameReversed: string;
117
117
  };
118
+ /**
119
+ * The search fields a document NAMED BY `displayName` carries (AGL-3321):
120
+ * the site artifacts (screens of every kind, layouts, reusable components,
121
+ * templates) and the marketplace listings.
122
+ *
123
+ * `nameSearchFields` is for documents whose name IS `name`. These documents
124
+ * name themselves `displayName`, and have since before search existed, so
125
+ * the keys ride beside it under the same three names every list declares —
126
+ * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,
127
+ * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.
128
+ *
129
+ * `displayName` is NOT returned. A create that carries no name must not gain
130
+ * an empty one: readers fall back with `displayName ?? id`, which an empty
131
+ * string defeats. But the KEYS are always returned, empty for a missing name,
132
+ * so a create stamps them unconditionally — an `orderBy('nameLower')` drops
133
+ * any document that lacks the field, and a document created without a name
134
+ * is still one a list must reach.
135
+ *
136
+ * ⚠️ Spread at every write that sets `displayName`, and at every create —
137
+ * and nowhere else: a partial update that stamped keys for a name it did not
138
+ * write would make the document unfindable by the name it still shows.
139
+ */
140
+ export declare function displayNameSearchFields(displayName: unknown): {
141
+ nameLower: string;
142
+ nameTokens: string[];
143
+ nameReversed: string;
144
+ };
118
145
  /**
119
146
  * The normalizers a list query plan (`planListQuery` in
120
147
  * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys
@@ -127,3 +154,59 @@ export declare const nameSearchNormalizers: {
127
154
  readonly reversed: (value: string) => string;
128
155
  readonly maxPrefix: 12;
129
156
  };
157
+ /**
158
+ * What joins a scope token to a search prefix in a SCOPED search token
159
+ * (AGL-3321): `host:abc~acm`.
160
+ *
161
+ * A list under a scope clause (`visibleTo array-contains-any [...]`) has
162
+ * spent Firestore's one array clause on the scope, so its search cannot be a
163
+ * second `array-contains`. The list query plan folds the typed word INTO the scope
164
+ * clause instead (`ListQueryDeclaration.search.scoped`), asking for each
165
+ * scope token joined to the word; the writers stamp every scope token
166
+ * joined to every prefix, through {@link scopedSearchTokens}. A declaration
167
+ * names this constant as its `join`, so the writer and the reader cannot
168
+ * disagree about the character between the two halves.
169
+ *
170
+ * ⛔ Security rules cannot prove a folded query for a reader limited to some
171
+ * sites. Rules like `canReadScoped` grant a list from
172
+ * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only
173
+ * the scoped-token field says nothing about `visibleTo`, so Firestore denies
174
+ * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search
175
+ * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an
176
+ * org-wide member, whose rule short-circuits) or for an Admin-SDK route that
177
+ * checks the reader's scope itself. A partial-reach collaborator's search
178
+ * stays beside the `visibleTo` clause instead, as a prefix range on one
179
+ * ordered key (media: `nameLower`; inbox leads: `email`).
180
+ *
181
+ * `~` appears in no scope token (`org`, `host:{id}`), so the split is never
182
+ * ambiguous; a prefix that itself holds a `~` is matched whole on both sides
183
+ * and needs no escaping.
184
+ */
185
+ export declare const SCOPED_SEARCH_JOIN = "~";
186
+ /**
187
+ * The `search.scoped` half of a list declaration, over the array the
188
+ * writers stamp with {@link scopedSearchTokens}.
189
+ */
190
+ export declare function scopedSearch(tokensPath: string): {
191
+ tokensPath: string;
192
+ join: typeof SCOPED_SEARCH_JOIN;
193
+ };
194
+ /**
195
+ * Every scope a record is visible to, joined to every search prefix it
196
+ * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,
197
+ * with no duplicates (AGL-3321).
198
+ *
199
+ * `visibleTo` is read defensively — a record's stored array — and keeps
200
+ * every non-empty string in it; anything else answers no scope, and so no
201
+ * scoped tokens: a record visible to nobody is found by no scoped search.
202
+ * `tokens` is whatever the record's plain search array holds, so a list that
203
+ * searches several fields passes their merged prefixes rather than one
204
+ * name's. Each is stamped beside the plain array on every write that sets
205
+ * either the searched text or `visibleTo`.
206
+ *
207
+ * The count is the product of the two, so a record placed on thirty sites
208
+ * with a long name carries thousands of entries. That stays far inside
209
+ * Firestore's per-document index-entry ceiling, and it is the price of a
210
+ * search a scoped reader can run at all.
211
+ */
212
+ export declare function scopedSearchTokens(visibleTo: unknown, tokens: readonly string[]): string[];
@@ -141,6 +141,35 @@
141
141
  nameReversed: nameSearchReversed(name)
142
142
  };
143
143
  }
144
+ /**
145
+ * The search fields a document NAMED BY `displayName` carries (AGL-3321):
146
+ * the site artifacts (screens of every kind, layouts, reusable components,
147
+ * templates) and the marketplace listings.
148
+ *
149
+ * `nameSearchFields` is for documents whose name IS `name`. These documents
150
+ * name themselves `displayName`, and have since before search existed, so
151
+ * the keys ride beside it under the same three names every list declares —
152
+ * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,
153
+ * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.
154
+ *
155
+ * `displayName` is NOT returned. A create that carries no name must not gain
156
+ * an empty one: readers fall back with `displayName ?? id`, which an empty
157
+ * string defeats. But the KEYS are always returned, empty for a missing name,
158
+ * so a create stamps them unconditionally — an `orderBy('nameLower')` drops
159
+ * any document that lacks the field, and a document created without a name
160
+ * is still one a list must reach.
161
+ *
162
+ * ⚠️ Spread at every write that sets `displayName`, and at every create —
163
+ * and nowhere else: a partial update that stamped keys for a name it did not
164
+ * write would make the document unfindable by the name it still shows.
165
+ */ export function displayNameSearchFields(displayName) {
166
+ const name = typeof displayName === 'string' ? displayName : '';
167
+ return {
168
+ nameLower: nameSearchKey(name),
169
+ nameTokens: nameSearchTokens(name),
170
+ nameReversed: nameSearchReversed(name)
171
+ };
172
+ }
144
173
  /**
145
174
  * The normalizers a list query plan (`planListQuery` in
146
175
  * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys
@@ -152,5 +181,73 @@
152
181
  reversed: (value)=>nameSearchReversed(value),
153
182
  maxPrefix: NAME_TOKEN_MAX_PREFIX
154
183
  };
184
+ /**
185
+ * What joins a scope token to a search prefix in a SCOPED search token
186
+ * (AGL-3321): `host:abc~acm`.
187
+ *
188
+ * A list under a scope clause (`visibleTo array-contains-any [...]`) has
189
+ * spent Firestore's one array clause on the scope, so its search cannot be a
190
+ * second `array-contains`. The list query plan folds the typed word INTO the scope
191
+ * clause instead (`ListQueryDeclaration.search.scoped`), asking for each
192
+ * scope token joined to the word; the writers stamp every scope token
193
+ * joined to every prefix, through {@link scopedSearchTokens}. A declaration
194
+ * names this constant as its `join`, so the writer and the reader cannot
195
+ * disagree about the character between the two halves.
196
+ *
197
+ * ⛔ Security rules cannot prove a folded query for a reader limited to some
198
+ * sites. Rules like `canReadScoped` grant a list from
199
+ * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only
200
+ * the scoped-token field says nothing about `visibleTo`, so Firestore denies
201
+ * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search
202
+ * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an
203
+ * org-wide member, whose rule short-circuits) or for an Admin-SDK route that
204
+ * checks the reader's scope itself. A partial-reach collaborator's search
205
+ * stays beside the `visibleTo` clause instead, as a prefix range on one
206
+ * ordered key (media: `nameLower`; inbox leads: `email`).
207
+ *
208
+ * `~` appears in no scope token (`org`, `host:{id}`), so the split is never
209
+ * ambiguous; a prefix that itself holds a `~` is matched whole on both sides
210
+ * and needs no escaping.
211
+ */ export const SCOPED_SEARCH_JOIN = '~';
212
+ /**
213
+ * The `search.scoped` half of a list declaration, over the array the
214
+ * writers stamp with {@link scopedSearchTokens}.
215
+ */ export function scopedSearch(tokensPath) {
216
+ return {
217
+ tokensPath,
218
+ join: SCOPED_SEARCH_JOIN
219
+ };
220
+ }
221
+ /**
222
+ * Every scope a record is visible to, joined to every search prefix it
223
+ * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,
224
+ * with no duplicates (AGL-3321).
225
+ *
226
+ * `visibleTo` is read defensively — a record's stored array — and keeps
227
+ * every non-empty string in it; anything else answers no scope, and so no
228
+ * scoped tokens: a record visible to nobody is found by no scoped search.
229
+ * `tokens` is whatever the record's plain search array holds, so a list that
230
+ * searches several fields passes their merged prefixes rather than one
231
+ * name's. Each is stamped beside the plain array on every write that sets
232
+ * either the searched text or `visibleTo`.
233
+ *
234
+ * The count is the product of the two, so a record placed on thirty sites
235
+ * with a long name carries thousands of entries. That stays far inside
236
+ * Firestore's per-document index-entry ceiling, and it is the price of a
237
+ * search a scoped reader can run at all.
238
+ */ export function scopedSearchTokens(visibleTo, tokens) {
239
+ if (!Array.isArray(visibleTo)) return [];
240
+ const scopes = [
241
+ ...new Set(visibleTo.filter((entry)=>typeof entry === 'string' && entry.length > 0))
242
+ ];
243
+ const words = [
244
+ ...new Set(tokens.filter((token)=>typeof token === 'string' && token))
245
+ ];
246
+ const scoped = [];
247
+ for (const scope of scopes){
248
+ for (const word of words)scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`);
249
+ }
250
+ return scoped;
251
+ }
155
252
 
156
253
  //# sourceMappingURL=name-search.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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 * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n return (key.split(' ')[0] ?? '').slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","size","nameSearchToken","query","nameSearchReversed","reverse","join","nameSearchFields","nameLower","nameTokens","nameReversed","nameSearchNormalizers","value","token","reversed","maxPrefix"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,cAAcC,IAA+B;IAC3D,OAAO,CAACA,eAAAA,OAAQ,IAAIC,IAAI,GAAGC,OAAO,CAAC,QAAQ,KAAKC,WAAW;AAC7D;AAEA;;;;;;;;CAQC,GACD,OAAO,MAAMC,wBAAwB,GAAE;AAEvC,mFAAmF,GACnF,OAAO,MAAMC,mBAAmB,IAAG;AAEnC;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASC,iBAAiBN,IAA+B;IAC9D,MAAMO,MAAMR,cAAcC;IAC1B,IAAI,CAACO,KAAK,OAAO,EAAE;IACnB,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,QAAQH,IAAII,KAAK,CAAC,KAAM;QACjC,IAAI,CAACD,MAAM;QACX,MAAME,SAASF,KAAKG,KAAK,CAAC,GAAGT;QAC7B,IAAK,IAAIU,MAAM,GAAGA,OAAOF,OAAOG,MAAM,EAAED,OAAO,EAAG;YAChDN,OAAOQ,GAAG,CAACJ,OAAOC,KAAK,CAAC,GAAGC;YAC3B,IAAIN,OAAOS,IAAI,IAAIZ,kBAAkB,OAAO;mBAAIG;aAAO;QACzD;IACF;IACA,OAAO;WAAIA;KAAO;AACpB;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASU,gBAAgBC,KAAgC;QAGtDZ;IAFR,MAAMA,MAAMR,cAAcoB;IAC1B,IAAI,CAACZ,KAAK,OAAO;IACjB,OAAO,EAACA,cAAAA,IAAII,KAAK,CAAC,IAAI,CAAC,EAAE,YAAjBJ,cAAqB,IAAIM,KAAK,CAAC,GAAGT;AAC5C;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASgB,mBAAmBpB,IAA+B;IAChE,OAAO;WAAID,cAAcC;KAAM,CAACqB,OAAO,GAAGC,IAAI,CAAC;AACjD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBAAiBvB,IAAY;IAM3C,8EAA8E;IAC9E,0EAA0E;IAC1E,2EAA2E;IAC3E,8BAA8B;IAC9B,OAAO;QACLA;QACAwB,WAAWzB,cAAcC;QACzByB,YAAYnB,iBAAiBN;QAC7B0B,cAAcN,mBAAmBpB;IACnC;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAM2B,wBAAwB;IACnCpB,KAAK,CAACqB,QAAkB7B,cAAc6B;IACtCC,OAAO,CAACD,QAAkBV,gBAAgBU;IAC1CE,UAAU,CAACF,QAAkBR,mBAAmBQ;IAChDG,WAAW3B;AACb,EAAU"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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 * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n return (key.split(' ')[0] ?? '').slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The search fields a document NAMED BY `displayName` carries (AGL-3321):\n * the site artifacts (screens of every kind, layouts, reusable components,\n * templates) and the marketplace listings.\n *\n * `nameSearchFields` is for documents whose name IS `name`. These documents\n * name themselves `displayName`, and have since before search existed, so\n * the keys ride beside it under the same three names every list declares —\n * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,\n * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.\n *\n * `displayName` is NOT returned. A create that carries no name must not gain\n * an empty one: readers fall back with `displayName ?? id`, which an empty\n * string defeats. But the KEYS are always returned, empty for a missing name,\n * so a create stamps them unconditionally — an `orderBy('nameLower')` drops\n * any document that lacks the field, and a document created without a name\n * is still one a list must reach.\n *\n * ⚠️ Spread at every write that sets `displayName`, and at every create —\n * and nowhere else: a partial update that stamped keys for a name it did not\n * write would make the document unfindable by the name it still shows.\n */\nexport function displayNameSearchFields(displayName: unknown): {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n const name = typeof displayName === 'string' ? displayName : ''\n return {\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n\n/**\n * What joins a scope token to a search prefix in a SCOPED search token\n * (AGL-3321): `host:abc~acm`.\n *\n * A list under a scope clause (`visibleTo array-contains-any [...]`) has\n * spent Firestore's one array clause on the scope, so its search cannot be a\n * second `array-contains`. The list query plan folds the typed word INTO the scope\n * clause instead (`ListQueryDeclaration.search.scoped`), asking for each\n * scope token joined to the word; the writers stamp every scope token\n * joined to every prefix, through {@link scopedSearchTokens}. A declaration\n * names this constant as its `join`, so the writer and the reader cannot\n * disagree about the character between the two halves.\n *\n * ⛔ Security rules cannot prove a folded query for a reader limited to some\n * sites. Rules like `canReadScoped` grant a list from\n * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only\n * the scoped-token field says nothing about `visibleTo`, so Firestore denies\n * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search\n * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an\n * org-wide member, whose rule short-circuits) or for an Admin-SDK route that\n * checks the reader's scope itself. A partial-reach collaborator's search\n * stays beside the `visibleTo` clause instead, as a prefix range on one\n * ordered key (media: `nameLower`; inbox leads: `email`).\n *\n * `~` appears in no scope token (`org`, `host:{id}`), so the split is never\n * ambiguous; a prefix that itself holds a `~` is matched whole on both sides\n * and needs no escaping.\n */\nexport const SCOPED_SEARCH_JOIN = '~'\n\n/**\n * The `search.scoped` half of a list declaration, over the array the\n * writers stamp with {@link scopedSearchTokens}.\n */\nexport function scopedSearch(tokensPath: string): {\n tokensPath: string\n join: typeof SCOPED_SEARCH_JOIN\n} {\n return { tokensPath, join: SCOPED_SEARCH_JOIN }\n}\n\n/**\n * Every scope a record is visible to, joined to every search prefix it\n * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,\n * with no duplicates (AGL-3321).\n *\n * `visibleTo` is read defensively — a record's stored array — and keeps\n * every non-empty string in it; anything else answers no scope, and so no\n * scoped tokens: a record visible to nobody is found by no scoped search.\n * `tokens` is whatever the record's plain search array holds, so a list that\n * searches several fields passes their merged prefixes rather than one\n * name's. Each is stamped beside the plain array on every write that sets\n * either the searched text or `visibleTo`.\n *\n * The count is the product of the two, so a record placed on thirty sites\n * with a long name carries thousands of entries. That stays far inside\n * Firestore's per-document index-entry ceiling, and it is the price of a\n * search a scoped reader can run at all.\n */\nexport function scopedSearchTokens(\n visibleTo: unknown,\n tokens: readonly string[],\n): string[] {\n if (!Array.isArray(visibleTo)) return []\n const scopes = [\n ...new Set(\n visibleTo.filter(\n (entry): entry is string => typeof entry === 'string' && entry.length > 0,\n ),\n ),\n ]\n const words = [...new Set(tokens.filter((token) => typeof token === 'string' && token))]\n const scoped: string[] = []\n for (const scope of scopes) {\n for (const word of words) scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`)\n }\n return scoped\n}\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","size","nameSearchToken","query","nameSearchReversed","reverse","join","nameSearchFields","nameLower","nameTokens","nameReversed","displayNameSearchFields","displayName","nameSearchNormalizers","value","token","reversed","maxPrefix","SCOPED_SEARCH_JOIN","scopedSearch","tokensPath","scopedSearchTokens","visibleTo","Array","isArray","scopes","filter","entry","words","scoped","scope","push"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASA,cAAcC,IAA+B;IAC3D,OAAO,CAACA,eAAAA,OAAQ,IAAIC,IAAI,GAAGC,OAAO,CAAC,QAAQ,KAAKC,WAAW;AAC7D;AAEA;;;;;;;;CAQC,GACD,OAAO,MAAMC,wBAAwB,GAAE;AAEvC,mFAAmF,GACnF,OAAO,MAAMC,mBAAmB,IAAG;AAEnC;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASC,iBAAiBN,IAA+B;IAC9D,MAAMO,MAAMR,cAAcC;IAC1B,IAAI,CAACO,KAAK,OAAO,EAAE;IACnB,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,QAAQH,IAAII,KAAK,CAAC,KAAM;QACjC,IAAI,CAACD,MAAM;QACX,MAAME,SAASF,KAAKG,KAAK,CAAC,GAAGT;QAC7B,IAAK,IAAIU,MAAM,GAAGA,OAAOF,OAAOG,MAAM,EAAED,OAAO,EAAG;YAChDN,OAAOQ,GAAG,CAACJ,OAAOC,KAAK,CAAC,GAAGC;YAC3B,IAAIN,OAAOS,IAAI,IAAIZ,kBAAkB,OAAO;mBAAIG;aAAO;QACzD;IACF;IACA,OAAO;WAAIA;KAAO;AACpB;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASU,gBAAgBC,KAAgC;QAGtDZ;IAFR,MAAMA,MAAMR,cAAcoB;IAC1B,IAAI,CAACZ,KAAK,OAAO;IACjB,OAAO,EAACA,cAAAA,IAAII,KAAK,CAAC,IAAI,CAAC,EAAE,YAAjBJ,cAAqB,IAAIM,KAAK,CAAC,GAAGT;AAC5C;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASgB,mBAAmBpB,IAA+B;IAChE,OAAO;WAAID,cAAcC;KAAM,CAACqB,OAAO,GAAGC,IAAI,CAAC;AACjD;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASC,iBAAiBvB,IAAY;IAM3C,8EAA8E;IAC9E,0EAA0E;IAC1E,2EAA2E;IAC3E,8BAA8B;IAC9B,OAAO;QACLA;QACAwB,WAAWzB,cAAcC;QACzByB,YAAYnB,iBAAiBN;QAC7B0B,cAAcN,mBAAmBpB;IACnC;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAAS2B,wBAAwBC,WAAoB;IAK1D,MAAM5B,OAAO,OAAO4B,gBAAgB,WAAWA,cAAc;IAC7D,OAAO;QACLJ,WAAWzB,cAAcC;QACzByB,YAAYnB,iBAAiBN;QAC7B0B,cAAcN,mBAAmBpB;IACnC;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAM6B,wBAAwB;IACnCtB,KAAK,CAACuB,QAAkB/B,cAAc+B;IACtCC,OAAO,CAACD,QAAkBZ,gBAAgBY;IAC1CE,UAAU,CAACF,QAAkBV,mBAAmBU;IAChDG,WAAW7B;AACb,EAAU;AAGV;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,MAAM8B,qBAAqB,IAAG;AAErC;;;CAGC,GACD,OAAO,SAASC,aAAaC,UAAkB;IAI7C,OAAO;QAAEA;QAAYd,MAAMY;IAAmB;AAChD;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASG,mBACdC,SAAkB,EAClB9B,MAAyB;IAEzB,IAAI,CAAC+B,MAAMC,OAAO,CAACF,YAAY,OAAO,EAAE;IACxC,MAAMG,SAAS;WACV,IAAIhC,IACL6B,UAAUI,MAAM,CACd,CAACC,QAA2B,OAAOA,UAAU,YAAYA,MAAM5B,MAAM,GAAG;KAG7E;IACD,MAAM6B,QAAQ;WAAI,IAAInC,IAAID,OAAOkC,MAAM,CAAC,CAACX,QAAU,OAAOA,UAAU,YAAYA;KAAQ;IACxF,MAAMc,SAAmB,EAAE;IAC3B,KAAK,MAAMC,SAASL,OAAQ;QAC1B,KAAK,MAAM/B,QAAQkC,MAAOC,OAAOE,IAAI,CAAC,GAAGD,QAAQZ,qBAAqBxB,MAAM;IAC9E;IACA,OAAOmC;AACT"}
@@ -31,6 +31,7 @@ export * from './collection-entries';
31
31
  export * from './collection-kind';
32
32
  export * from './collection-slug';
33
33
  export * from './content-authors';
34
+ export * from './content-query-fields';
34
35
  export * from './content-schema-type';
35
36
  export * from './content-author-profile';
36
37
  export * from './url-slug';
@@ -133,6 +134,7 @@ export * from './resolve-named-tokens';
133
134
  export * from './screen-route';
134
135
  export * from './host-naming';
135
136
  export * from './name-search';
137
+ export * from './artifact-list-keys';
136
138
  export * from './name-match';
137
139
  export * from './api-plugins';
138
140
  export * from './plugin-api-rate-limit';
@@ -34,6 +34,7 @@ export * from "./collection-entries.js";
34
34
  export * from "./collection-kind.js";
35
35
  export * from "./collection-slug.js";
36
36
  export * from "./content-authors.js";
37
+ export * from "./content-query-fields.js";
37
38
  export * from "./content-schema-type.js";
38
39
  export * from "./content-author-profile.js";
39
40
  export * from "./url-slug.js";
@@ -266,6 +267,7 @@ export * from "./resolve-named-tokens.js";
266
267
  export * from "./screen-route.js";
267
268
  export * from "./host-naming.js";
268
269
  export * from "./name-search.js";
270
+ export * from "./artifact-list-keys.js";
269
271
  export * from "./name-match.js";
270
272
  export * from "./api-plugins.js";
271
273
  export * from "./plugin-api-rate-limit.js";