@aglyn/aglyn 1.0.0-beta.230 → 1.0.0-beta.231

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 (112) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/crm.d.ts +14 -1
  3. package/src/lib/app-utils/crm.js +19 -2
  4. package/src/lib/app-utils/crm.js.map +1 -1
  5. package/src/lib/app-utils/docs-help.generated.d.ts +80 -2
  6. package/src/lib/app-utils/docs-help.generated.js +209 -1
  7. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  8. package/src/lib/app-utils/docs-index.generated.js +517 -20
  9. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  10. package/src/lib/app-utils/host-status.d.ts +85 -0
  11. package/src/lib/app-utils/host-status.js +115 -0
  12. package/src/lib/app-utils/host-status.js.map +1 -0
  13. package/src/lib/app-utils/lockdown.js +1 -1
  14. package/src/lib/app-utils/lockdown.js.map +1 -1
  15. package/src/lib/app-utils/media-filter.d.ts +136 -0
  16. package/src/lib/app-utils/media-filter.js +400 -0
  17. package/src/lib/app-utils/media-filter.js.map +1 -0
  18. package/src/lib/app-utils/mobile-push.d.ts +98 -0
  19. package/src/lib/app-utils/mobile-push.js +97 -0
  20. package/src/lib/app-utils/mobile-push.js.map +1 -0
  21. package/src/lib/app-utils/notification-push.d.ts +38 -0
  22. package/src/lib/app-utils/notification-push.js +54 -0
  23. package/src/lib/app-utils/notification-push.js.map +1 -0
  24. package/src/lib/app-utils/notifications.d.ts +7 -0
  25. package/src/lib/app-utils/notifications.js.map +1 -1
  26. package/src/lib/app-utils/organizations.js +5 -2
  27. package/src/lib/app-utils/organizations.js.map +1 -1
  28. package/src/lib/app-utils/plugin-host-events.generated.d.ts +1 -1
  29. package/src/lib/app-utils/plugin-host-events.generated.js +149 -0
  30. package/src/lib/app-utils/plugin-host-events.generated.js.map +1 -1
  31. package/src/lib/app-utils/plugin-release-flags.generated.d.ts +1 -1
  32. package/src/lib/app-utils/plugin-release-flags.generated.js +7 -0
  33. package/src/lib/app-utils/plugin-release-flags.generated.js.map +1 -1
  34. package/src/lib/app-utils/realm-host-surface.generated.js +3 -0
  35. package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
  36. package/src/lib/app-utils/release-flags.js +6 -2
  37. package/src/lib/app-utils/release-flags.js.map +1 -1
  38. package/src/lib/app-utils/scope-tokens.d.ts +16 -1
  39. package/src/lib/app-utils/scope-tokens.js +15 -1
  40. package/src/lib/app-utils/scope-tokens.js.map +1 -1
  41. package/src/lib/app-utils/site-list-query.d.ts +47 -0
  42. package/src/lib/app-utils/site-list-query.js +142 -0
  43. package/src/lib/app-utils/site-list-query.js.map +1 -0
  44. package/src/lib/app-utils/site-wide-outbox.d.ts +95 -0
  45. package/src/lib/app-utils/site-wide-outbox.js +117 -0
  46. package/src/lib/app-utils/site-wide-outbox.js.map +1 -0
  47. package/src/lib/app-utils/transfer-launcher-context.d.ts +6 -0
  48. package/src/lib/app-utils/transfer-launcher-context.js.map +1 -1
  49. package/src/lib/app-utils/upload-inspection.js +7 -0
  50. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  51. package/src/lib/app-utils/webhook-delivery.js +4 -1
  52. package/src/lib/app-utils/webhook-delivery.js.map +1 -1
  53. package/src/lib/foundation/definitions/org-billing.types.d.ts +8 -0
  54. package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
  55. package/src/lib/foundation/definitions/organization.types.d.ts +18 -7
  56. package/src/lib/foundation/definitions/organization.types.js.map +1 -1
  57. package/src/lib/foundation/definitions/write-deny-coverage.util.d.ts +4 -1
  58. package/src/lib/foundation/definitions/write-deny-coverage.util.js +12 -2
  59. package/src/lib/foundation/definitions/write-deny-coverage.util.js.map +1 -1
  60. package/src/lib/plugin-manager/enabled-plugins.js +4 -2
  61. package/src/lib/plugin-manager/enabled-plugins.js.map +1 -1
  62. package/src/lib/plugin-manager/feature-plugins.d.ts +88 -0
  63. package/src/lib/plugin-manager/feature-plugins.js +44 -0
  64. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  65. package/src/lib/plugin-manager/first-party-plugins.generated.js +222 -2
  66. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  67. package/src/lib/plugin-manager/plugin-checkout-extras.d.ts +168 -0
  68. package/src/lib/plugin-manager/plugin-checkout-extras.js +172 -0
  69. package/src/lib/plugin-manager/plugin-checkout-extras.js.map +1 -0
  70. package/src/lib/plugin-manager/plugin-contributions.d.ts +7 -0
  71. package/src/lib/plugin-manager/plugin-contributions.js +1 -1
  72. package/src/lib/plugin-manager/plugin-contributions.js.map +1 -1
  73. package/src/lib/plugin-manager/plugin-domain-events.d.ts +138 -0
  74. package/src/lib/plugin-manager/plugin-domain-events.js +148 -0
  75. package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -0
  76. package/src/lib/plugin-manager/plugin-events.d.ts +30 -0
  77. package/src/lib/plugin-manager/plugin-events.js +4 -0
  78. package/src/lib/plugin-manager/plugin-events.js.map +1 -1
  79. package/src/lib/plugin-manager/plugin-fulfillment-providers.d.ts +101 -0
  80. package/src/lib/plugin-manager/plugin-fulfillment-providers.js +83 -0
  81. package/src/lib/plugin-manager/plugin-fulfillment-providers.js.map +1 -0
  82. package/src/lib/plugin-manager/plugin-permissions.js +21 -5
  83. package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
  84. package/src/lib/plugin-manager/plugin-person-records.d.ts +90 -0
  85. package/src/lib/plugin-manager/plugin-person-records.js +26 -0
  86. package/src/lib/plugin-manager/plugin-person-records.js.map +1 -1
  87. package/src/lib/plugin-manager/plugin-product-catalog.d.ts +204 -0
  88. package/src/lib/plugin-manager/plugin-product-catalog.js +43 -0
  89. package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -0
  90. package/src/lib/plugin-manager/plugin-shipment-records.d.ts +210 -0
  91. package/src/lib/plugin-manager/plugin-shipment-records.js +63 -0
  92. package/src/lib/plugin-manager/plugin-shipment-records.js.map +1 -0
  93. package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +151 -0
  94. package/src/lib/plugin-manager/plugin-shipping-rates.js +62 -0
  95. package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -0
  96. package/src/lib/plugin-manager/plugin-sms-messaging.d.ts +103 -0
  97. package/src/lib/plugin-manager/plugin-sms-messaging.js +39 -0
  98. package/src/lib/plugin-manager/plugin-sms-messaging.js.map +1 -0
  99. package/src/lib/plugin-manager/plugin-stock-levels.d.ts +81 -0
  100. package/src/lib/plugin-manager/plugin-stock-levels.js +32 -0
  101. package/src/lib/plugin-manager/plugin-stock-levels.js.map +1 -0
  102. package/src/lib/plugin-manager/plugin-tax-profile.d.ts +154 -0
  103. package/src/lib/plugin-manager/plugin-tax-profile.js +56 -0
  104. package/src/lib/plugin-manager/plugin-tax-profile.js.map +1 -1
  105. package/src/lib/plugin-manager/plugin-theme-font-catalog.d.ts +59 -0
  106. package/src/lib/plugin-manager/plugin-theme-font-catalog.js +40 -0
  107. package/src/lib/plugin-manager/plugin-theme-font-catalog.js.map +1 -0
  108. package/src/lib/plugin-manager/plugin-tracking-pages.d.ts +55 -0
  109. package/src/lib/plugin-manager/plugin-tracking-pages.js +76 -0
  110. package/src/lib/plugin-manager/plugin-tracking-pages.js.map +1 -0
  111. package/src/lib/plugin-manager/realm-host-aglyn.generated.js +3 -0
  112. package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
