@aglyn/aglyn 1.0.0-beta.232 → 1.0.0-beta.233
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.
- package/package.json +11 -11
- package/src/lib/app-utils/admin-audit-index.d.ts +3 -1
- package/src/lib/app-utils/admin-audit-index.js +9 -1
- package/src/lib/app-utils/admin-audit-index.js.map +1 -1
- package/src/lib/app-utils/analytics-summary.d.ts +95 -0
- package/src/lib/app-utils/analytics-summary.js +113 -0
- package/src/lib/app-utils/analytics-summary.js.map +1 -0
- package/src/lib/app-utils/artifact-list-keys.js +12 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/artifact-list-queries.d.ts +16 -0
- package/src/lib/app-utils/artifact-list-queries.js +122 -4
- package/src/lib/app-utils/artifact-list-queries.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +30 -2
- package/src/lib/app-utils/crm.js +77 -15
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +17 -5
- package/src/lib/app-utils/docs-help.generated.js +35 -1
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +139 -6
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/lockdown.js +1 -1
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/message-search.js +5 -2
- package/src/lib/app-utils/message-search.js.map +1 -1
- package/src/lib/app-utils/name-search.js +15 -3
- package/src/lib/app-utils/name-search.js.map +1 -1
- package/src/lib/app-utils/screen-analytics-aggregate.d.ts +70 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js +82 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -0
- package/src/lib/app-utils/screen-kind.d.ts +25 -0
- package/src/lib/app-utils/screen-kind.js +25 -0
- package/src/lib/app-utils/screen-kind.js.map +1 -0
- package/src/lib/app-utils/screen-route.d.ts +1 -5
- package/src/lib/app-utils/screen-route.js +2 -4
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/site-journey-steps.d.ts +38 -0
- package/src/lib/app-utils/site-journey-steps.js +54 -0
- package/src/lib/app-utils/site-journey-steps.js.map +1 -0
- package/src/lib/app-utils/site-journey.d.ts +2 -22
- package/src/lib/app-utils/site-journey.js +2 -32
- package/src/lib/app-utils/site-journey.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +20 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-checkout-credits.d.ts +288 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js +196 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.233",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -37,16 +37,16 @@
|
|
|
37
37
|
"./package.json": "./package.json"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
45
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
46
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
47
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
48
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
49
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
40
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.233",
|
|
41
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.233",
|
|
42
|
+
"@aglyn/shared-data-types": "1.0.0-beta.233",
|
|
43
|
+
"@aglyn/shared-util-email": "1.0.0-beta.233",
|
|
44
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.233",
|
|
45
|
+
"@aglyn/shared-util-http": "1.0.0-beta.233",
|
|
46
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.233",
|
|
47
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.233",
|
|
48
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.233",
|
|
49
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.233",
|
|
50
50
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
51
51
|
"@msgpack/msgpack": "^3.1.3",
|
|
52
52
|
"@types/unist": "^3.0.3",
|
|
@@ -101,4 +101,6 @@ export declare function adminAuditIndexFields(entry: AdminAuditIndexSource): Adm
|
|
|
101
101
|
* The row as it is stored: the entry, with the fields its lists query.
|
|
102
102
|
* Every write to `adminAudit` passes its data through this.
|
|
103
103
|
*/
|
|
104
|
-
export declare function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(entry: Entry): Entry & AdminAuditIndexFields
|
|
104
|
+
export declare function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(entry: Entry): Entry & AdminAuditIndexFields & {
|
|
105
|
+
scope: unknown;
|
|
106
|
+
};
|
|
@@ -171,7 +171,15 @@ import { nameSearchTokens } from "./name-search.js";
|
|
|
171
171
|
* The row as it is stored: the entry, with the fields its lists query.
|
|
172
172
|
* Every write to `adminAudit` passes its data through this.
|
|
173
173
|
*/ export function withAdminAuditIndex(entry) {
|
|
174
|
-
|
|
174
|
+
var _entry_scope;
|
|
175
|
+
/*
|
|
176
|
+
* `scope` stored on EVERY row, null when the writer has none (AGL-3680):
|
|
177
|
+
* the audit page sorts by it, and an `orderBy` drops every document that
|
|
178
|
+
* lacks the field. Only the scope-aware writers name one; the rows written
|
|
179
|
+
* before are stamped by `tools/scripts/backfill-staff-list-sort-fields.mjs`.
|
|
180
|
+
*/ return _extends({}, entry, {
|
|
181
|
+
scope: (_entry_scope = entry.scope) != null ? _entry_scope : null
|
|
182
|
+
}, adminAuditIndexFields(entry));
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
//# sourceMappingURL=admin-audit-index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/admin-audit-index.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 {\n isPluginStaffAuditAccess,\n pluginStaffAuditActionGroup,\n} from '../plugin-manager/plugin-activity-actions'\nimport { nameSearchTokens } from './name-search'\n\n/*\n * WHAT THE STAFF AUDIT LOG IS QUERIED BY, WRITTEN WITH EVERY ROW (AGL-3321).\n *\n * The staff audit page and the audit tables on a staff account's page filter\n * `adminAudit` by fields no writer used to store — the action's group, the\n * kind of thing acted on, the site, whether the act only looked — and search\n * it. All of that used to be answered by reading the log in batches and\n * keeping the rows that matched, and a match read that way stops wherever\n * the batches stop, so an entry past them was reported as not there. On an\n * audit trail that is the one wrong answer that matters.\n *\n * So each is stored on the row as it is written, and the query asks for it:\n *\n * actionGroup the facet's group for the action, answered by the plugin\n * activity registry (`pluginStaffAuditActionGroup`): a\n * registered group by code or by `staffAuditPrefixes`,\n * otherwise the action's leading namespace.\n * kind `access` for an act that only looked (a core read action,\n * or one a plugin declares in `staffAuditAccessActions`),\n * `change` for everything else.\n * targetKind the kind of record acted on: the first segment of the\n * target path (`orgs`, `users`, `hosts`, `lockdowns`).\n * targetHostId the site acted on, when the target is a site or a record\n * under one (`hosts/{id}/…`, `orgs/{org}/hosts/{id}/…`);\n * null otherwise, so \"no site\" is a value a query can ask for.\n * searchTokens word-prefix tokens (`nameSearchTokens`) of the fields a\n * reviewer searches by, for `array-contains` on one word.\n *\n * Every write goes through `withAdminAuditIndex`, on the server through\n * `addAdminAudit` / `setAdminAudit` / `recordAdminAudit` in\n * `@aglyn/tenant-data-admin`. `apps/console/specs/admin-audit-writes-are-stamped.spec.ts`\n * refuses a write to the collection anywhere else, and\n * `apps/console/specs/admin-audit-action-groups.spec.ts` pins the registry's\n * groups and reads to `tools/scripts/lib/admin-audit-index.fixtures.json`,\n * the mapping `tools/scripts/backfill-admin-audit-index.mjs` restamps old\n * rows with. A plugin adding a code, a prefix or a read action therefore\n * fails CI until the fixture names it, and the backfill is re-run.\n */\n\n/** The row's group, as the Action group filter asks for it. */\nexport const ADMIN_AUDIT_GROUP_FIELD = 'actionGroup'\n\n/** Whether the row only looked, as the account page's two tables ask for it. */\nexport const ADMIN_AUDIT_KIND_FIELD = 'kind'\n\n/** The kind of record acted on, as the Target type filter asks for it. */\nexport const ADMIN_AUDIT_TARGET_KIND_FIELD = 'targetKind'\n\n/** The site acted on, as the Site filter asks for it. */\nexport const ADMIN_AUDIT_SITE_FIELD = 'targetHostId'\n\n/** The row's search tokens, as the search asks for one. */\nexport const ADMIN_AUDIT_SEARCH_FIELD = 'searchTokens'\n\n/**\n * The fields a search reaches, in the order they claim the token budget:\n * what was done, who did it, what it was done to, then why.\n */\nexport const ADMIN_AUDIT_SEARCHED_FIELDS = [\n 'action',\n 'actorEmail',\n 'target',\n 'actorUid',\n 'scope',\n 'reason',\n 'note',\n] as const\n\n/**\n * The most tokens one row stores. A free-text note is the only field that\n * can run long, and it is searched last, so it is the one that loses reach\n * past the cap.\n */\nexport const ADMIN_AUDIT_SEARCH_TOKEN_LIMIT = 200\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The core actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nexport const ADMIN_AUDIT_ACCESS_ACTIONS: readonly string[] = [\n 'email.message-viewed',\n // The acquisition card (AGL-3289). No longer written; the rows already in\n // the log still classify as reads.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n]\n\n/** What a row carries that the stamped fields are derived from. */\nexport type AdminAuditIndexSource = Partial<\n Record<(typeof ADMIN_AUDIT_SEARCHED_FIELDS)[number], unknown>\n>\n\n/** The fields the lists query. */\nexport interface AdminAuditIndexFields {\n actionGroup: string\n kind: AdminAuditKind\n targetKind: string\n targetHostId: string | null\n searchTokens: string[]\n}\n\n/** Separators inside a value: an address's `@` and `.`, a path's `/`, a code's `.`. */\nconst SEPARATORS = /[^\\p{L}\\p{N}]+/gu\n\n/**\n * The search tokens for one row.\n *\n * Each value is tokenized twice: as written, so a typed address or code\n * (`jane@acme`, `org.override`) matches from its start, and split at its\n * separators, so a reader finds `org.override` by `override` and an address\n * by its domain.\n */\nexport function adminAuditSearchTokens(entry: AdminAuditIndexSource): string[] {\n const tokens = new Set<string>()\n for (const field of ADMIN_AUDIT_SEARCHED_FIELDS) {\n const value = entry[field]\n if (typeof value !== 'string' || !value.trim()) continue\n const words = [\n ...nameSearchTokens(value),\n ...nameSearchTokens(value.replace(SEPARATORS, ' ')),\n ]\n for (const token of words) {\n tokens.add(token)\n if (tokens.size >= ADMIN_AUDIT_SEARCH_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The group an action is filed under — the same answer the page's facet\n * offers. Empty for a row with no action.\n */\nexport function adminAuditActionGroup(action: unknown): string {\n return pluginStaffAuditActionGroup(action)\n}\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(action: unknown): AdminAuditKind {\n return typeof action === 'string' &&\n action &&\n (ADMIN_AUDIT_ACCESS_ACTIONS.includes(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\n/** The segments of a target path; empty for a target that is not one. */\nconst segmentsOf = (target: unknown): string[] =>\n typeof target === 'string' ? target.trim().split('/').filter(Boolean) : []\n\n/**\n * The kind of record acted on: the target's first segment, which for every\n * path-shaped target is its collection (`orgs/{id}` → `orgs`). A target\n * that is an identifier rather than a path (`sso-domains:acme.com`) is its\n * own type up to the first `:`. Empty for a row with no target.\n */\nexport function adminAuditTargetKind(target: unknown): string {\n const [first = ''] = segmentsOf(target)\n const colon = first.indexOf(':')\n return colon > 0 ? first.slice(0, colon) : first\n}\n\n/**\n * The site acted on: the id after a `hosts` segment in the target path —\n * `hosts/{id}` and anything under it, and a site filed under its\n * organization (`orgs/{org}/hosts/{id}`). Null when the act was not on a site.\n */\nexport function adminAuditTargetHostId(target: unknown): string | null {\n const segments = segmentsOf(target)\n for (let at = 0; at < segments.length - 1; at += 2) {\n if (segments[at] === 'hosts') return segments[at + 1] || null\n }\n return null\n}\n\n/** Every stamped field for one row. */\nexport function adminAuditIndexFields(\n entry: AdminAuditIndexSource,\n): AdminAuditIndexFields {\n return {\n actionGroup: adminAuditActionGroup(entry.action),\n kind: adminAuditKind(entry.action),\n targetKind: adminAuditTargetKind(entry.target),\n targetHostId: adminAuditTargetHostId(entry.target),\n searchTokens: adminAuditSearchTokens(entry),\n }\n}\n\n/**\n * The row as it is stored: the entry, with the fields its lists query.\n * Every write to `adminAudit` passes its data through this.\n */\nexport function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(\n entry: Entry,\n): Entry & AdminAuditIndexFields {\n return { ...entry, ...adminAuditIndexFields(entry) }\n}\n"],"names":["isPluginStaffAuditAccess","pluginStaffAuditActionGroup","nameSearchTokens","ADMIN_AUDIT_GROUP_FIELD","ADMIN_AUDIT_KIND_FIELD","ADMIN_AUDIT_TARGET_KIND_FIELD","ADMIN_AUDIT_SITE_FIELD","ADMIN_AUDIT_SEARCH_FIELD","ADMIN_AUDIT_SEARCHED_FIELDS","ADMIN_AUDIT_SEARCH_TOKEN_LIMIT","ADMIN_AUDIT_ACCESS_ACTIONS","SEPARATORS","adminAuditSearchTokens","entry","tokens","Set","field","value","trim","words","replace","token","add","size","adminAuditActionGroup","action","adminAuditKind","includes","segmentsOf","target","split","filter","Boolean","adminAuditTargetKind","first","colon","indexOf","slice","adminAuditTargetHostId","segments","at","length","adminAuditIndexFields","actionGroup","kind","targetKind","targetHostId","searchTokens","withAdminAuditIndex"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/admin-audit-index.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 {\n isPluginStaffAuditAccess,\n pluginStaffAuditActionGroup,\n} from '../plugin-manager/plugin-activity-actions'\nimport { nameSearchTokens } from './name-search'\n\n/*\n * WHAT THE STAFF AUDIT LOG IS QUERIED BY, WRITTEN WITH EVERY ROW (AGL-3321).\n *\n * The staff audit page and the audit tables on a staff account's page filter\n * `adminAudit` by fields no writer used to store — the action's group, the\n * kind of thing acted on, the site, whether the act only looked — and search\n * it. All of that used to be answered by reading the log in batches and\n * keeping the rows that matched, and a match read that way stops wherever\n * the batches stop, so an entry past them was reported as not there. On an\n * audit trail that is the one wrong answer that matters.\n *\n * So each is stored on the row as it is written, and the query asks for it:\n *\n * actionGroup the facet's group for the action, answered by the plugin\n * activity registry (`pluginStaffAuditActionGroup`): a\n * registered group by code or by `staffAuditPrefixes`,\n * otherwise the action's leading namespace.\n * kind `access` for an act that only looked (a core read action,\n * or one a plugin declares in `staffAuditAccessActions`),\n * `change` for everything else.\n * targetKind the kind of record acted on: the first segment of the\n * target path (`orgs`, `users`, `hosts`, `lockdowns`).\n * targetHostId the site acted on, when the target is a site or a record\n * under one (`hosts/{id}/…`, `orgs/{org}/hosts/{id}/…`);\n * null otherwise, so \"no site\" is a value a query can ask for.\n * searchTokens word-prefix tokens (`nameSearchTokens`) of the fields a\n * reviewer searches by, for `array-contains` on one word.\n *\n * Every write goes through `withAdminAuditIndex`, on the server through\n * `addAdminAudit` / `setAdminAudit` / `recordAdminAudit` in\n * `@aglyn/tenant-data-admin`. `apps/console/specs/admin-audit-writes-are-stamped.spec.ts`\n * refuses a write to the collection anywhere else, and\n * `apps/console/specs/admin-audit-action-groups.spec.ts` pins the registry's\n * groups and reads to `tools/scripts/lib/admin-audit-index.fixtures.json`,\n * the mapping `tools/scripts/backfill-admin-audit-index.mjs` restamps old\n * rows with. A plugin adding a code, a prefix or a read action therefore\n * fails CI until the fixture names it, and the backfill is re-run.\n */\n\n/** The row's group, as the Action group filter asks for it. */\nexport const ADMIN_AUDIT_GROUP_FIELD = 'actionGroup'\n\n/** Whether the row only looked, as the account page's two tables ask for it. */\nexport const ADMIN_AUDIT_KIND_FIELD = 'kind'\n\n/** The kind of record acted on, as the Target type filter asks for it. */\nexport const ADMIN_AUDIT_TARGET_KIND_FIELD = 'targetKind'\n\n/** The site acted on, as the Site filter asks for it. */\nexport const ADMIN_AUDIT_SITE_FIELD = 'targetHostId'\n\n/** The row's search tokens, as the search asks for one. */\nexport const ADMIN_AUDIT_SEARCH_FIELD = 'searchTokens'\n\n/**\n * The fields a search reaches, in the order they claim the token budget:\n * what was done, who did it, what it was done to, then why.\n */\nexport const ADMIN_AUDIT_SEARCHED_FIELDS = [\n 'action',\n 'actorEmail',\n 'target',\n 'actorUid',\n 'scope',\n 'reason',\n 'note',\n] as const\n\n/**\n * The most tokens one row stores. A free-text note is the only field that\n * can run long, and it is searched last, so it is the one that loses reach\n * past the cap.\n */\nexport const ADMIN_AUDIT_SEARCH_TOKEN_LIMIT = 200\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The core actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nexport const ADMIN_AUDIT_ACCESS_ACTIONS: readonly string[] = [\n 'email.message-viewed',\n // The acquisition card (AGL-3289). No longer written; the rows already in\n // the log still classify as reads.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n]\n\n/** What a row carries that the stamped fields are derived from. */\nexport type AdminAuditIndexSource = Partial<\n Record<(typeof ADMIN_AUDIT_SEARCHED_FIELDS)[number], unknown>\n>\n\n/** The fields the lists query. */\nexport interface AdminAuditIndexFields {\n actionGroup: string\n kind: AdminAuditKind\n targetKind: string\n targetHostId: string | null\n searchTokens: string[]\n}\n\n/** Separators inside a value: an address's `@` and `.`, a path's `/`, a code's `.`. */\nconst SEPARATORS = /[^\\p{L}\\p{N}]+/gu\n\n/**\n * The search tokens for one row.\n *\n * Each value is tokenized twice: as written, so a typed address or code\n * (`jane@acme`, `org.override`) matches from its start, and split at its\n * separators, so a reader finds `org.override` by `override` and an address\n * by its domain.\n */\nexport function adminAuditSearchTokens(entry: AdminAuditIndexSource): string[] {\n const tokens = new Set<string>()\n for (const field of ADMIN_AUDIT_SEARCHED_FIELDS) {\n const value = entry[field]\n if (typeof value !== 'string' || !value.trim()) continue\n const words = [\n ...nameSearchTokens(value),\n ...nameSearchTokens(value.replace(SEPARATORS, ' ')),\n ]\n for (const token of words) {\n tokens.add(token)\n if (tokens.size >= ADMIN_AUDIT_SEARCH_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The group an action is filed under — the same answer the page's facet\n * offers. Empty for a row with no action.\n */\nexport function adminAuditActionGroup(action: unknown): string {\n return pluginStaffAuditActionGroup(action)\n}\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(action: unknown): AdminAuditKind {\n return typeof action === 'string' &&\n action &&\n (ADMIN_AUDIT_ACCESS_ACTIONS.includes(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\n/** The segments of a target path; empty for a target that is not one. */\nconst segmentsOf = (target: unknown): string[] =>\n typeof target === 'string' ? target.trim().split('/').filter(Boolean) : []\n\n/**\n * The kind of record acted on: the target's first segment, which for every\n * path-shaped target is its collection (`orgs/{id}` → `orgs`). A target\n * that is an identifier rather than a path (`sso-domains:acme.com`) is its\n * own type up to the first `:`. Empty for a row with no target.\n */\nexport function adminAuditTargetKind(target: unknown): string {\n const [first = ''] = segmentsOf(target)\n const colon = first.indexOf(':')\n return colon > 0 ? first.slice(0, colon) : first\n}\n\n/**\n * The site acted on: the id after a `hosts` segment in the target path —\n * `hosts/{id}` and anything under it, and a site filed under its\n * organization (`orgs/{org}/hosts/{id}`). Null when the act was not on a site.\n */\nexport function adminAuditTargetHostId(target: unknown): string | null {\n const segments = segmentsOf(target)\n for (let at = 0; at < segments.length - 1; at += 2) {\n if (segments[at] === 'hosts') return segments[at + 1] || null\n }\n return null\n}\n\n/** Every stamped field for one row. */\nexport function adminAuditIndexFields(\n entry: AdminAuditIndexSource,\n): AdminAuditIndexFields {\n return {\n actionGroup: adminAuditActionGroup(entry.action),\n kind: adminAuditKind(entry.action),\n targetKind: adminAuditTargetKind(entry.target),\n targetHostId: adminAuditTargetHostId(entry.target),\n searchTokens: adminAuditSearchTokens(entry),\n }\n}\n\n/**\n * The row as it is stored: the entry, with the fields its lists query.\n * Every write to `adminAudit` passes its data through this.\n */\nexport function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(\n entry: Entry,\n): Entry & AdminAuditIndexFields & { scope: unknown } {\n /*\n * `scope` stored on EVERY row, null when the writer has none (AGL-3680):\n * the audit page sorts by it, and an `orderBy` drops every document that\n * lacks the field. Only the scope-aware writers name one; the rows written\n * before are stamped by `tools/scripts/backfill-staff-list-sort-fields.mjs`.\n */\n return { ...entry, scope: entry.scope ?? null, ...adminAuditIndexFields(entry) }\n}\n"],"names":["isPluginStaffAuditAccess","pluginStaffAuditActionGroup","nameSearchTokens","ADMIN_AUDIT_GROUP_FIELD","ADMIN_AUDIT_KIND_FIELD","ADMIN_AUDIT_TARGET_KIND_FIELD","ADMIN_AUDIT_SITE_FIELD","ADMIN_AUDIT_SEARCH_FIELD","ADMIN_AUDIT_SEARCHED_FIELDS","ADMIN_AUDIT_SEARCH_TOKEN_LIMIT","ADMIN_AUDIT_ACCESS_ACTIONS","SEPARATORS","adminAuditSearchTokens","entry","tokens","Set","field","value","trim","words","replace","token","add","size","adminAuditActionGroup","action","adminAuditKind","includes","segmentsOf","target","split","filter","Boolean","adminAuditTargetKind","first","colon","indexOf","slice","adminAuditTargetHostId","segments","at","length","adminAuditIndexFields","actionGroup","kind","targetKind","targetHostId","searchTokens","withAdminAuditIndex","scope"],"mappings":"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"}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 arithmetic behind the traffic tiles `/product/analytics` advertises
|
|
19
|
+
* (AGL-2160).
|
|
20
|
+
*
|
|
21
|
+
* Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,
|
|
22
|
+
* `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had
|
|
23
|
+
* `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever
|
|
24
|
+
* the range selector said, and a one-word `Top device`.
|
|
25
|
+
*/
|
|
26
|
+
/** One day counter document, as the traffic card reads it. */
|
|
27
|
+
export interface TrafficDay {
|
|
28
|
+
day: string;
|
|
29
|
+
total: number;
|
|
30
|
+
visitors: number;
|
|
31
|
+
paths: Record<string, number>;
|
|
32
|
+
referrers: Record<string, number>;
|
|
33
|
+
devices: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
export interface TrafficWindows<T> {
|
|
36
|
+
/** The newest `windowSize` days, oldest first — what the chart plots. */
|
|
37
|
+
current: T[];
|
|
38
|
+
/** The `windowSize` days before those, for the comparison only. */
|
|
39
|
+
prior: T[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Splits a `windowSize * 2` run of days into the displayed window and the
|
|
43
|
+
* one behind it.
|
|
44
|
+
*
|
|
45
|
+
* Both callers pass days OLDEST FIRST, which is the order the chart reads
|
|
46
|
+
* and the order the fetch produces. Getting this backwards is silent — the
|
|
47
|
+
* delta simply comes out negated — so the split is here with a test rather
|
|
48
|
+
* than inline in a 400-line component.
|
|
49
|
+
*/
|
|
50
|
+
export declare function splitTrafficWindows<T>(days: readonly T[], windowSize: number): TrafficWindows<T>;
|
|
51
|
+
/**
|
|
52
|
+
* Percentage change between two window totals, to one decimal.
|
|
53
|
+
*
|
|
54
|
+
* `null` when the prior window recorded nothing. A site's first week has no
|
|
55
|
+
* growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data
|
|
56
|
+
* does not. The tile renders nothing instead.
|
|
57
|
+
*/
|
|
58
|
+
export declare function trafficDeltaPct(current: number, prior: number): number | null;
|
|
59
|
+
export interface DeviceSplitEntry {
|
|
60
|
+
device: string;
|
|
61
|
+
count: number;
|
|
62
|
+
/** Whole percent of the window's device-classified views. */
|
|
63
|
+
percent: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.
|
|
67
|
+
*
|
|
68
|
+
* Ordered by share, and NOT padded with zero-count devices — a site with no
|
|
69
|
+
* tablet traffic should not carry a `Tablet 0%` label, which reads as a
|
|
70
|
+
* measurement rather than an absence.
|
|
71
|
+
*
|
|
72
|
+
* Percentages are rounded independently and therefore need not total 100.
|
|
73
|
+
* That is deliberate: forcing the largest share to absorb the rounding
|
|
74
|
+
* error makes one number wrong to make a sum right, and nothing here sums
|
|
75
|
+
* them.
|
|
76
|
+
*/
|
|
77
|
+
export declare function deviceSplit(devices: Record<string, number>): DeviceSplitEntry[];
|
|
78
|
+
/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */
|
|
79
|
+
export declare function deviceSplitLabel(split: readonly DeviceSplitEntry[]): string;
|
|
80
|
+
/** `61% / 39%` — the figures, in the same order as the labels. */
|
|
81
|
+
export declare function deviceSplitValue(split: readonly DeviceSplitEntry[]): string;
|
|
82
|
+
/**
|
|
83
|
+
* Sums a per-day map across a window, returning entries by descending
|
|
84
|
+
* count. Shared by paths and referrers, which had two copies of it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function rollUp(days: readonly Partial<Record<'paths' | 'referrers', Record<string, number>>>[], field: 'paths' | 'referrers'): [string, number][];
|
|
87
|
+
/**
|
|
88
|
+
* `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup
|
|
89
|
+
* shows (AGL-2182).
|
|
90
|
+
*
|
|
91
|
+
* Seconds are zero-padded so a column of these stays aligned, and the
|
|
92
|
+
* minute part is dropped below a minute rather than rendering `0m 04s`,
|
|
93
|
+
* which reads like a broken clock.
|
|
94
|
+
*/
|
|
95
|
+
export declare function formatDwell(ms: number): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
* The arithmetic behind the traffic tiles `/product/analytics` advertises
|
|
18
|
+
* (AGL-2160).
|
|
19
|
+
*
|
|
20
|
+
* Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,
|
|
21
|
+
* `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had
|
|
22
|
+
* `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever
|
|
23
|
+
* the range selector said, and a one-word `Top device`.
|
|
24
|
+
*/ /** One day counter document, as the traffic card reads it. */ /**
|
|
25
|
+
* Splits a `windowSize * 2` run of days into the displayed window and the
|
|
26
|
+
* one behind it.
|
|
27
|
+
*
|
|
28
|
+
* Both callers pass days OLDEST FIRST, which is the order the chart reads
|
|
29
|
+
* and the order the fetch produces. Getting this backwards is silent — the
|
|
30
|
+
* delta simply comes out negated — so the split is here with a test rather
|
|
31
|
+
* than inline in a 400-line component.
|
|
32
|
+
*/ export function splitTrafficWindows(days, windowSize) {
|
|
33
|
+
if (windowSize <= 0) return {
|
|
34
|
+
current: [],
|
|
35
|
+
prior: []
|
|
36
|
+
};
|
|
37
|
+
const current = days.slice(-windowSize);
|
|
38
|
+
const prior = days.slice(-windowSize * 2, -windowSize);
|
|
39
|
+
return {
|
|
40
|
+
current,
|
|
41
|
+
prior
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Percentage change between two window totals, to one decimal.
|
|
46
|
+
*
|
|
47
|
+
* `null` when the prior window recorded nothing. A site's first week has no
|
|
48
|
+
* growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data
|
|
49
|
+
* does not. The tile renders nothing instead.
|
|
50
|
+
*/ export function trafficDeltaPct(current, prior) {
|
|
51
|
+
if (!prior) return null;
|
|
52
|
+
return Math.round((current - prior) / prior * 1000) / 10;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.
|
|
56
|
+
*
|
|
57
|
+
* Ordered by share, and NOT padded with zero-count devices — a site with no
|
|
58
|
+
* tablet traffic should not carry a `Tablet 0%` label, which reads as a
|
|
59
|
+
* measurement rather than an absence.
|
|
60
|
+
*
|
|
61
|
+
* Percentages are rounded independently and therefore need not total 100.
|
|
62
|
+
* That is deliberate: forcing the largest share to absorb the rounding
|
|
63
|
+
* error makes one number wrong to make a sum right, and nothing here sums
|
|
64
|
+
* them.
|
|
65
|
+
*/ export function deviceSplit(devices) {
|
|
66
|
+
const entries = Object.entries(devices != null ? devices : {}).filter(([, count])=>Number.isFinite(count) && count > 0);
|
|
67
|
+
const sum = entries.reduce((total, [, count])=>total + count, 0);
|
|
68
|
+
if (!sum) return [];
|
|
69
|
+
return entries.sort(([, a], [, b])=>b - a).map(([device, count])=>({
|
|
70
|
+
device,
|
|
71
|
+
count,
|
|
72
|
+
percent: Math.round(count / sum * 100)
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */ export function deviceSplitLabel(split) {
|
|
76
|
+
return split.map((entry)=>entry.device.charAt(0).toUpperCase() + entry.device.slice(1)).join(' / ');
|
|
77
|
+
}
|
|
78
|
+
/** `61% / 39%` — the figures, in the same order as the labels. */ export function deviceSplitValue(split) {
|
|
79
|
+
return split.map((entry)=>`${entry.percent}%`).join(' / ');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Sums a per-day map across a window, returning entries by descending
|
|
83
|
+
* count. Shared by paths and referrers, which had two copies of it.
|
|
84
|
+
*/ export function rollUp(days, field) {
|
|
85
|
+
const totals = {};
|
|
86
|
+
for (const day of days){
|
|
87
|
+
var _day_field;
|
|
88
|
+
const map = (_day_field = day[field]) != null ? _day_field : {};
|
|
89
|
+
for (const [key, count] of Object.entries(map)){
|
|
90
|
+
var _totals_key;
|
|
91
|
+
totals[key] = ((_totals_key = totals[key]) != null ? _totals_key : 0) + count;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.entries(totals).sort(([, a], [, b])=>b - a);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup
|
|
98
|
+
* shows (AGL-2182).
|
|
99
|
+
*
|
|
100
|
+
* Seconds are zero-padded so a column of these stays aligned, and the
|
|
101
|
+
* minute part is dropped below a minute rather than rendering `0m 04s`,
|
|
102
|
+
* which reads like a broken clock.
|
|
103
|
+
*/ export function formatDwell(ms) {
|
|
104
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
105
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
106
|
+
const seconds = totalSeconds % 60;
|
|
107
|
+
if (minutes === 0) return `${seconds}s`;
|
|
108
|
+
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
|
109
|
+
const hours = Math.floor(minutes / 60);
|
|
110
|
+
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=analytics-summary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/analytics-summary.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 arithmetic behind the traffic tiles `/product/analytics` advertises\n * (AGL-2160).\n *\n * Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,\n * `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had\n * `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever\n * the range selector said, and a one-word `Top device`.\n */\n\n/** One day counter document, as the traffic card reads it. */\nexport interface TrafficDay {\n day: string\n total: number\n visitors: number\n paths: Record<string, number>\n referrers: Record<string, number>\n devices: Record<string, number>\n}\n\nexport interface TrafficWindows<T> {\n /** The newest `windowSize` days, oldest first — what the chart plots. */\n current: T[]\n /** The `windowSize` days before those, for the comparison only. */\n prior: T[]\n}\n\n/**\n * Splits a `windowSize * 2` run of days into the displayed window and the\n * one behind it.\n *\n * Both callers pass days OLDEST FIRST, which is the order the chart reads\n * and the order the fetch produces. Getting this backwards is silent — the\n * delta simply comes out negated — so the split is here with a test rather\n * than inline in a 400-line component.\n */\nexport function splitTrafficWindows<T>(\n days: readonly T[],\n windowSize: number,\n): TrafficWindows<T> {\n if (windowSize <= 0) return { current: [], prior: [] }\n const current = days.slice(-windowSize)\n const prior = days.slice(-windowSize * 2, -windowSize)\n return { current, prior }\n}\n\n/**\n * Percentage change between two window totals, to one decimal.\n *\n * `null` when the prior window recorded nothing. A site's first week has no\n * growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data\n * does not. The tile renders nothing instead.\n */\nexport function trafficDeltaPct(\n current: number,\n prior: number,\n): number | null {\n if (!prior) return null\n return Math.round(((current - prior) / prior) * 1000) / 10\n}\n\nexport interface DeviceSplitEntry {\n device: string\n count: number\n /** Whole percent of the window's device-classified views. */\n percent: number\n}\n\n/**\n * The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.\n *\n * Ordered by share, and NOT padded with zero-count devices — a site with no\n * tablet traffic should not carry a `Tablet 0%` label, which reads as a\n * measurement rather than an absence.\n *\n * Percentages are rounded independently and therefore need not total 100.\n * That is deliberate: forcing the largest share to absorb the rounding\n * error makes one number wrong to make a sum right, and nothing here sums\n * them.\n */\nexport function deviceSplit(\n devices: Record<string, number>,\n): DeviceSplitEntry[] {\n const entries = Object.entries(devices ?? {}).filter(\n ([, count]) => Number.isFinite(count) && count > 0,\n )\n const sum = entries.reduce((total, [, count]) => total + count, 0)\n if (!sum) return []\n return entries\n .sort(([, a], [, b]) => b - a)\n .map(([device, count]) => ({\n device,\n count,\n percent: Math.round((count / sum) * 100),\n }))\n}\n\n/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */\nexport function deviceSplitLabel(split: readonly DeviceSplitEntry[]): string {\n return split\n .map((entry) => entry.device.charAt(0).toUpperCase() + entry.device.slice(1))\n .join(' / ')\n}\n\n/** `61% / 39%` — the figures, in the same order as the labels. */\nexport function deviceSplitValue(split: readonly DeviceSplitEntry[]): string {\n return split.map((entry) => `${entry.percent}%`).join(' / ')\n}\n\n/**\n * Sums a per-day map across a window, returning entries by descending\n * count. Shared by paths and referrers, which had two copies of it.\n */\nexport function rollUp(\n days: readonly Partial<\n Record<'paths' | 'referrers', Record<string, number>>\n >[],\n field: 'paths' | 'referrers',\n): [string, number][] {\n const totals: Record<string, number> = {}\n for (const day of days) {\n const map = day[field] ?? {}\n for (const [key, count] of Object.entries(map)) {\n totals[key] = (totals[key] ?? 0) + count\n }\n }\n return Object.entries(totals).sort(([, a], [, b]) => b - a)\n}\n\n/**\n * `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup\n * shows (AGL-2182).\n *\n * Seconds are zero-padded so a column of these stays aligned, and the\n * minute part is dropped below a minute rather than rendering `0m 04s`,\n * which reads like a broken clock.\n */\nexport function formatDwell(ms: number): string {\n const totalSeconds = Math.max(0, Math.round(ms / 1000))\n const minutes = Math.floor(totalSeconds / 60)\n const seconds = totalSeconds % 60\n if (minutes === 0) return `${seconds}s`\n if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`\n const hours = Math.floor(minutes / 60)\n return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`\n}\n"],"names":["splitTrafficWindows","days","windowSize","current","prior","slice","trafficDeltaPct","Math","round","deviceSplit","devices","entries","Object","filter","count","Number","isFinite","sum","reduce","total","sort","a","b","map","device","percent","deviceSplitLabel","split","entry","charAt","toUpperCase","join","deviceSplitValue","rollUp","field","totals","day","key","formatDwell","ms","totalSeconds","max","minutes","floor","seconds","String","padStart","hours"],"mappings":"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"}
|
|
@@ -39,6 +39,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
39
39
|
* so does a deleted template (`artifactDeleteListKeys`), which
|
|
40
40
|
* is how the library's query leaves tombstones out;
|
|
41
41
|
*
|
|
42
|
+
* description on a layout or component that has none, `null` — the
|
|
43
|
+
* lists sort by it (AGL-3680), and an `orderBy` drops every
|
|
44
|
+
* document that lacks the field. A writer that passes a
|
|
45
|
+
* partial document here must pass its `description` too, or
|
|
46
|
+
* the null would overwrite the one it writes;
|
|
42
47
|
* deletedAt on a screen, `null` — the flag a campaign's screens list
|
|
43
48
|
* asks for (`deletedAt == null`) to leave tombstones out. A
|
|
44
49
|
* query cannot ask for a field to be absent, so a live
|
|
@@ -102,7 +107,14 @@ const record = (value)=>value && typeof value === 'object' ? value : {};
|
|
|
102
107
|
// Stored, not omitted: `deletedAt == null` matches only a document that
|
|
103
108
|
// holds the field (AGL-3321, a campaign's screens).
|
|
104
109
|
if (collection === 'screens') keys['deletedAt'] = null;
|
|
110
|
+
// A live layout too: its list asks for `deletedAt == null` rather than
|
|
111
|
+
// dropping tombstones from the page they fall in (AGL-3680).
|
|
112
|
+
if (collection === 'layouts' && doc['deletedAt'] == null) keys['deletedAt'] = null;
|
|
105
113
|
if (collection === 'components' && doc['kind'] !== 'email') keys['kind'] = 'site';
|
|
114
|
+
// Stored, not omitted: the Description header orders by it (AGL-3680).
|
|
115
|
+
if ((collection === 'layouts' || collection === 'components') && doc['description'] == null) {
|
|
116
|
+
keys['description'] = null;
|
|
117
|
+
}
|
|
106
118
|
if (collection === 'templates') {
|
|
107
119
|
if (!TEMPLATE_KINDS.includes(String(doc['kind']))) keys['kind'] = 'page';
|
|
108
120
|
// Provenance is server-managed (AGL-666), and a writer that states it is
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/artifact-list-keys.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 { displayNameSearchFields } from './name-search'\n\n/*\n * THE KEYS A SITE ARTIFACT'S LISTS QUERY BY (AGL-3321).\n *\n * The screens, layouts, reusable components and templates of a site are\n * listed, searched and filtered by Firestore queries, so every key a list\n * asks about has to be WRITTEN — a query cannot find a document by a value it\n * derives, and cannot find one that lacks the field at all:\n *\n * nameLower / nameTokens / nameReversed\n * the name keys (`displayNameSearchFields`), on all four;\n * kind on a component, `site` or `email` — a component stored\n * before AGL-3287 carries none and is read as `site`, so the\n * \"Used in: Page\" filter needs the word stored;\n * on a template, `page`, `component` or `layout` — a template\n * stored with none is read as a page template;\n * source.type on a template, its provenance — one stored with no\n * `source`, or with the legacy `workspace`, was saved here,\n * which is `authored`;\n * libraryRow on a template, whether it is its own row of the templates\n * library: a multi-page STARTER is one row (AGL-696), led by\n * its first live page, so the other pages carry `false` — and\n * so does a deleted template (`artifactDeleteListKeys`), which\n * is how the library's query leaves tombstones out;\n *\n * deletedAt on a screen, `null` — the flag a campaign's screens list\n * asks for (`deletedAt == null`) to leave tombstones out. A\n * query cannot ask for a field to be absent, so a live\n * screen must STORE the null; a delete overwrites it with\n * the time (and the security rules let an editor do that\n * only while it is null, so a tombstone stays one);\n *\n * A DELETED screen gets none of them. The email templates list orders by\n * `nameLower`, and an email template's delete clears its name keys so the\n * tombstone leaves that order (a query cannot ask for `deletedAt` to be\n * absent) — so no create, restore or backfill may stamp them back.\n *\n * Every CREATE stamps them (`artifactCreateListKeys`) and every rename\n * restamps the name keys (`artifactRenameListKeys`).\n * `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the documents\n * written before them; a script cannot import this library, so it reads the\n * documents the way this module does, and both sides answer\n * `tools/scripts/lib/artifact-list-keys.fixtures.json` — this module's spec\n * and the backfill's `--self-test`.\n */\n\n/** The site artifact collections whose lists query these keys. */\nexport const LISTED_ARTIFACT_COLLECTIONS = [\n 'screens',\n 'layouts',\n 'components',\n 'templates',\n] as const\n\nexport type ListedArtifactCollection =\n (typeof LISTED_ARTIFACT_COLLECTIONS)[number]\n\n/** Whether a host subcollection is one whose lists query these keys. */\nexport const isListedArtifactCollection = (\n collection: string,\n): collection is ListedArtifactCollection =>\n (LISTED_ARTIFACT_COLLECTIONS as readonly string[]).includes(collection)\n\n/** The kinds a template's `kind` holds; absent reads as `page`. */\nconst TEMPLATE_KINDS = ['page', 'component', 'layout']\n\n/**\n * A provenance word no writer stores any more: a detached marketplace copy\n * was stamped `workspace` before it was stamped `authored` (AGL-3321).\n */\nconst LEGACY_AUTHORED_SOURCE = 'workspace'\n\nconst record = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' ? (value as Record<string, unknown>) : {}\n\n/**\n * The name a list finds an artifact by.\n *\n * Its `displayName` — except a page of a multi-page STARTER, which the\n * templates library lists as ONE row under the starter's name\n * (`source.starterName`), so every page of it carries that name's keys and a\n * search for the starter finds the row whichever page leads it.\n */\nexport function artifactSearchName(\n collection: string,\n doc: Record<string, unknown>,\n): unknown {\n if (collection === 'templates') {\n const starterName = record(doc['source'])['starterName']\n if (typeof starterName === 'string' && starterName.trim()) return starterName\n }\n return doc['displayName']\n}\n\n/**\n * The list keys a CREATE of `doc` into `collection` stamps: the name keys,\n * and the stored default of a kind a reader otherwise infers. Nothing for a\n * collection no artifact list queries.\n *\n * Spread AFTER the document's own fields: the keys are derived from them.\n */\nexport function artifactCreateListKeys(\n collection: string,\n doc: Record<string, unknown>,\n): Record<string, unknown> {\n if (!isListedArtifactCollection(collection)) return {}\n if (collection === 'screens' && doc['deletedAt'] != null) return {}\n const keys: Record<string, unknown> = {\n ...displayNameSearchFields(artifactSearchName(collection, doc)),\n }\n // Stored, not omitted: `deletedAt == null` matches only a document that\n // holds the field (AGL-3321, a campaign's screens).\n if (collection === 'screens') keys['deletedAt'] = null\n if (collection === 'components' && doc['kind'] !== 'email') keys['kind'] = 'site'\n if (collection === 'templates') {\n if (!TEMPLATE_KINDS.includes(String(doc['kind']))) keys['kind'] = 'page'\n // Provenance is server-managed (AGL-666), and a writer that states it is\n // never overridden here. None at all, or the legacy word a detached copy\n // carried, was saved here, which is `authored`.\n const sourceType = record(doc['source'])['type']\n if (typeof sourceType !== 'string' || sourceType === LEGACY_AUTHORED_SOURCE) {\n keys['source'] = { ...record(doc['source']), type: 'authored' }\n }\n keys['libraryRow'] = doc['deletedAt'] == null && leadsItsStarter(doc)\n }\n return keys\n}\n\n/**\n * Whether a template leads its starter's library row when the starter is\n * written whole: every template that is not a starter page does, and of a\n * starter's pages the first (`starterOrder` 0). A later delete moves the lead\n * to the next live page — see `artifactDeleteListKeys`.\n */\nfunction leadsItsStarter(doc: Record<string, unknown>): boolean {\n const source = record(doc['source'])\n const starterId = source['starterId']\n if (typeof starterId !== 'string' || !starterId) return true\n return Number(source['starterOrder'] ?? 0) === 0\n}\n\n/**\n * The list keys a SOFT DELETE writes beside `deletedAt`: a deleted template\n * is no longer a library row. A caller deleting the page that LEADS a\n * starter's row gives the lead to the next live page (`libraryRow: true`),\n * or the starter disappears from the library with pages still in it.\n */\nexport function artifactDeleteListKeys(\n collection: string,\n): Record<string, unknown> {\n return collection === 'templates' ? { libraryRow: false } : {}\n}\n\n/**\n * The name keys a RENAME writes beside the new `displayName`. `doc` is the\n * document as read, for the one case whose search name is not its display\n * name (a starter page — see `artifactSearchName`).\n */\nexport function artifactRenameListKeys(\n collection: string,\n displayName: unknown,\n doc: Record<string, unknown> = {},\n): Record<string, unknown> {\n if (!isListedArtifactCollection(collection)) return {}\n return displayNameSearchFields(\n artifactSearchName(collection, { ...doc, displayName }),\n )\n}\n"],"names":["displayNameSearchFields","LISTED_ARTIFACT_COLLECTIONS","isListedArtifactCollection","collection","includes","TEMPLATE_KINDS","LEGACY_AUTHORED_SOURCE","record","value","artifactSearchName","doc","starterName","trim","artifactCreateListKeys","keys","String","sourceType","type","leadsItsStarter","source","starterId","Number","artifactDeleteListKeys","libraryRow","artifactRenameListKeys","displayName"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,uBAAuB,QAAQ,mBAAe;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED,gEAAgE,GAChE,OAAO,MAAMC,8BAA8B;IACzC;IACA;IACA;IACA;CACD,CAAS;AAKV,sEAAsE,GACtE,OAAO,MAAMC,6BAA6B,CACxCC,aAEA,AAACF,4BAAkDG,QAAQ,CAACD,YAAW;AAEzE,iEAAiE,GACjE,MAAME,iBAAiB;IAAC;IAAQ;IAAa;CAAS;AAEtD;;;CAGC,GACD,MAAMC,yBAAyB;AAE/B,MAAMC,SAAS,CAACC,QACdA,SAAS,OAAOA,UAAU,WAAYA,QAAoC,CAAC;AAE7E;;;;;;;CAOC,GACD,OAAO,SAASC,mBACdN,UAAkB,EAClBO,GAA4B;IAE5B,IAAIP,eAAe,aAAa;QAC9B,MAAMQ,cAAcJ,OAAOG,GAAG,CAAC,SAAS,CAAC,CAAC,cAAc;QACxD,IAAI,OAAOC,gBAAgB,YAAYA,YAAYC,IAAI,IAAI,OAAOD;IACpE;IACA,OAAOD,GAAG,CAAC,cAAc;AAC3B;AAEA;;;;;;CAMC,GACD,OAAO,SAASG,uBACdV,UAAkB,EAClBO,GAA4B;IAE5B,IAAI,CAACR,2BAA2BC,aAAa,OAAO,CAAC;IACrD,IAAIA,eAAe,aAAaO,GAAG,CAAC,YAAY,IAAI,MAAM,OAAO,CAAC;IAClE,MAAMI,OAAgC,aACjCd,wBAAwBS,mBAAmBN,YAAYO;IAE5D,wEAAwE;IACxE,oDAAoD;IACpD,IAAIP,eAAe,WAAWW,IAAI,CAAC,YAAY,GAAG;IAClD,IAAIX,eAAe,gBAAgBO,GAAG,CAAC,OAAO,KAAK,SAASI,IAAI,CAAC,OAAO,GAAG;IAC3E,IAAIX,eAAe,aAAa;QAC9B,IAAI,CAACE,eAAeD,QAAQ,CAACW,OAAOL,GAAG,CAAC,OAAO,IAAII,IAAI,CAAC,OAAO,GAAG;QAClE,yEAAyE;QACzE,yEAAyE;QACzE,gDAAgD;QAChD,MAAME,aAAaT,OAAOG,GAAG,CAAC,SAAS,CAAC,CAAC,OAAO;QAChD,IAAI,OAAOM,eAAe,YAAYA,eAAeV,wBAAwB;YAC3EQ,IAAI,CAAC,SAAS,GAAG,aAAKP,OAAOG,GAAG,CAAC,SAAS;gBAAGO,MAAM;;QACrD;QACAH,IAAI,CAAC,aAAa,GAAGJ,GAAG,CAAC,YAAY,IAAI,QAAQQ,gBAAgBR;IACnE;IACA,OAAOI;AACT;AAEA;;;;;CAKC,GACD,SAASI,gBAAgBR,GAA4B;QAIrCS;IAHd,MAAMA,SAASZ,OAAOG,GAAG,CAAC,SAAS;IACnC,MAAMU,YAAYD,MAAM,CAAC,YAAY;IACrC,IAAI,OAAOC,cAAc,YAAY,CAACA,WAAW,OAAO;IACxD,OAAOC,QAAOF,uBAAAA,MAAM,CAAC,eAAe,YAAtBA,uBAA0B,OAAO;AACjD;AAEA;;;;;CAKC,GACD,OAAO,SAASG,uBACdnB,UAAkB;IAElB,OAAOA,eAAe,cAAc;QAAEoB,YAAY;IAAM,IAAI,CAAC;AAC/D;AAEA;;;;CAIC,GACD,OAAO,SAASC,uBACdrB,UAAkB,EAClBsB,WAAoB,EACpBf,MAA+B,CAAC,CAAC;IAEjC,IAAI,CAACR,2BAA2BC,aAAa,OAAO,CAAC;IACrD,OAAOH,wBACLS,mBAAmBN,YAAY,aAAKO;QAAKe;;AAE7C"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/artifact-list-keys.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 { displayNameSearchFields } from './name-search'\n\n/*\n * THE KEYS A SITE ARTIFACT'S LISTS QUERY BY (AGL-3321).\n *\n * The screens, layouts, reusable components and templates of a site are\n * listed, searched and filtered by Firestore queries, so every key a list\n * asks about has to be WRITTEN — a query cannot find a document by a value it\n * derives, and cannot find one that lacks the field at all:\n *\n * nameLower / nameTokens / nameReversed\n * the name keys (`displayNameSearchFields`), on all four;\n * kind on a component, `site` or `email` — a component stored\n * before AGL-3287 carries none and is read as `site`, so the\n * \"Used in: Page\" filter needs the word stored;\n * on a template, `page`, `component` or `layout` — a template\n * stored with none is read as a page template;\n * source.type on a template, its provenance — one stored with no\n * `source`, or with the legacy `workspace`, was saved here,\n * which is `authored`;\n * libraryRow on a template, whether it is its own row of the templates\n * library: a multi-page STARTER is one row (AGL-696), led by\n * its first live page, so the other pages carry `false` — and\n * so does a deleted template (`artifactDeleteListKeys`), which\n * is how the library's query leaves tombstones out;\n *\n * description on a layout or component that has none, `null` — the\n * lists sort by it (AGL-3680), and an `orderBy` drops every\n * document that lacks the field. A writer that passes a\n * partial document here must pass its `description` too, or\n * the null would overwrite the one it writes;\n * deletedAt on a screen, `null` — the flag a campaign's screens list\n * asks for (`deletedAt == null`) to leave tombstones out. A\n * query cannot ask for a field to be absent, so a live\n * screen must STORE the null; a delete overwrites it with\n * the time (and the security rules let an editor do that\n * only while it is null, so a tombstone stays one);\n *\n * A DELETED screen gets none of them. The email templates list orders by\n * `nameLower`, and an email template's delete clears its name keys so the\n * tombstone leaves that order (a query cannot ask for `deletedAt` to be\n * absent) — so no create, restore or backfill may stamp them back.\n *\n * Every CREATE stamps them (`artifactCreateListKeys`) and every rename\n * restamps the name keys (`artifactRenameListKeys`).\n * `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the documents\n * written before them; a script cannot import this library, so it reads the\n * documents the way this module does, and both sides answer\n * `tools/scripts/lib/artifact-list-keys.fixtures.json` — this module's spec\n * and the backfill's `--self-test`.\n */\n\n/** The site artifact collections whose lists query these keys. */\nexport const LISTED_ARTIFACT_COLLECTIONS = [\n 'screens',\n 'layouts',\n 'components',\n 'templates',\n] as const\n\nexport type ListedArtifactCollection =\n (typeof LISTED_ARTIFACT_COLLECTIONS)[number]\n\n/** Whether a host subcollection is one whose lists query these keys. */\nexport const isListedArtifactCollection = (\n collection: string,\n): collection is ListedArtifactCollection =>\n (LISTED_ARTIFACT_COLLECTIONS as readonly string[]).includes(collection)\n\n/** The kinds a template's `kind` holds; absent reads as `page`. */\nconst TEMPLATE_KINDS = ['page', 'component', 'layout']\n\n/**\n * A provenance word no writer stores any more: a detached marketplace copy\n * was stamped `workspace` before it was stamped `authored` (AGL-3321).\n */\nconst LEGACY_AUTHORED_SOURCE = 'workspace'\n\nconst record = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' ? (value as Record<string, unknown>) : {}\n\n/**\n * The name a list finds an artifact by.\n *\n * Its `displayName` — except a page of a multi-page STARTER, which the\n * templates library lists as ONE row under the starter's name\n * (`source.starterName`), so every page of it carries that name's keys and a\n * search for the starter finds the row whichever page leads it.\n */\nexport function artifactSearchName(\n collection: string,\n doc: Record<string, unknown>,\n): unknown {\n if (collection === 'templates') {\n const starterName = record(doc['source'])['starterName']\n if (typeof starterName === 'string' && starterName.trim()) return starterName\n }\n return doc['displayName']\n}\n\n/**\n * The list keys a CREATE of `doc` into `collection` stamps: the name keys,\n * and the stored default of a kind a reader otherwise infers. Nothing for a\n * collection no artifact list queries.\n *\n * Spread AFTER the document's own fields: the keys are derived from them.\n */\nexport function artifactCreateListKeys(\n collection: string,\n doc: Record<string, unknown>,\n): Record<string, unknown> {\n if (!isListedArtifactCollection(collection)) return {}\n if (collection === 'screens' && doc['deletedAt'] != null) return {}\n const keys: Record<string, unknown> = {\n ...displayNameSearchFields(artifactSearchName(collection, doc)),\n }\n // Stored, not omitted: `deletedAt == null` matches only a document that\n // holds the field (AGL-3321, a campaign's screens).\n if (collection === 'screens') keys['deletedAt'] = null\n // A live layout too: its list asks for `deletedAt == null` rather than\n // dropping tombstones from the page they fall in (AGL-3680).\n if (collection === 'layouts' && doc['deletedAt'] == null) keys['deletedAt'] = null\n if (collection === 'components' && doc['kind'] !== 'email') keys['kind'] = 'site'\n // Stored, not omitted: the Description header orders by it (AGL-3680).\n if ((collection === 'layouts' || collection === 'components') && doc['description'] == null) {\n keys['description'] = null\n }\n if (collection === 'templates') {\n if (!TEMPLATE_KINDS.includes(String(doc['kind']))) keys['kind'] = 'page'\n // Provenance is server-managed (AGL-666), and a writer that states it is\n // never overridden here. None at all, or the legacy word a detached copy\n // carried, was saved here, which is `authored`.\n const sourceType = record(doc['source'])['type']\n if (typeof sourceType !== 'string' || sourceType === LEGACY_AUTHORED_SOURCE) {\n keys['source'] = { ...record(doc['source']), type: 'authored' }\n }\n keys['libraryRow'] = doc['deletedAt'] == null && leadsItsStarter(doc)\n }\n return keys\n}\n\n/**\n * Whether a template leads its starter's library row when the starter is\n * written whole: every template that is not a starter page does, and of a\n * starter's pages the first (`starterOrder` 0). A later delete moves the lead\n * to the next live page — see `artifactDeleteListKeys`.\n */\nfunction leadsItsStarter(doc: Record<string, unknown>): boolean {\n const source = record(doc['source'])\n const starterId = source['starterId']\n if (typeof starterId !== 'string' || !starterId) return true\n return Number(source['starterOrder'] ?? 0) === 0\n}\n\n/**\n * The list keys a SOFT DELETE writes beside `deletedAt`: a deleted template\n * is no longer a library row. A caller deleting the page that LEADS a\n * starter's row gives the lead to the next live page (`libraryRow: true`),\n * or the starter disappears from the library with pages still in it.\n */\nexport function artifactDeleteListKeys(\n collection: string,\n): Record<string, unknown> {\n return collection === 'templates' ? { libraryRow: false } : {}\n}\n\n/**\n * The name keys a RENAME writes beside the new `displayName`. `doc` is the\n * document as read, for the one case whose search name is not its display\n * name (a starter page — see `artifactSearchName`).\n */\nexport function artifactRenameListKeys(\n collection: string,\n displayName: unknown,\n doc: Record<string, unknown> = {},\n): Record<string, unknown> {\n if (!isListedArtifactCollection(collection)) return {}\n return displayNameSearchFields(\n artifactSearchName(collection, { ...doc, displayName }),\n )\n}\n"],"names":["displayNameSearchFields","LISTED_ARTIFACT_COLLECTIONS","isListedArtifactCollection","collection","includes","TEMPLATE_KINDS","LEGACY_AUTHORED_SOURCE","record","value","artifactSearchName","doc","starterName","trim","artifactCreateListKeys","keys","String","sourceType","type","leadsItsStarter","source","starterId","Number","artifactDeleteListKeys","libraryRow","artifactRenameListKeys","displayName"],"mappings":"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"}
|
|
@@ -22,13 +22,29 @@ export declare const ARTIFACT_LIST_ORDER: ListQuerySort;
|
|
|
22
22
|
export declare const ARTIFACT_NAME_SEARCH: {
|
|
23
23
|
tokensPath: string;
|
|
24
24
|
};
|
|
25
|
+
/** The layouts page's header sorts; the ID ascending one is the default. */
|
|
26
|
+
export declare const LAYOUT_LIST_SORTS: readonly ListQuerySort[];
|
|
27
|
+
/**
|
|
28
|
+
* The layouts list's scope: live layouts. Every create stores `deletedAt: null`
|
|
29
|
+
* (`artifactCreateListKeys`) and a delete stamps the time, so the query leaves
|
|
30
|
+
* tombstones out instead of the page dropping them after the read (AGL-3680).
|
|
31
|
+
*/
|
|
32
|
+
export declare const LAYOUT_LIST_BASE: readonly ListQueryFilter[];
|
|
25
33
|
/** `hosts/{hostId}/layouts`. */
|
|
26
34
|
export declare const LAYOUT_LIST_QUERY: ListQueryDeclaration;
|
|
27
35
|
export declare const LAYOUT_LIST_HEADERS: Readonly<Record<string, string>>;
|
|
36
|
+
/** The components card's header sorts; the ID ascending one is the default. */
|
|
37
|
+
export declare const COMPONENT_LIST_SORTS: readonly ListQuerySort[];
|
|
28
38
|
/** `hosts/{hostId}/components`. */
|
|
29
39
|
export declare const COMPONENT_LIST_QUERY: ListQueryDeclaration;
|
|
30
40
|
export declare const COMPONENT_LIST_HEADERS: Readonly<Record<string, string>>;
|
|
31
41
|
export declare const TEMPLATE_LIST_BASE: readonly ListQueryFilter[];
|
|
42
|
+
/**
|
|
43
|
+
* The templates library's header sorts. It shows no ID column, so the walk
|
|
44
|
+
* stays the unlabelled default; Description sorts the page (see "The header
|
|
45
|
+
* sorts").
|
|
46
|
+
*/
|
|
47
|
+
export declare const TEMPLATE_LIST_SORTS: readonly ListQuerySort[];
|
|
32
48
|
export declare const TEMPLATE_LIST_QUERY: ListQueryDeclaration;
|
|
33
49
|
export declare const TEMPLATE_LIST_HEADERS: Readonly<Record<string, string>>;
|
|
34
50
|
/** The template kinds, by the stored `kind`. */
|