@@ -0,0 +1,400 @@
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 { mediaSearchToken } from "./media-metadata.js";
18
+ import { nameSearchNormalizers } from "./name-search.js";
19
+ import { planListQuery } from "@aglyn/shared-util-tools/list-query/list-query-plan";
20
+ /*
21
+ * What the media library filters and searches by, declared for the
22
+ * console's list query plan (AGL-3327).
23
+ *
24
+ * The library composes every clause and the search word onto ONE Firestore
25
+ * query through `planListQuery` (AGL-3321) — nothing is matched over the
26
+ * pages already read. What one query cannot hold (a second array clause, a
27
+ * second range, more than thirty disjunctions) the plan refuses by name and
28
+ * the library says so; the rest still stands.
29
+ *
30
+ * Every field is stored on the media document by every writer
31
+ * (`mediaFilterKeys` in `@aglyn/aglyn/app-utils/media-metadata`) and by the
32
+ * backfill for the documents written before (`backfill-media-filter-keys`):
33
+ *
34
+ * type `kind`, the family (image, video, pdf, document)
35
+ * tags `tags`, lower-cased by every writer, matched whole
36
+ * uploaded `createdAt`
37
+ * size `sizeBytes`, asked in megabytes
38
+ * uploadedBy `uploadedBy`, a uid, or `api:<key id>` from the REST API
39
+ * alt `hasAlt`, so "missing" is a value a query can find
40
+ * orientation `orientation`, from the stored pixel size
41
+ * name `nameLower`, for the Name sort and "starts with"
42
+ * search `nameTokens`, the word prefixes of the file name
43
+ *
44
+ * Not offered, because no query serves them: custom metadata (its keys are
45
+ * the reader's own, and each would need composites of its own), the width
46
+ * and the height as ranges (each a range field that would have to lead the
47
+ * order, with composites for every other field — Orientation answers the
48
+ * question people ask of them), and "contains" on the alt text itself.
49
+ *
50
+ * Firestore merges one `[field, sort]` composite per field for any
51
+ * combination of equalities; `media-scope-indexes.spec.ts` holds the index
52
+ * file to `listQueryIndexes` of this declaration.
53
+ */ /** The family a file belongs to, as the Type filter offers it. */ export const MEDIA_TYPE_OPTIONS = [
54
+ {
55
+ value: 'image',
56
+ label: 'Images'
57
+ },
58
+ {
59
+ value: 'video',
60
+ label: 'Video'
61
+ },
62
+ {
63
+ value: 'pdf',
64
+ label: 'PDF'
65
+ },
66
+ {
67
+ value: 'document',
68
+ label: 'Other documents'
69
+ }
70
+ ];
71
+ /** Orientation, as the stored `orientation` names it. */ export const MEDIA_ORIENTATION_OPTIONS = [
72
+ {
73
+ value: 'landscape',
74
+ label: 'Landscape'
75
+ },
76
+ {
77
+ value: 'portrait',
78
+ label: 'Portrait'
79
+ },
80
+ {
81
+ value: 'square',
82
+ label: 'Square'
83
+ }
84
+ ];
85
+ /** Alt text, as the two answers a query can give about it. */ export const MEDIA_ALT_OPTIONS = [
86
+ {
87
+ value: 'false',
88
+ label: 'Missing'
89
+ },
90
+ {
91
+ value: 'true',
92
+ label: 'Set'
93
+ }
94
+ ];
95
+ const MB = 1024 * 1024;
96
+ /**
97
+ * The fields the Filters panel offers, each as the query serves it.
98
+ *
99
+ * Tags is an array matched whole: `exact` with its `tokensPath`, so `contains`
100
+ * is `array-contains` and `isAnyOf` is `array-contains-any` — the panel shows
101
+ * `contains` as the select's "is" (`MEDIA_TAGS_CODEC`). Size is asked in
102
+ * megabytes and served in bytes (`mediaQueryRequest`).
103
+ */ export const MEDIA_FILTER_FIELDS = [
104
+ {
105
+ column: 'type',
106
+ kind: 'exact',
107
+ path: 'kind',
108
+ operators: [
109
+ 'equals',
110
+ 'isAnyOf'
111
+ ]
112
+ },
113
+ {
114
+ column: 'tags',
115
+ kind: 'exact',
116
+ path: 'tags',
117
+ tokensPath: 'tags',
118
+ operators: [
119
+ 'contains',
120
+ 'isAnyOf'
121
+ ]
122
+ },
123
+ {
124
+ column: 'uploaded',
125
+ kind: 'date',
126
+ path: 'createdAt',
127
+ presence: 'always'
128
+ },
129
+ {
130
+ column: 'sizeMb',
131
+ kind: 'number',
132
+ path: 'sizeBytes',
133
+ operators: [
134
+ '>',
135
+ '>=',
136
+ '<',
137
+ '<='
138
+ ]
139
+ },
140
+ {
141
+ column: 'uploadedBy',
142
+ kind: 'exact',
143
+ path: 'uploadedBy',
144
+ operators: [
145
+ 'equals',
146
+ 'isAnyOf'
147
+ ]
148
+ },
149
+ {
150
+ column: 'alt',
151
+ kind: 'boolean',
152
+ path: 'hasAlt',
153
+ operators: [
154
+ 'equals'
155
+ ]
156
+ },
157
+ {
158
+ column: 'orientation',
159
+ kind: 'exact',
160
+ path: 'orientation',
161
+ operators: [
162
+ 'equals',
163
+ 'isAnyOf'
164
+ ]
165
+ },
166
+ {
167
+ column: 'fileName',
168
+ kind: 'text',
169
+ path: 'fileName',
170
+ lowerPath: 'nameLower',
171
+ operators: [
172
+ 'startsWith'
173
+ ]
174
+ }
175
+ ];
176
+ export const MEDIA_FILTER_HEADERS = {
177
+ type: 'Type',
178
+ tags: 'Tags',
179
+ uploaded: 'Uploaded',
180
+ sizeMb: 'Size (MB)',
181
+ uploadedBy: 'Uploaded by',
182
+ alt: 'Alt text',
183
+ orientation: 'Orientation',
184
+ fileName: 'Name'
185
+ };
186
+ /** The fields one library offers. */ export function mediaFilterFields(options) {
187
+ return MEDIA_FILTER_FIELDS.filter((field)=>!(options.typeLocked && field.column === 'type') && !(options.scoped && field.column === 'tags'));
188
+ }
189
+ export const MEDIA_SORTS = [
190
+ 'newest',
191
+ 'oldest',
192
+ 'name',
193
+ 'size'
194
+ ];
195
+ /** What each sort orders by, and the List view column that sorts it. */ export const MEDIA_SORT_ORDER = {
196
+ newest: {
197
+ path: 'createdAt',
198
+ direction: 'desc',
199
+ column: 'uploaded'
200
+ },
201
+ oldest: {
202
+ path: 'createdAt',
203
+ direction: 'asc',
204
+ column: 'uploaded'
205
+ },
206
+ // `nameLower`, so the order ignores case, and a "starts with" range — the
207
+ // name's own range — orders the list the way this sort already does.
208
+ name: {
209
+ path: 'nameLower',
210
+ direction: 'asc',
211
+ column: 'fileName'
212
+ },
213
+ size: {
214
+ path: 'sizeBytes',
215
+ direction: 'desc',
216
+ column: 'size'
217
+ }
218
+ };
219
+ export const MEDIA_SORT_LABELS = {
220
+ newest: 'Newest',
221
+ oldest: 'Oldest',
222
+ name: 'Name',
223
+ size: 'Largest'
224
+ };
225
+ /** The library's sort for a query order, or null for one it does not offer. */ export function mediaSortOf(order) {
226
+ const found = MEDIA_SORTS.find((sort)=>MEDIA_SORT_ORDER[sort].path === order.path && MEDIA_SORT_ORDER[sort].direction === order.direction);
227
+ return found != null ? found : null;
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // The query
231
+ // ---------------------------------------------------------------------------
232
+ /**
233
+ * The library as the list query plan reads it. Newest first is the default,
234
+ * and every sort is one the index file holds for every field.
235
+ */ export const MEDIA_LIST_QUERY = {
236
+ fields: MEDIA_FILTER_FIELDS,
237
+ sorts: MEDIA_SORTS.map((sort)=>MEDIA_SORT_ORDER[sort]),
238
+ search: {
239
+ tokensPath: 'nameTokens'
240
+ }
241
+ };
242
+ /**
243
+ * The normalizers the media writers stamped the name fields with: the
244
+ * platform's name search, except that a file name's words are split at every
245
+ * separator (`mediaSearchToken`), so `hero-banner.png` is found by "banner".
246
+ */ export const MEDIA_NAME_NORMALIZERS = _extends({}, nameSearchNormalizers, {
247
+ token: (value)=>mediaSearchToken(value)
248
+ });
249
+ /** The predicates every query of one library view carries, before any clause. */ export function mediaBaseFilters(input) {
250
+ const { folder, scopeTokens } = input;
251
+ return [
252
+ ...folder === 'root' ? [
253
+ {
254
+ path: 'folderId',
255
+ op: '==',
256
+ value: null
257
+ }
258
+ ] : folder === 'all' ? [] : folder.length === 1 ? [
259
+ {
260
+ path: 'folderId',
261
+ op: '==',
262
+ value: folder[0]
263
+ }
264
+ ] : [
265
+ {
266
+ path: 'folderId',
267
+ op: 'in',
268
+ value: [
269
+ ...folder
270
+ ]
271
+ }
272
+ ],
273
+ ...scopeTokens ? [
274
+ {
275
+ path: 'visibleTo',
276
+ op: 'array-contains-any',
277
+ value: [
278
+ ...scopeTokens
279
+ ]
280
+ }
281
+ ] : []
282
+ ];
283
+ }
284
+ /** The scope predicates, as `listQueryIndexes` enumerates them. */ export const MEDIA_BASE_INDEX_FIELDS = [
285
+ {
286
+ path: 'folderId'
287
+ },
288
+ {
289
+ path: 'visibleTo',
290
+ array: true
291
+ }
292
+ ];
293
+ /** Firestore's cap on disjunctions: the product of every `in` and `-any`. */ export const MEDIA_DISJUNCTION_LIMIT = 30;
294
+ /** A clause as the query asks it: a size in bytes, the rest as written. */ export function mediaQueryRequest(clause) {
295
+ if (clause.field !== 'sizeMb') return clause;
296
+ const megabytes = Number(clause.value);
297
+ return Number.isFinite(megabytes) && String(clause.value).trim() !== '' ? _extends({}, clause, {
298
+ value: String(Math.round(megabytes * MB))
299
+ }) : clause;
300
+ }
301
+ /** Said when a scoped reader's search is served as a name range. */ export const MEDIA_SCOPED_SEARCH_NOTICE = 'In a library limited to some sites, search finds files whose name ' + 'starts with what you type.';
302
+ /**
303
+ * A scoped reader's search, as the clause their query can serve (AGL-3327).
304
+ *
305
+ * The quick search is `array-contains` on `nameTokens`, and the scope clause
306
+ * a scoped reader's query must carry (`visibleTo array-contains-any`,
307
+ * AGL-1042) is the query's one array filter, so the two cannot share one
308
+ * query. Folding the word into the scope clause (`search.scoped` in the list
309
+ * query plan) cannot be used here either: the security rules prove a scoped
310
+ * reader's list from its `visibleTo` clause, and a query that asks only for
311
+ * `<scope>~<word>` tokens proves nothing about `visibleTo`, so Firestore
312
+ * refuses it outright (probed against the emulator, AGL-3327). What the rules
313
+ * and one query can both hold is a range on `nameLower` beside the scope
314
+ * clause: the files whose name starts with what was typed, claimed first so
315
+ * a date or size range gives way to it rather than the other way round.
316
+ */ export function mediaScopedSearchClause(search) {
317
+ const typed = search.map((word)=>word.trim()).filter(Boolean).join(' ');
318
+ return typed ? {
319
+ field: 'fileName',
320
+ op: 'startsWith',
321
+ value: typed
322
+ } : null;
323
+ }
324
+ /**
325
+ * The one query a view of the library reads (AGL-3327).
326
+ *
327
+ * The folder and the scope clause are the base every query carries. A range
328
+ * filter orders the query by its own field; where the reader's sort is on
329
+ * that field already (Oldest under an Uploaded filter), its direction stands.
330
+ * Under the scope clause the search is a name range
331
+ * (`mediaScopedSearchClause`), and the library says so.
332
+ */ export function mediaQuery(input) {
333
+ var _ref;
334
+ const { sort, scopeTokens } = input;
335
+ const scopedSearch = scopeTokens ? mediaScopedSearchClause(input.search) : null;
336
+ const clauses = scopedSearch ? [
337
+ scopedSearch,
338
+ ...input.clauses
339
+ ] : input.clauses;
340
+ const search = scopedSearch ? [] : input.search;
341
+ const tokens = (_ref = scopeTokens == null ? void 0 : scopeTokens.length) != null ? _ref : 1;
342
+ const subfoldersDropped = Array.isArray(input.folder) && input.folder.length > 1 && input.folder.length * tokens > MEDIA_DISJUNCTION_LIMIT;
343
+ const folder = subfoldersDropped && Array.isArray(input.folder) ? [
344
+ input.folder[0]
345
+ ] : input.folder;
346
+ const asked = MEDIA_SORT_ORDER[sort];
347
+ const plan = planListQuery(MEDIA_LIST_QUERY, {
348
+ base: mediaBaseFilters({
349
+ folder,
350
+ scopeTokens
351
+ }),
352
+ clauses: clauses.map(mediaQueryRequest),
353
+ search,
354
+ sort: asked
355
+ }, MEDIA_NAME_NORMALIZERS);
356
+ const orderBy = plan.orderBy.path === asked.path && plan.orderBy.direction !== asked.direction ? asked : plan.orderBy;
357
+ const notices = scopedSearch ? [
358
+ MEDIA_SCOPED_SEARCH_NOTICE,
359
+ ...plan.notices
360
+ ] : plan.notices;
361
+ return {
362
+ plan: _extends({}, plan, {
363
+ orderBy,
364
+ notices
365
+ }),
366
+ subfoldersDropped
367
+ };
368
+ }
369
+ /**
370
+ * Every folder at or under `folderId`, the folder itself first. Walks the
371
+ * parent links downward and never revisits one, so a cycle a stale write
372
+ * left behind cannot loop.
373
+ */ export function mediaFolderDescendants(folderId, folders) {
374
+ const children = new Map();
375
+ for (const folder of folders){
376
+ var _folder_parentId, _children_get;
377
+ const parent = (_folder_parentId = folder.parentId) != null ? _folder_parentId : null;
378
+ if (!parent) continue;
379
+ children.set(parent, [
380
+ ...(_children_get = children.get(parent)) != null ? _children_get : [],
381
+ folder.$id
382
+ ]);
383
+ }
384
+ const ids = [];
385
+ const queue = [
386
+ folderId
387
+ ];
388
+ const seen = new Set();
389
+ while(queue.length){
390
+ var _children_get1;
391
+ const next = queue.shift();
392
+ if (seen.has(next)) continue;
393
+ seen.add(next);
394
+ ids.push(next);
395
+ queue.push(...(_children_get1 = children.get(next)) != null ? _children_get1 : []);
396
+ }
397
+ return ids;
398
+ }
399
+
400
+ //# sourceMappingURL=media-filter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/media-filter.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 { mediaSearchToken } from './media-metadata'\nimport { nameSearchNormalizers } from './name-search'\nimport type {\n ListFilterField,\n ListFilterRequest,\n} from '@aglyn/shared-util-tools/list-query/list-filter'\nimport type { ListFilterOption } from '@aglyn/shared-util-tools/list-query/list-filter-codecs'\nimport {\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQueryNormalizers,\n type ListQueryPlan,\n type ListQuerySort,\n planListQuery,\n} from '@aglyn/shared-util-tools/list-query/list-query-plan'\n\n/*\n * What the media library filters and searches by, declared for the\n * console's list query plan (AGL-3327).\n *\n * The library composes every clause and the search word onto ONE Firestore\n * query through `planListQuery` (AGL-3321) — nothing is matched over the\n * pages already read. What one query cannot hold (a second array clause, a\n * second range, more than thirty disjunctions) the plan refuses by name and\n * the library says so; the rest still stands.\n *\n * Every field is stored on the media document by every writer\n * (`mediaFilterKeys` in `@aglyn/aglyn/app-utils/media-metadata`) and by the\n * backfill for the documents written before (`backfill-media-filter-keys`):\n *\n * type `kind`, the family (image, video, pdf, document)\n * tags `tags`, lower-cased by every writer, matched whole\n * uploaded `createdAt`\n * size `sizeBytes`, asked in megabytes\n * uploadedBy `uploadedBy`, a uid, or `api:<key id>` from the REST API\n * alt `hasAlt`, so \"missing\" is a value a query can find\n * orientation `orientation`, from the stored pixel size\n * name `nameLower`, for the Name sort and \"starts with\"\n * search `nameTokens`, the word prefixes of the file name\n *\n * Not offered, because no query serves them: custom metadata (its keys are\n * the reader's own, and each would need composites of its own), the width\n * and the height as ranges (each a range field that would have to lead the\n * order, with composites for every other field — Orientation answers the\n * question people ask of them), and \"contains\" on the alt text itself.\n *\n * Firestore merges one `[field, sort]` composite per field for any\n * combination of equalities; `media-scope-indexes.spec.ts` holds the index\n * file to `listQueryIndexes` of this declaration.\n */\n\n/** The family a file belongs to, as the Type filter offers it. */\nexport const MEDIA_TYPE_OPTIONS: readonly ListFilterOption[] = [\n { value: 'image', label: 'Images' },\n { value: 'video', label: 'Video' },\n { value: 'pdf', label: 'PDF' },\n { value: 'document', label: 'Other documents' },\n]\n\n/** Orientation, as the stored `orientation` names it. */\nexport const MEDIA_ORIENTATION_OPTIONS: readonly ListFilterOption[] = [\n { value: 'landscape', label: 'Landscape' },\n { value: 'portrait', label: 'Portrait' },\n { value: 'square', label: 'Square' },\n]\n\n/** Alt text, as the two answers a query can give about it. */\nexport const MEDIA_ALT_OPTIONS: readonly ListFilterOption[] = [\n { value: 'false', label: 'Missing' },\n { value: 'true', label: 'Set' },\n]\n\nconst MB = 1024 * 1024\n\n/**\n * The fields the Filters panel offers, each as the query serves it.\n *\n * Tags is an array matched whole: `exact` with its `tokensPath`, so `contains`\n * is `array-contains` and `isAnyOf` is `array-contains-any` — the panel shows\n * `contains` as the select's \"is\" (`MEDIA_TAGS_CODEC`). Size is asked in\n * megabytes and served in bytes (`mediaQueryRequest`).\n */\nexport const MEDIA_FILTER_FIELDS: readonly ListFilterField[] = [\n { column: 'type', kind: 'exact', path: 'kind', operators: ['equals', 'isAnyOf'] },\n {\n column: 'tags',\n kind: 'exact',\n path: 'tags',\n tokensPath: 'tags',\n operators: ['contains', 'isAnyOf'],\n },\n { column: 'uploaded', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'sizeMb', kind: 'number', path: 'sizeBytes', operators: ['>', '>=', '<', '<='] },\n { column: 'uploadedBy', kind: 'exact', path: 'uploadedBy', operators: ['equals', 'isAnyOf'] },\n { column: 'alt', kind: 'boolean', path: 'hasAlt', operators: ['equals'] },\n {\n column: 'orientation',\n kind: 'exact',\n path: 'orientation',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: 'fileName',\n kind: 'text',\n path: 'fileName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n },\n]\n\nexport const MEDIA_FILTER_HEADERS: Readonly<Record<string, string>> = {\n type: 'Type',\n tags: 'Tags',\n uploaded: 'Uploaded',\n sizeMb: 'Size (MB)',\n uploadedBy: 'Uploaded by',\n alt: 'Alt text',\n orientation: 'Orientation',\n fileName: 'Name',\n}\n\n/** The fields one library offers. */\nexport function mediaFilterFields(options: {\n /** A picker's fixed kind (AGL-2953): Type is not the reader's to change. */\n typeLocked: boolean\n /**\n * The query carries the scope clause (AGL-1042), which is its one array\n * clause, so Tags could never be applied and is not offered.\n */\n scoped: boolean\n}): ListFilterField[] {\n return MEDIA_FILTER_FIELDS.filter(\n (field) =>\n !(options.typeLocked && field.column === 'type') &&\n !(options.scoped && field.column === 'tags'),\n )\n}\n\n// ---------------------------------------------------------------------------\n// The sorts\n// ---------------------------------------------------------------------------\n\nexport type MediaSort = 'newest' | 'oldest' | 'name' | 'size'\n\nexport const MEDIA_SORTS: readonly MediaSort[] = ['newest', 'oldest', 'name', 'size']\n\n/** What each sort orders by, and the List view column that sorts it. */\nexport const MEDIA_SORT_ORDER: Readonly<Record<MediaSort, ListQuerySort>> = {\n newest: { path: 'createdAt', direction: 'desc', column: 'uploaded' },\n oldest: { path: 'createdAt', direction: 'asc', column: 'uploaded' },\n // `nameLower`, so the order ignores case, and a \"starts with\" range — the\n // name's own range — orders the list the way this sort already does.\n name: { path: 'nameLower', direction: 'asc', column: 'fileName' },\n size: { path: 'sizeBytes', direction: 'desc', column: 'size' },\n}\n\nexport const MEDIA_SORT_LABELS: Readonly<Record<MediaSort, string>> = {\n newest: 'Newest',\n oldest: 'Oldest',\n name: 'Name',\n size: 'Largest',\n}\n\n/** The library's sort for a query order, or null for one it does not offer. */\nexport function mediaSortOf(order: ListQuerySort): MediaSort | null {\n const found = MEDIA_SORTS.find(\n (sort) =>\n MEDIA_SORT_ORDER[sort].path === order.path &&\n MEDIA_SORT_ORDER[sort].direction === order.direction,\n )\n return found ?? null\n}\n\n// ---------------------------------------------------------------------------\n// The query\n// ---------------------------------------------------------------------------\n\n/**\n * The library as the list query plan reads it. Newest first is the default,\n * and every sort is one the index file holds for every field.\n */\nexport const MEDIA_LIST_QUERY: ListQueryDeclaration = {\n fields: MEDIA_FILTER_FIELDS,\n sorts: MEDIA_SORTS.map((sort) => MEDIA_SORT_ORDER[sort]),\n search: { tokensPath: 'nameTokens' },\n}\n\n/**\n * The normalizers the media writers stamped the name fields with: the\n * platform's name search, except that a file name's words are split at every\n * separator (`mediaSearchToken`), so `hero-banner.png` is found by \"banner\".\n */\nexport const MEDIA_NAME_NORMALIZERS: ListQueryNormalizers = {\n ...nameSearchNormalizers,\n token: (value) => mediaSearchToken(value),\n}\n\n/** The predicates every query of one library view carries, before any clause. */\nexport function mediaBaseFilters(input: {\n /** Every file, the files in no folder, or the files in these folders. */\n folder: 'all' | 'root' | readonly string[]\n /** The scope clause a scoped reader's query must carry (AGL-1042), or null. */\n scopeTokens: readonly string[] | null\n}): ListQueryFilter[] {\n const { folder, scopeTokens } = input\n return [\n ...(folder === 'root'\n ? [{ path: 'folderId', op: '==' as const, value: null }]\n : folder === 'all'\n ? []\n : folder.length === 1\n ? [{ path: 'folderId', op: '==' as const, value: folder[0] }]\n : [{ path: 'folderId', op: 'in' as const, value: [...folder] }]),\n ...(scopeTokens\n ? [{ path: 'visibleTo', op: 'array-contains-any' as const, value: [...scopeTokens] }]\n : []),\n ]\n}\n\n/** The scope predicates, as `listQueryIndexes` enumerates them. */\nexport const MEDIA_BASE_INDEX_FIELDS = [\n { path: 'folderId' },\n { path: 'visibleTo', array: true },\n] as const\n\n/** Firestore's cap on disjunctions: the product of every `in` and `-any`. */\nexport const MEDIA_DISJUNCTION_LIMIT = 30\n\n/** A clause as the query asks it: a size in bytes, the rest as written. */\nexport function mediaQueryRequest(clause: ListFilterRequest): ListFilterRequest {\n if (clause.field !== 'sizeMb') return clause\n const megabytes = Number(clause.value)\n return Number.isFinite(megabytes) && String(clause.value).trim() !== ''\n ? { ...clause, value: String(Math.round(megabytes * MB)) }\n : clause\n}\n\nexport interface MediaQueryInput {\n /** Every clause in force, a picker's fixed kind included. */\n clauses: readonly ListFilterRequest[]\n sort: MediaSort\n folder: 'all' | 'root' | readonly string[]\n scopeTokens: readonly string[] | null\n /** The quick search's words. */\n search: readonly string[]\n}\n\nexport interface MediaQuery {\n plan: ListQueryPlan\n /**\n * Whether Include subfolders had to give way to the open folder alone:\n * its subfolders times the scope's tokens pass Firestore's thirty\n * disjunctions.\n */\n subfoldersDropped: boolean\n}\n\n/** Said when a scoped reader's search is served as a name range. */\nexport const MEDIA_SCOPED_SEARCH_NOTICE =\n 'In a library limited to some sites, search finds files whose name ' +\n 'starts with what you type.'\n\n/**\n * A scoped reader's search, as the clause their query can serve (AGL-3327).\n *\n * The quick search is `array-contains` on `nameTokens`, and the scope clause\n * a scoped reader's query must carry (`visibleTo array-contains-any`,\n * AGL-1042) is the query's one array filter, so the two cannot share one\n * query. Folding the word into the scope clause (`search.scoped` in the list\n * query plan) cannot be used here either: the security rules prove a scoped\n * reader's list from its `visibleTo` clause, and a query that asks only for\n * `<scope>~<word>` tokens proves nothing about `visibleTo`, so Firestore\n * refuses it outright (probed against the emulator, AGL-3327). What the rules\n * and one query can both hold is a range on `nameLower` beside the scope\n * clause: the files whose name starts with what was typed, claimed first so\n * a date or size range gives way to it rather than the other way round.\n */\nexport function mediaScopedSearchClause(\n search: readonly string[],\n): ListFilterRequest | null {\n const typed = search.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'fileName', op: 'startsWith', value: typed } : null\n}\n\n/**\n * The one query a view of the library reads (AGL-3327).\n *\n * The folder and the scope clause are the base every query carries. A range\n * filter orders the query by its own field; where the reader's sort is on\n * that field already (Oldest under an Uploaded filter), its direction stands.\n * Under the scope clause the search is a name range\n * (`mediaScopedSearchClause`), and the library says so.\n */\nexport function mediaQuery(input: MediaQueryInput): MediaQuery {\n const { sort, scopeTokens } = input\n const scopedSearch = scopeTokens ? mediaScopedSearchClause(input.search) : null\n const clauses = scopedSearch ? [scopedSearch, ...input.clauses] : input.clauses\n const search = scopedSearch ? [] : input.search\n const tokens = scopeTokens?.length ?? 1\n const subfoldersDropped =\n Array.isArray(input.folder) &&\n input.folder.length > 1 &&\n input.folder.length * tokens > MEDIA_DISJUNCTION_LIMIT\n const folder =\n subfoldersDropped && Array.isArray(input.folder) ? [input.folder[0]] : input.folder\n const asked = MEDIA_SORT_ORDER[sort]\n const plan = planListQuery(\n MEDIA_LIST_QUERY,\n {\n base: mediaBaseFilters({ folder, scopeTokens }),\n clauses: clauses.map(mediaQueryRequest),\n search,\n sort: asked,\n },\n MEDIA_NAME_NORMALIZERS,\n )\n const orderBy =\n plan.orderBy.path === asked.path && plan.orderBy.direction !== asked.direction\n ? asked\n : plan.orderBy\n const notices = scopedSearch ? [MEDIA_SCOPED_SEARCH_NOTICE, ...plan.notices] : plan.notices\n return { plan: { ...plan, orderBy, notices }, subfoldersDropped }\n}\n\n/**\n * Every folder at or under `folderId`, the folder itself first. Walks the\n * parent links downward and never revisits one, so a cycle a stale write\n * left behind cannot loop.\n */\nexport function mediaFolderDescendants(\n folderId: string,\n folders: ReadonlyArray<{ $id: string; parentId?: string | null }>,\n): string[] {\n const children = new Map<string, string[]>()\n for (const folder of folders) {\n const parent = folder.parentId ?? null\n if (!parent) continue\n children.set(parent, [...(children.get(parent) ?? []), folder.$id])\n }\n const ids: string[] = []\n const queue = [folderId]\n const seen = new Set<string>()\n while (queue.length) {\n const next = queue.shift() as string\n if (seen.has(next)) continue\n seen.add(next)\n ids.push(next)\n queue.push(...(children.get(next) ?? []))\n }\n return ids\n}\n"],"names":["mediaSearchToken","nameSearchNormalizers","planListQuery","MEDIA_TYPE_OPTIONS","value","label","MEDIA_ORIENTATION_OPTIONS","MEDIA_ALT_OPTIONS","MB","MEDIA_FILTER_FIELDS","column","kind","path","operators","tokensPath","presence","lowerPath","MEDIA_FILTER_HEADERS","type","tags","uploaded","sizeMb","uploadedBy","alt","orientation","fileName","mediaFilterFields","options","filter","field","typeLocked","scoped","MEDIA_SORTS","MEDIA_SORT_ORDER","newest","direction","oldest","name","size","MEDIA_SORT_LABELS","mediaSortOf","order","found","find","sort","MEDIA_LIST_QUERY","fields","sorts","map","search","MEDIA_NAME_NORMALIZERS","token","mediaBaseFilters","input","folder","scopeTokens","op","length","MEDIA_BASE_INDEX_FIELDS","array","MEDIA_DISJUNCTION_LIMIT","mediaQueryRequest","clause","megabytes","Number","isFinite","String","trim","Math","round","MEDIA_SCOPED_SEARCH_NOTICE","mediaScopedSearchClause","typed","word","Boolean","join","mediaQuery","scopedSearch","clauses","tokens","subfoldersDropped","Array","isArray","asked","plan","base","orderBy","notices","mediaFolderDescendants","folderId","folders","children","Map","parent","parentId","set","get","$id","ids","queue","seen","Set","next","shift","has","add","push"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAkB;AACnD,SAASC,qBAAqB,QAAQ,mBAAe;AAMrD,SAMEC,aAAa,QACR,sDAAqD;AAE5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GAED,gEAAgE,GAChE,OAAO,MAAMC,qBAAkD;IAC7D;QAAEC,OAAO;QAASC,OAAO;IAAS;IAClC;QAAED,OAAO;QAASC,OAAO;IAAQ;IACjC;QAAED,OAAO;QAAOC,OAAO;IAAM;IAC7B;QAAED,OAAO;QAAYC,OAAO;IAAkB;CAC/C,CAAA;AAED,uDAAuD,GACvD,OAAO,MAAMC,4BAAyD;IACpE;QAAEF,OAAO;QAAaC,OAAO;IAAY;IACzC;QAAED,OAAO;QAAYC,OAAO;IAAW;IACvC;QAAED,OAAO;QAAUC,OAAO;IAAS;CACpC,CAAA;AAED,4DAA4D,GAC5D,OAAO,MAAME,oBAAiD;IAC5D;QAAEH,OAAO;QAASC,OAAO;IAAU;IACnC;QAAED,OAAO;QAAQC,OAAO;IAAM;CAC/B,CAAA;AAED,MAAMG,KAAK,OAAO;AAElB;;;;;;;CAOC,GACD,OAAO,MAAMC,sBAAkD;IAC7D;QAAEC,QAAQ;QAAQC,MAAM;QAASC,MAAM;QAAQC,WAAW;YAAC;YAAU;SAAU;IAAC;IAChF;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNE,YAAY;QACZD,WAAW;YAAC;YAAY;SAAU;IACpC;IACA;QAAEH,QAAQ;QAAYC,MAAM;QAAQC,MAAM;QAAaG,UAAU;IAAS;IAC1E;QAAEL,QAAQ;QAAUC,MAAM;QAAUC,MAAM;QAAaC,WAAW;YAAC;YAAK;YAAM;YAAK;SAAK;IAAC;IACzF;QAAEH,QAAQ;QAAcC,MAAM;QAASC,MAAM;QAAcC,WAAW;YAAC;YAAU;SAAU;IAAC;IAC5F;QAAEH,QAAQ;QAAOC,MAAM;QAAWC,MAAM;QAAUC,WAAW;YAAC;SAAS;IAAC;IACxE;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNC,WAAW;YAAC;YAAU;SAAU;IAClC;IACA;QACEH,QAAQ;QACRC,MAAM;QACNC,MAAM;QACNI,WAAW;QACXH,WAAW;YAAC;SAAa;IAC3B;CACD,CAAA;AAED,OAAO,MAAMI,uBAAyD;IACpEC,MAAM;IACNC,MAAM;IACNC,UAAU;IACVC,QAAQ;IACRC,YAAY;IACZC,KAAK;IACLC,aAAa;IACbC,UAAU;AACZ,EAAC;AAED,mCAAmC,GACnC,OAAO,SAASC,kBAAkBC,OAQjC;IACC,OAAOlB,oBAAoBmB,MAAM,CAC/B,CAACC,QACC,CAAEF,CAAAA,QAAQG,UAAU,IAAID,MAAMnB,MAAM,KAAK,MAAK,KAC9C,CAAEiB,CAAAA,QAAQI,MAAM,IAAIF,MAAMnB,MAAM,KAAK,MAAK;AAEhD;AAQA,OAAO,MAAMsB,cAAoC;IAAC;IAAU;IAAU;IAAQ;CAAO,CAAA;AAErF,sEAAsE,GACtE,OAAO,MAAMC,mBAA+D;IAC1EC,QAAQ;QAAEtB,MAAM;QAAauB,WAAW;QAAQzB,QAAQ;IAAW;IACnE0B,QAAQ;QAAExB,MAAM;QAAauB,WAAW;QAAOzB,QAAQ;IAAW;IAClE,0EAA0E;IAC1E,qEAAqE;IACrE2B,MAAM;QAAEzB,MAAM;QAAauB,WAAW;QAAOzB,QAAQ;IAAW;IAChE4B,MAAM;QAAE1B,MAAM;QAAauB,WAAW;QAAQzB,QAAQ;IAAO;AAC/D,EAAC;AAED,OAAO,MAAM6B,oBAAyD;IACpEL,QAAQ;IACRE,QAAQ;IACRC,MAAM;IACNC,MAAM;AACR,EAAC;AAED,6EAA6E,GAC7E,OAAO,SAASE,YAAYC,KAAoB;IAC9C,MAAMC,QAAQV,YAAYW,IAAI,CAC5B,CAACC,OACCX,gBAAgB,CAACW,KAAK,CAAChC,IAAI,KAAK6B,MAAM7B,IAAI,IAC1CqB,gBAAgB,CAACW,KAAK,CAACT,SAAS,KAAKM,MAAMN,SAAS;IAExD,OAAOO,gBAAAA,QAAS;AAClB;AAEA,8EAA8E;AAC9E,YAAY;AACZ,8EAA8E;AAE9E;;;CAGC,GACD,OAAO,MAAMG,mBAAyC;IACpDC,QAAQrC;IACRsC,OAAOf,YAAYgB,GAAG,CAAC,CAACJ,OAASX,gBAAgB,CAACW,KAAK;IACvDK,QAAQ;QAAEnC,YAAY;IAAa;AACrC,EAAC;AAED;;;;CAIC,GACD,OAAO,MAAMoC,yBAA+C,aACvDjD;IACHkD,OAAO,CAAC/C,QAAUJ,iBAAiBI;GACpC;AAED,+EAA+E,GAC/E,OAAO,SAASgD,iBAAiBC,KAKhC;IACC,MAAM,EAAEC,MAAM,EAAEC,WAAW,EAAE,GAAGF;IAChC,OAAO;WACDC,WAAW,SACX;YAAC;gBAAE1C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAO;YAAK;SAAE,GACtDkD,WAAW,QACT,EAAE,GACFA,OAAOG,MAAM,KAAK,IAChB;YAAC;gBAAE7C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAOkD,MAAM,CAAC,EAAE;YAAC;SAAE,GAC3D;YAAC;gBAAE1C,MAAM;gBAAY4C,IAAI;gBAAepD,OAAO;uBAAIkD;iBAAO;YAAC;SAAE;WACjEC,cACA;YAAC;gBAAE3C,MAAM;gBAAa4C,IAAI;gBAA+BpD,OAAO;uBAAImD;iBAAY;YAAC;SAAE,GACnF,EAAE;KACP;AACH;AAEA,iEAAiE,GACjE,OAAO,MAAMG,0BAA0B;IACrC;QAAE9C,MAAM;IAAW;IACnB;QAAEA,MAAM;QAAa+C,OAAO;IAAK;CAClC,CAAS;AAEV,2EAA2E,GAC3E,OAAO,MAAMC,0BAA0B,GAAE;AAEzC,yEAAyE,GACzE,OAAO,SAASC,kBAAkBC,MAAyB;IACzD,IAAIA,OAAOjC,KAAK,KAAK,UAAU,OAAOiC;IACtC,MAAMC,YAAYC,OAAOF,OAAO1D,KAAK;IACrC,OAAO4D,OAAOC,QAAQ,CAACF,cAAcG,OAAOJ,OAAO1D,KAAK,EAAE+D,IAAI,OAAO,KACjE,aAAKL;QAAQ1D,OAAO8D,OAAOE,KAAKC,KAAK,CAACN,YAAYvD;SAClDsD;AACN;AAsBA,kEAAkE,GAClE,OAAO,MAAMQ,6BACX,uEACA,6BAA4B;AAE9B;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,wBACdtB,MAAyB;IAEzB,MAAMuB,QAAQvB,OAAOD,GAAG,CAAC,CAACyB,OAASA,KAAKN,IAAI,IAAIvC,MAAM,CAAC8C,SAASC,IAAI,CAAC;IACrE,OAAOH,QAAQ;QAAE3C,OAAO;QAAY2B,IAAI;QAAcpD,OAAOoE;IAAM,IAAI;AACzE;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,WAAWvB,KAAsB;;IAC/C,MAAM,EAAET,IAAI,EAAEW,WAAW,EAAE,GAAGF;IAC9B,MAAMwB,eAAetB,cAAcgB,wBAAwBlB,MAAMJ,MAAM,IAAI;IAC3E,MAAM6B,UAAUD,eAAe;QAACA;WAAiBxB,MAAMyB,OAAO;KAAC,GAAGzB,MAAMyB,OAAO;IAC/E,MAAM7B,SAAS4B,eAAe,EAAE,GAAGxB,MAAMJ,MAAM;IAC/C,MAAM8B,iBAASxB,+BAAAA,YAAaE,MAAM,mBAAI;IACtC,MAAMuB,oBACJC,MAAMC,OAAO,CAAC7B,MAAMC,MAAM,KAC1BD,MAAMC,MAAM,CAACG,MAAM,GAAG,KACtBJ,MAAMC,MAAM,CAACG,MAAM,GAAGsB,SAASnB;IACjC,MAAMN,SACJ0B,qBAAqBC,MAAMC,OAAO,CAAC7B,MAAMC,MAAM,IAAI;QAACD,MAAMC,MAAM,CAAC,EAAE;KAAC,GAAGD,MAAMC,MAAM;IACrF,MAAM6B,QAAQlD,gBAAgB,CAACW,KAAK;IACpC,MAAMwC,OAAOlF,cACX2C,kBACA;QACEwC,MAAMjC,iBAAiB;YAAEE;YAAQC;QAAY;QAC7CuB,SAASA,QAAQ9B,GAAG,CAACa;QACrBZ;QACAL,MAAMuC;IACR,GACAjC;IAEF,MAAMoC,UACJF,KAAKE,OAAO,CAAC1E,IAAI,KAAKuE,MAAMvE,IAAI,IAAIwE,KAAKE,OAAO,CAACnD,SAAS,KAAKgD,MAAMhD,SAAS,GAC1EgD,QACAC,KAAKE,OAAO;IAClB,MAAMC,UAAUV,eAAe;QAACP;WAA+Bc,KAAKG,OAAO;KAAC,GAAGH,KAAKG,OAAO;IAC3F,OAAO;QAAEH,MAAM,aAAKA;YAAME;YAASC;;QAAWP;IAAkB;AAClE;AAEA;;;;CAIC,GACD,OAAO,SAASQ,uBACdC,QAAgB,EAChBC,OAAiE;IAEjE,MAAMC,WAAW,IAAIC;IACrB,KAAK,MAAMtC,UAAUoC,QAAS;YACbpC,kBAEWqC;QAF1B,MAAME,UAASvC,mBAAAA,OAAOwC,QAAQ,YAAfxC,mBAAmB;QAClC,IAAI,CAACuC,QAAQ;QACbF,SAASI,GAAG,CAACF,QAAQ;gBAAKF,gBAAAA,SAASK,GAAG,CAACH,mBAAbF,gBAAwB,EAAE;YAAGrC,OAAO2C,GAAG;SAAC;IACpE;IACA,MAAMC,MAAgB,EAAE;IACxB,MAAMC,QAAQ;QAACV;KAAS;IACxB,MAAMW,OAAO,IAAIC;IACjB,MAAOF,MAAM1C,MAAM,CAAE;YAKJkC;QAJf,MAAMW,OAAOH,MAAMI,KAAK;QACxB,IAAIH,KAAKI,GAAG,CAACF,OAAO;QACpBF,KAAKK,GAAG,CAACH;QACTJ,IAAIQ,IAAI,CAACJ;QACTH,MAAMO,IAAI,KAAKf,iBAAAA,SAASK,GAAG,CAACM,iBAAbX,iBAAsB,EAAE;IACzC;IACA,OAAOO;AACT"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The mobile push channel's shapes (AGL-3620, AGL-3651), for the server
19
+ * fan-out and the native apps alike.
20
+ *
21
+ * NO IMPORTS, not even types, on purpose: the native apps compile this file
22
+ * with their own compiler, and a type import would drag the core's whole
23
+ * type graph into it. The full per-scope resolution lives in
24
+ * `notification-push.ts`, which the server reads; the app needs only the
25
+ * account scope its settings screen edits, `accountPushSwitch` below, which
26
+ * a spec holds to the full resolver.
27
+ */
28
+ /** `users/{uid}/devices/{installId}`: one row per app install, written by its owner. */
29
+ export declare const MOBILE_DEVICES_COLLECTION = "devices";
30
+ export type MobileDevicePlatform = 'ios' | 'android' | 'macos';
31
+ /** The two native apps (bundle `com.aglyn.app` and `com.aglyn.pos`). */
32
+ export type MobileDeviceApp = 'aglyn' | 'aglyn-pos';
33
+ /** Each app's bundle id: the APNs topic its pushes are addressed to. */
34
+ export declare const MOBILE_APP_BUNDLES: Readonly<Record<MobileDeviceApp, string>>;
35
+ /** How a device is reached: Apple's APNs (iOS, macOS) or Google's FCM (Android). */
36
+ export type MobilePushTransport = 'apns' | 'fcm';
37
+ /** The APNs environment a device token was minted in; a token only works in its own. */
38
+ export type ApnsEnvironment = 'sandbox' | 'production';
39
+ export interface MobileDevice {
40
+ /** An APNs device token (hex) or an FCM registration token, per `transport`. */
41
+ token: string;
42
+ transport: MobilePushTransport;
43
+ /** APNs only: which gateway the token belongs to. */
44
+ apnsEnvironment?: ApnsEnvironment;
45
+ platform: MobileDevicePlatform;
46
+ app: MobileDeviceApp;
47
+ appVersion?: string;
48
+ /** Server time of the app's last launch while signed in. */
49
+ lastSeen?: unknown;
50
+ }
51
+ /** A device not seen for this long is not pushed to (and is pruned by the fan-out). */
52
+ export declare const MOBILE_DEVICE_STALE_MS: number;
53
+ /** The most devices one person's push fan-out reads. */
54
+ export declare const MOBILE_DEVICES_PER_USER = 20;
55
+ /** An APNs device token: hex, 64 to 200 characters (the Firestore rules hold the same). */
56
+ export declare function isApnsDeviceToken(value: unknown): value is string;
57
+ /** An FCM registration token: `[A-Za-z0-9_:-]`, 100 to 4096 characters (the rules hold the same). */
58
+ export declare function isFcmRegistrationToken(value: unknown): value is string;
59
+ /** Whether `token` is a token of `transport`'s shape. */
60
+ export declare function isMobilePushToken(transport: unknown, token: unknown): boolean;
61
+ /**
62
+ * What a push carries for the app to act on when it is tapped. The app
63
+ * resolves `link` the way it resolves a feed row: natively when a plugin
64
+ * registered a deep link for it, else in the console WebView.
65
+ */
66
+ export interface MobilePushData {
67
+ type: string;
68
+ link?: string;
69
+ orgId?: string;
70
+ hostId?: string;
71
+ }
72
+ export declare function mobilePushData(payload: {
73
+ type: string;
74
+ link?: string | null;
75
+ orgId?: string | null;
76
+ hostId?: string | null;
77
+ }): MobilePushData;
78
+ /** Read a tapped push's data back, refusing anything that is not ours. */
79
+ export declare function readMobilePushData(data: unknown): MobilePushData | null;
80
+ type ChannelAnswers = {
81
+ console?: boolean;
82
+ push?: boolean;
83
+ } | undefined;
84
+ /** The account-scope slice of `NotificationSettings` the app's switch reads. */
85
+ export interface AccountPushSettings {
86
+ account?: Record<string, ChannelAnswers>;
87
+ accountTypes?: Record<string, ChannelAnswers>;
88
+ }
89
+ /**
90
+ * What the app's per-type push switch shows: the account's push answer for
91
+ * the type, then for its category, and otherwise what the console feed does
92
+ * for it at the account scope (its console answers, the legacy category
93
+ * mute, then `consoleDefault`, the type's console default from the catalog).
94
+ * Equal to `notificationPushEnabled` with no scope; a spec holds the two
95
+ * together.
96
+ */
97
+ export declare function accountPushSwitch(settings: AccountPushSettings | null | undefined, type: string, category: string, consoleDefault: boolean, legacyPrefs?: Record<string, boolean> | null): boolean;
98
+ export {};
@@ -0,0 +1,97 @@
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
+ */ /**
18
+ * The mobile push channel's shapes (AGL-3620, AGL-3651), for the server
19
+ * fan-out and the native apps alike.
20
+ *
21
+ * NO IMPORTS, not even types, on purpose: the native apps compile this file
22
+ * with their own compiler, and a type import would drag the core's whole
23
+ * type graph into it. The full per-scope resolution lives in
24
+ * `notification-push.ts`, which the server reads; the app needs only the
25
+ * account scope its settings screen edits, `accountPushSwitch` below, which
26
+ * a spec holds to the full resolver.
27
+ */ /** `users/{uid}/devices/{installId}`: one row per app install, written by its owner. */ export const MOBILE_DEVICES_COLLECTION = 'devices';
28
+ /** Each app's bundle id: the APNs topic its pushes are addressed to. */ export const MOBILE_APP_BUNDLES = {
29
+ aglyn: 'com.aglyn.app',
30
+ 'aglyn-pos': 'com.aglyn.pos'
31
+ };
32
+ /** A device not seen for this long is not pushed to (and is pruned by the fan-out). */ export const MOBILE_DEVICE_STALE_MS = 60 * 24 * 60 * 60 * 1000;
33
+ /** The most devices one person's push fan-out reads. */ export const MOBILE_DEVICES_PER_USER = 20;
34
+ const HEX = /^[0-9A-Fa-f]+$/;
35
+ const FCM_TOKEN = /^[A-Za-z0-9_:-]+$/;
36
+ /** An APNs device token: hex, 64 to 200 characters (the Firestore rules hold the same). */ export function isApnsDeviceToken(value) {
37
+ return typeof value === 'string' && value.length >= 64 && value.length <= 200 && HEX.test(value);
38
+ }
39
+ /** An FCM registration token: `[A-Za-z0-9_:-]`, 100 to 4096 characters (the rules hold the same). */ export function isFcmRegistrationToken(value) {
40
+ return typeof value === 'string' && value.length >= 100 && value.length <= 4096 && FCM_TOKEN.test(value);
41
+ }
42
+ /** Whether `token` is a token of `transport`'s shape. */ export function isMobilePushToken(transport, token) {
43
+ if (transport === 'apns') return isApnsDeviceToken(token);
44
+ if (transport === 'fcm') return isFcmRegistrationToken(token);
45
+ return false;
46
+ }
47
+ export function mobilePushData(payload) {
48
+ return _extends({
49
+ type: payload.type
50
+ }, payload.link ? {
51
+ link: payload.link
52
+ } : {}, payload.orgId ? {
53
+ orgId: payload.orgId
54
+ } : {}, payload.hostId ? {
55
+ hostId: payload.hostId
56
+ } : {});
57
+ }
58
+ /** Read a tapped push's data back, refusing anything that is not ours. */ export function readMobilePushData(data) {
59
+ if (!data || typeof data !== 'object') return null;
60
+ const record = data;
61
+ if (typeof record['type'] !== 'string' || !record['type']) return null;
62
+ const text = (value)=>typeof value === 'string' && value ? value : undefined;
63
+ const link = text(record['link']);
64
+ return mobilePushData({
65
+ type: record['type'],
66
+ // Only a console path or an https URL; never a scheme the app would hand to the OS.
67
+ link: link && (link.startsWith('/') || link.startsWith('https://')) ? link : undefined,
68
+ orgId: text(record['orgId']),
69
+ hostId: text(record['hostId'])
70
+ });
71
+ }
72
+ /**
73
+ * What the app's per-type push switch shows: the account's push answer for
74
+ * the type, then for its category, and otherwise what the console feed does
75
+ * for it at the account scope (its console answers, the legacy category
76
+ * mute, then `consoleDefault`, the type's console default from the catalog).
77
+ * Equal to `notificationPushEnabled` with no scope; a spec holds the two
78
+ * together.
79
+ */ export function accountPushSwitch(settings, type, category, consoleDefault, legacyPrefs) {
80
+ var _settings_accountTypes_type, _settings_accountTypes, _settings_account_category, _settings_account, _settings_accountTypes_type1, _settings_accountTypes1, _settings_account_category1, _settings_account1;
81
+ for (const answer of [
82
+ settings == null ? void 0 : (_settings_accountTypes = settings.accountTypes) == null ? void 0 : (_settings_accountTypes_type = _settings_accountTypes[type]) == null ? void 0 : _settings_accountTypes_type.push,
83
+ settings == null ? void 0 : (_settings_account = settings.account) == null ? void 0 : (_settings_account_category = _settings_account[category]) == null ? void 0 : _settings_account_category.push
84
+ ]){
85
+ if (typeof answer === 'boolean') return answer;
86
+ }
87
+ for (const answer of [
88
+ settings == null ? void 0 : (_settings_accountTypes1 = settings.accountTypes) == null ? void 0 : (_settings_accountTypes_type1 = _settings_accountTypes1[type]) == null ? void 0 : _settings_accountTypes_type1.console,
89
+ settings == null ? void 0 : (_settings_account1 = settings.account) == null ? void 0 : (_settings_account_category1 = _settings_account1[category]) == null ? void 0 : _settings_account_category1.console
90
+ ]){
91
+ if (typeof answer === 'boolean') return answer;
92
+ }
93
+ if ((legacyPrefs == null ? void 0 : legacyPrefs[category]) === false) return false;
94
+ return consoleDefault;
95
+ }
96
+
97
+ //# sourceMappingURL=mobile-push.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/mobile-push.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 * The mobile push channel's shapes (AGL-3620, AGL-3651), for the server\n * fan-out and the native apps alike.\n *\n * NO IMPORTS, not even types, on purpose: the native apps compile this file\n * with their own compiler, and a type import would drag the core's whole\n * type graph into it. The full per-scope resolution lives in\n * `notification-push.ts`, which the server reads; the app needs only the\n * account scope its settings screen edits, `accountPushSwitch` below, which\n * a spec holds to the full resolver.\n */\n\n/** `users/{uid}/devices/{installId}`: one row per app install, written by its owner. */\nexport const MOBILE_DEVICES_COLLECTION = 'devices'\n\nexport type MobileDevicePlatform = 'ios' | 'android' | 'macos'\n\n/** The two native apps (bundle `com.aglyn.app` and `com.aglyn.pos`). */\nexport type MobileDeviceApp = 'aglyn' | 'aglyn-pos'\n\n/** Each app's bundle id: the APNs topic its pushes are addressed to. */\nexport const MOBILE_APP_BUNDLES: Readonly<Record<MobileDeviceApp, string>> = {\n aglyn: 'com.aglyn.app',\n 'aglyn-pos': 'com.aglyn.pos',\n}\n\n/** How a device is reached: Apple's APNs (iOS, macOS) or Google's FCM (Android). */\nexport type MobilePushTransport = 'apns' | 'fcm'\n\n/** The APNs environment a device token was minted in; a token only works in its own. */\nexport type ApnsEnvironment = 'sandbox' | 'production'\n\nexport interface MobileDevice {\n /** An APNs device token (hex) or an FCM registration token, per `transport`. */\n token: string\n transport: MobilePushTransport\n /** APNs only: which gateway the token belongs to. */\n apnsEnvironment?: ApnsEnvironment\n platform: MobileDevicePlatform\n app: MobileDeviceApp\n appVersion?: string\n /** Server time of the app's last launch while signed in. */\n lastSeen?: unknown\n}\n\n/** A device not seen for this long is not pushed to (and is pruned by the fan-out). */\nexport const MOBILE_DEVICE_STALE_MS = 60 * 24 * 60 * 60 * 1000\n\n/** The most devices one person's push fan-out reads. */\nexport const MOBILE_DEVICES_PER_USER = 20\n\nconst HEX = /^[0-9A-Fa-f]+$/\nconst FCM_TOKEN = /^[A-Za-z0-9_:-]+$/\n\n/** An APNs device token: hex, 64 to 200 characters (the Firestore rules hold the same). */\nexport function isApnsDeviceToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 64 && value.length <= 200 && HEX.test(value)\n}\n\n/** An FCM registration token: `[A-Za-z0-9_:-]`, 100 to 4096 characters (the rules hold the same). */\nexport function isFcmRegistrationToken(value: unknown): value is string {\n return typeof value === 'string' && value.length >= 100 && value.length <= 4096 && FCM_TOKEN.test(value)\n}\n\n/** Whether `token` is a token of `transport`'s shape. */\nexport function isMobilePushToken(transport: unknown, token: unknown): boolean {\n if (transport === 'apns') return isApnsDeviceToken(token)\n if (transport === 'fcm') return isFcmRegistrationToken(token)\n return false\n}\n\n/**\n * What a push carries for the app to act on when it is tapped. The app\n * resolves `link` the way it resolves a feed row: natively when a plugin\n * registered a deep link for it, else in the console WebView.\n */\nexport interface MobilePushData {\n type: string\n link?: string\n orgId?: string\n hostId?: string\n}\n\nexport function mobilePushData(payload: {\n type: string\n link?: string | null\n orgId?: string | null\n hostId?: string | null\n}): MobilePushData {\n return {\n type: payload.type,\n ...(payload.link ? { link: payload.link } : {}),\n ...(payload.orgId ? { orgId: payload.orgId } : {}),\n ...(payload.hostId ? { hostId: payload.hostId } : {}),\n }\n}\n\n/** Read a tapped push's data back, refusing anything that is not ours. */\nexport function readMobilePushData(data: unknown): MobilePushData | null {\n if (!data || typeof data !== 'object') return null\n const record = data as Record<string, unknown>\n if (typeof record['type'] !== 'string' || !record['type']) return null\n const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined)\n const link = text(record['link'])\n return mobilePushData({\n type: record['type'],\n // Only a console path or an https URL; never a scheme the app would hand to the OS.\n link: link && (link.startsWith('/') || link.startsWith('https://')) ? link : undefined,\n orgId: text(record['orgId']),\n hostId: text(record['hostId']),\n })\n}\n\ntype ChannelAnswers = { console?: boolean; push?: boolean } | undefined\n\n/** The account-scope slice of `NotificationSettings` the app's switch reads. */\nexport interface AccountPushSettings {\n account?: Record<string, ChannelAnswers>\n accountTypes?: Record<string, ChannelAnswers>\n}\n\n/**\n * What the app's per-type push switch shows: the account's push answer for\n * the type, then for its category, and otherwise what the console feed does\n * for it at the account scope (its console answers, the legacy category\n * mute, then `consoleDefault`, the type's console default from the catalog).\n * Equal to `notificationPushEnabled` with no scope; a spec holds the two\n * together.\n */\nexport function accountPushSwitch(\n settings: AccountPushSettings | null | undefined,\n type: string,\n category: string,\n consoleDefault: boolean,\n legacyPrefs?: Record<string, boolean> | null,\n): boolean {\n for (const answer of [settings?.accountTypes?.[type]?.push, settings?.account?.[category]?.push]) {\n if (typeof answer === 'boolean') return answer\n }\n for (const answer of [settings?.accountTypes?.[type]?.console, settings?.account?.[category]?.console]) {\n if (typeof answer === 'boolean') return answer\n }\n if (legacyPrefs?.[category] === false) return false\n return consoleDefault\n}\n"],"names":["MOBILE_DEVICES_COLLECTION","MOBILE_APP_BUNDLES","aglyn","MOBILE_DEVICE_STALE_MS","MOBILE_DEVICES_PER_USER","HEX","FCM_TOKEN","isApnsDeviceToken","value","length","test","isFcmRegistrationToken","isMobilePushToken","transport","token","mobilePushData","payload","type","link","orgId","hostId","readMobilePushData","data","record","text","undefined","startsWith","accountPushSwitch","settings","category","consoleDefault","legacyPrefs","answer","accountTypes","push","account","console"],"mappings":"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"}