@sharadtech/infralytiqs-sdk 1.0.2 → 1.0.3

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 (65) hide show
  1. package/buildScripts/Jenkinsfile.deploy +241 -125
  2. package/clients/publicis/arc/README.md +212 -0
  3. package/clients/publicis/arc/package-lock.json +808 -0
  4. package/clients/publicis/arc/package.json +23 -0
  5. package/clients/publicis/arc/rollup.config.mjs +28 -0
  6. package/clients/publicis/arc/src/index.js +2900 -0
  7. package/clients/publicis/atl/README.md +203 -0
  8. package/clients/publicis/atl/package-lock.json +808 -0
  9. package/clients/publicis/atl/package.json +23 -0
  10. package/clients/publicis/atl/rollup.config.mjs +28 -0
  11. package/clients/publicis/atl/src/index.js +2902 -0
  12. package/clients/publicis/colab/README.md +213 -0
  13. package/clients/publicis/colab/package-lock.json +808 -0
  14. package/clients/publicis/colab/package.json +23 -0
  15. package/clients/publicis/colab/rollup.config.mjs +28 -0
  16. package/clients/publicis/colab/src/index.js +2901 -0
  17. package/clients/publicis/fnacdarty/README.md +210 -0
  18. package/clients/publicis/fnacdarty/package-lock.json +808 -0
  19. package/clients/publicis/fnacdarty/package.json +23 -0
  20. package/clients/publicis/fnacdarty/rollup.config.mjs +28 -0
  21. package/clients/publicis/fnacdarty/src/index.js +2900 -0
  22. package/clients/publicis/garnier/README.md +206 -0
  23. package/clients/publicis/garnier/package-lock.json +808 -0
  24. package/clients/publicis/garnier/package.json +23 -0
  25. package/clients/publicis/garnier/rollup.config.mjs +28 -0
  26. package/clients/publicis/garnier/src/index.js +2894 -0
  27. package/clients/publicis/pmigtr/README.md +212 -0
  28. package/clients/publicis/pmigtr/package-lock.json +808 -0
  29. package/clients/publicis/pmigtr/package.json +23 -0
  30. package/clients/publicis/pmigtr/rollup.config.mjs +28 -0
  31. package/clients/publicis/pmigtr/src/index.js +2903 -0
  32. package/clients/publicis/ps/package-lock.json +2 -2
  33. package/clients/publicis/ps/package.json +1 -1
  34. package/clients/publicis/px/README.md +209 -0
  35. package/clients/publicis/px/package-lock.json +808 -0
  36. package/clients/publicis/px/package.json +23 -0
  37. package/clients/publicis/px/rollup.config.mjs +28 -0
  38. package/clients/publicis/px/src/index.js +2899 -0
  39. package/clients/publicis/pxp/README.md +212 -0
  40. package/clients/publicis/pxp/package-lock.json +808 -0
  41. package/clients/publicis/pxp/package.json +23 -0
  42. package/clients/publicis/pxp/rollup.config.mjs +28 -0
  43. package/clients/publicis/pxp/src/index.js +2900 -0
  44. package/clients/publicis/razorfish/README.md +210 -0
  45. package/clients/publicis/razorfish/package-lock.json +808 -0
  46. package/clients/publicis/razorfish/package.json +23 -0
  47. package/clients/publicis/razorfish/rollup.config.mjs +28 -0
  48. package/clients/publicis/razorfish/src/index.js +2900 -0
  49. package/clients/publicis/stellantis/README.md +208 -0
  50. package/clients/publicis/stellantis/package-lock.json +808 -0
  51. package/clients/publicis/stellantis/package.json +23 -0
  52. package/clients/publicis/stellantis/rollup.config.mjs +28 -0
  53. package/clients/publicis/stellantis/src/index.js +2895 -0
  54. package/clients/publicis/visa/README.md +208 -0
  55. package/clients/publicis/visa/package-lock.json +808 -0
  56. package/clients/publicis/visa/package.json +23 -0
  57. package/clients/publicis/visa/rollup.config.mjs +28 -0
  58. package/clients/publicis/visa/src/index.js +2894 -0
  59. package/dist/infralytiqs.js +243 -71
  60. package/dist/infralytiqs.min.js +2 -2
  61. package/package.json +1 -1
  62. package/src/envConfig.ts +1 -1
  63. package/src/remoteConfig.ts +164 -0
  64. package/src/tracker.ts +30 -0
  65. package/src/types.ts +10 -2
@@ -0,0 +1,2903 @@
1
+ /**
2
+ * Infralytiqs client-specific bootstrap — PMI GTR
3
+ * (damgtr-sit.productioncloud.io, ...)
4
+ *
5
+ * This is the "pmigtr" sibling of the "publicis/ps" bootstrap. Both target
6
+ * the same Adobe DAM Asset Share Commons platform (groupe-dam tenant), so
7
+ * the event taxonomy, selectors, and DOM heuristics are identical; only the
8
+ * host domain, the client-override global namespace (IL_PMIGTR_*), and the
9
+ * internal de-dupe flag prefixes (__ilPmigtr*) differ. The per-tenant
10
+ * server/tenant/site/db values are still read from window.IL_* injected
11
+ * server-side by the AEM Sightly `Infralytiqs.html` fragment (pmigtr runs on
12
+ * site_id = SIT), so this file hard-codes none of them.
13
+ *
14
+ * PMI GTR serves its ASC pages at the site root: login at `/login.html`
15
+ * (an alternate `/login-external.html` SSO entry point also exists), home at
16
+ * `/home.html`, asset explorer at `/asset-explorer.html`, details at
17
+ * `/.../details/<type>.html/<assetPath>`. Its DAM content lives under
18
+ * `/content/dam/pmigtr`. The page-type heuristics below match
19
+ * `login` / `asset-explorer` / `details` anywhere in the path (so both
20
+ * `/login.html` and `/login-external.html` are treated as login pages) and
21
+ * accept any `/content/dam/...` asset path, so the DAM root needs no special
22
+ * handling.
23
+ *
24
+ * Pipeline:
25
+ * 1. The host page embeds the Infralytiqs SDK from the CDN:
26
+ * <script src="https://assets.infralytiqs.com/cdn/infralytiqs.min.js"></script>
27
+ * 2. The host page injects per-tenant configuration as `window.IL_*` globals
28
+ * (server-side rendered via the Sightly `Infralytiqs.html` fragment).
29
+ * 3. This bootstrap loads after the SDK + config and:
30
+ * a. Reads window.IL_* and Infralytiqs.init() with browser-side enrichment.
31
+ * b. Resolves the logged-in AEM user via /libs/granite/security/currentuser.json
32
+ * and calls Infralytiqs.identify() so subsequent events carry user_id.
33
+ * c. Tracks two families of events:
34
+ *
35
+ * ──────────────────────────────────────────────────────────────
36
+ * LOGIN PAGE (auth_state = anonymous)
37
+ * ──────────────────────────────────────────────────────────────
38
+ * login_page_view — anonymous landing on the login page
39
+ * login_attempt — Sign-in button submit (captures typed username,
40
+ * NEVER the password)
41
+ * sso_click — "Login with Lion Login" SSO button click
42
+ * terms_click — "Terms and Conditions" link click
43
+ *
44
+ * ──────────────────────────────────────────────────────────────
45
+ * ASSET SHARE COMMONS (auth_state = authenticated)
46
+ * (search_context = "asc" — URL-driven, pushState SPA)
47
+ * ──────────────────────────────────────────────────────────────
48
+ * search_executed — fired on every page whose URL carries `?fulltext=…`.
49
+ * Captures search term, semantic / fuzzy toggles,
50
+ * active filters (group, human label, property,
51
+ * operation, values) cross-referenced against the
52
+ * right-rail predicate sections so reports can
53
+ * render `ASSET TYPE = Image` instead of
54
+ * `jcr:content/metadata/ps:assettype = Image`,
55
+ * order-by, sort direction, layout (card | list),
56
+ * offset / limit, plus the search-statistics widget
57
+ * (`search_time_ms`, `result_total`, `result_pages`)
58
+ * once ASC finishes rendering the result count and
59
+ * response time in the right rail. The bootstrap
60
+ * briefly observes the statistics block before
61
+ * firing so the timing reflects the search the
62
+ * user actually just executed (not a stale value
63
+ * left over from the previous in-page navigation).
64
+ *
65
+ * asset_preview — fired on every page whose URL matches
66
+ * /details/<type>.html/<assetPath>. Captures the
67
+ * /content/dam/... path, the asset-share asset type
68
+ * (image / video / document / 3d / …), file name
69
+ * and extension.
70
+ *
71
+ * asset_download — fired when the browser POSTs to the ASC download
72
+ * endpoint (network interception, NOT button click,
73
+ * so it captures bulk-download from card menus,
74
+ * detail-page button, and the dropdown rendition
75
+ * picker uniformly).
76
+ * Captures asset_path + rendition_name from the
77
+ * form-encoded POST body.
78
+ *
79
+ * asset_add_to_cart — fired on click of any element matched by the
80
+ * `addToCart` selector list, the `[data-il-cart-add]`
81
+ * opt-in attribute, or visible text /add to cart/i.
82
+ * Captures the asset_path from data-attrs on the
83
+ * button or its nearest card ancestor.
84
+ *
85
+ * asset_share — fired on click of any element matched by the
86
+ * `shareButton` selector list or visible text
87
+ * /share/i. Placeholder until the share workflow
88
+ * is finalized.
89
+ *
90
+ * ──────────────────────────────────────────────────────────────
91
+ * ASSET EXPLORER (search_context = "asset_explorer")
92
+ * /asset-explorer.html#/content/dam/... (React SPA, hash routing)
93
+ * ──────────────────────────────────────────────────────────────
94
+ * search_executed — fired when the user runs a search inside the
95
+ * explorer. Listens to every plausible trigger
96
+ * (Enter on the input, native form submit,
97
+ * Search Options modal close, right-rail filter
98
+ * click, debounced typing) and carries the
99
+ * original `search_trigger` on each event so we
100
+ * can prune redundant hooks once production
101
+ * tells us which path actually fires. Captures
102
+ * search_term, semantic_search, fuzzy_search
103
+ * (read from the toggles in the open Search
104
+ * Options modal — see readAssetExplorerToggleState
105
+ * for the four heuristics tried), `folder_path`
106
+ * (the `#/content/dam/...` hash), and the stats
107
+ * widget (`search_time_ms`, `result_total`,
108
+ * `result_displayed`, `result_pages` when shown)
109
+ * once it settles. Subtype is `asset_search` —
110
+ * same as ASC — so a single ClickHouse query can
111
+ * report cross-context search volume.
112
+ *
113
+ * folder_navigation — fired whenever the hash route changes (user
114
+ * clicked into another DAM folder). Carries
115
+ * `folder_path` + the standard browser-side
116
+ * enrichment dimensions. Subtype is `asset_browse`.
117
+ *
118
+ * asset_preview — reuses the ASC details-page detector when the
119
+ * explorer hands off to a /details/<type>.html URL,
120
+ * plus the body-level click delegation so a
121
+ * right-side preview panel that exposes a
122
+ * `data-asset-path` attribute is also captured.
123
+ *
124
+ * asset_download — same network-interception path as ASC, since the
125
+ * download endpoint is shared at the AEM backend
126
+ * layer.
127
+ *
128
+ * asset_add_to_cart, asset_share — same click delegation as ASC.
129
+ *
130
+ * Selector strategy
131
+ * -----------------
132
+ * The DOM may evolve, so every selector is overridable via window globals
133
+ * (set BEFORE this bootstrap runs):
134
+ *
135
+ * window.IL_PMIGTR_SELECTORS = {
136
+ * // Login page
137
+ * loginForm: '#loginForm',
138
+ * signInButton: 'button[type="submit"]',
139
+ * userField: 'input[name="username"]',
140
+ * ssoButton: '[data-il-sso-lion]',
141
+ * termsLink: 'a[href*="terms"]',
142
+ * // ASC
143
+ * searchInput: 'input[name="fulltext"]',
144
+ * filterRail: '#rail',
145
+ * addToCart: '[data-il-cart-add]',
146
+ * shareButton: '[data-il-share]'
147
+ * };
148
+ *
149
+ * If none are provided we fall back to a robust default set that matches
150
+ * by id, name, role, data-attribute, and visible text — so the bootstrap
151
+ * is essentially zero-config for the common ASC layout.
152
+ *
153
+ * The host page can also override how the logged-in user is resolved:
154
+ *
155
+ * window.IL_PMIGTR_USER_RESOLVER = function () {
156
+ * return 'qa-ps-asc-global'; // sync
157
+ * // or: return Promise.resolve('qa-ps-asc-global');
158
+ * };
159
+ */
160
+ (function () {
161
+ 'use strict';
162
+
163
+ if (window.__ilPmigtrBootstrapped) {
164
+ return;
165
+ }
166
+ window.__ilPmigtrBootstrapped = true;
167
+
168
+ // ─── 1. Read host-page configuration ──────────────────────
169
+ var SERVER_URL = window.IL_SERVER_URL;
170
+ var TENANT_ID = window.IL_TENANT_ID;
171
+ var SITE_ID = window.IL_SITE_ID;
172
+ var DB_NAME = window.IL_DB_NAME;
173
+
174
+ if (!SERVER_URL || !TENANT_ID || !SITE_ID) {
175
+ return;
176
+ }
177
+
178
+ var USER_OVERRIDES = (window.IL_PMIGTR_SELECTORS && typeof window.IL_PMIGTR_SELECTORS === 'object')
179
+ ? window.IL_PMIGTR_SELECTORS
180
+ : {};
181
+
182
+ // ─── 2. Default selectors ────────────────────────────────
183
+ // Each is a comma-separated list of CSS selectors tried in order. The first
184
+ // element that matches the page wins. Text-based fallback below handles
185
+ // pages whose controls don't carry stable IDs.
186
+ var SELECTORS = {
187
+ // ── Login page ─────────────────────────────────────────
188
+ loginForm: USER_OVERRIDES.loginForm
189
+ || 'form#loginForm, form[name="loginForm"], form[action*="login"], form[action*="signin"]',
190
+ signInButton: USER_OVERRIDES.signInButton
191
+ || '#signInButton, #signin, #signInBtn, #loginButton, button[name="signin"], button[data-action="signin"], button[type="submit"]',
192
+ userField: USER_OVERRIDES.userField
193
+ || '#username, #userId, #email, input[name="username"], input[name="userId"], input[name="email"], input[type="email"]',
194
+ ssoButton: USER_OVERRIDES.ssoButton
195
+ || '#lionLogin, [data-il-sso-lion], [data-sso="lion-login"], button[name="lion-login"], a[href*="lion-login"], a[href*="lionlogin"]',
196
+ termsLink: USER_OVERRIDES.termsLink
197
+ || 'a[data-il-terms], a#termsLink, a[href*="terms-and-conditions"], a[href*="terms_and_conditions"], a[href*="/terms"], a[href*="terms.html"]',
198
+
199
+ // ── Asset Share Commons ───────────────────────────────
200
+ // The search input on damgtr-sit.productioncloud.io has `name="fulltext"` and the
201
+ // `data-search-input` attribute set by the ASC search-bar component
202
+ // (id includes a per-render numeric suffix, so we don't depend on it).
203
+ searchInput: USER_OVERRIDES.searchInput
204
+ || 'input[name="fulltext"], [data-search-input], input.cmp-fulltext, input[type="search"][data-cmp-hook-search-input]',
205
+ // The ASC right-rail container that holds the filter/predicate panel.
206
+ // On damgtr-sit.productioncloud.io this is `#rail` inside the asset-share-commons
207
+ // root; we keep a comma-separated fallback list so the bootstrap also
208
+ // covers older / themed deployments that use a `.sidebar` wrapper.
209
+ filterRail: USER_OVERRIDES.filterRail
210
+ || '#rail, .search-rail, [data-cmp-is="search-rail"], #asset-share-commons .ui.wide.sidebar.right, .ui.wide.sidebar.right',
211
+ // Search-statistics widget (right-rail, "X assets / 54 ms / Y pages").
212
+ // On damgtr-sit.productioncloud.io this is `.cmp.cmp-search-statistics` containing
213
+ // a Semantic-UI `.ui.mini.three.statistics` block with three `.statistic`
214
+ // tiles. We keep alternative selectors for themed / re-skinned variants.
215
+ searchStatistics: USER_OVERRIDES.searchStatistics
216
+ || '.cmp.cmp-search-statistics, [class*="cmp-search-statistics"], [data-cmp-is="search-statistics"], .search-statistics, .cmp-searchstatistics',
217
+ // Asset-level Add-to-Cart button (per-tile / per-card / details
218
+ // page). The Asset Share Commons convention puts the asset path on
219
+ // `data-asset-share-asset` and the button-id on `data-asset-share-id`.
220
+ // We prioritise the data attributes so the button can be detected
221
+ // even when rendered as an icon-only variant with no visible label.
222
+ addToCart: USER_OVERRIDES.addToCart
223
+ || '[data-asset-share-id="add-to-cart"], [data-asset-share-id="addToCart"], [data-il-cart-add], [data-cmp-cart-action="add"], [data-asset-cart-action="add"], button.cart-add, a.cart-add, button[data-action="cart-add"]',
224
+ // Asset-level Share button. Distinct from the cart-level share
225
+ // (`data-asset-share-id="share-all"`) which the click delegation
226
+ // checks BEFORE this so they never overlap.
227
+ shareButton: USER_OVERRIDES.shareButton
228
+ || '[data-asset-share-id="share-asset"], [data-asset-share-id="share"], [data-il-share], [data-cmp-share-action], button.share, a.share, button[data-action="share"]',
229
+ // ── Cart & downloads (ASC modal flow, used on the asset-explorer too) ──
230
+ // Top-bar cart icon — opens the cart modal. The Asset Share
231
+ // Commons template renders this as a Semantic UI `<i class="big
232
+ // cart icon">` inside an `<a class="item">` link. We keep a
233
+ // data-attribute path in front for installations that follow
234
+ // newer ASC conventions.
235
+ cartOpenButton: USER_OVERRIDES.cartOpenButton
236
+ || '[data-asset-share-id="cart"], [data-asset-share-id="open-cart"], a[data-il-cart-open], a.item > i.cart.icon, a.item > i.big.cart.icon, .cmp-structure-user-menu i.cart.icon, .cmp-structure-user-menu i.big.cart.icon',
237
+ // Cart-modal "Share Cart" button — applies to the WHOLE cart.
238
+ // The DOM uses `data-asset-share-id="share-all"`.
239
+ cartShareButton: USER_OVERRIDES.cartShareButton
240
+ || '[data-asset-share-id="share-all"], [data-asset-share-id="shareCart"]',
241
+ // Cart-modal "Download Cart" button — initiates the cart-download
242
+ // flow (a second confirmation modal opens afterwards, then the
243
+ // actual binaries are produced and listed in the downloads modal).
244
+ cartDownloadButton: USER_OVERRIDES.cartDownloadButton
245
+ || '[data-asset-share-id="download-all"], [data-asset-share-id="downloadCart"]',
246
+ // Cart modal itself — used to scrape the cart contents at the
247
+ // moment Share Cart / Download Cart is clicked.
248
+ cartModal: USER_OVERRIDES.cartModal
249
+ || 'form.cmp-modal-cart, .cmp-modal-cart, [class*="cmp-modal-cart"]',
250
+ // Top-bar downloads icon — opens the downloads-list modal.
251
+ downloadsOpenButton: USER_OVERRIDES.downloadsOpenButton
252
+ || '[data-asset-share-id="downloads"], [data-asset-share-id="open-downloads"], a[data-il-downloads-open], a.item > i.download.icon, a.item > i.big.download.icon, .cmp-structure-user-menu i.download.icon, .cmp-structure-user-menu i.big.download.icon',
253
+ // Per-artifact download link inside the downloads modal. Anchors
254
+ // pointing at the AEM download-binaries endpoint with a
255
+ // `downloadId` query string. We deliberately accept ANY scheme /
256
+ // host the href might carry — the path is the stable identifier.
257
+ downloadBinaryLink: USER_OVERRIDES.downloadBinaryLink
258
+ || 'a[href*="downloadbinaries.json"], a[href*="downloadbinaries"][href*="downloadId="]',
259
+ // Per-download "remove from downloads" button. Carries the
260
+ // download UUID on `data-asset-share-download-id`.
261
+ downloadRemoveButton: USER_OVERRIDES.downloadRemoveButton
262
+ || '[data-asset-share-id="remove-from-downloads"], [data-asset-share-id="removeFromDownloads"]',
263
+ // ── Cart-Share modal (cmp-modal-share) ─────────────────
264
+ // The "Share Cart" button opens a SECOND modal that collects the
265
+ // recipient e-mails, the "Share Publicly" flag, and the optional
266
+ // start/expiry dates. The class anchor `cmp-modal-share` is
267
+ // stable across themes and the variant suffix
268
+ // `__wrapper--initial` covers the "fresh" state; we accept all
269
+ // wrapper variants because some themes also produce
270
+ // `__wrapper--success` / `__wrapper--error` after submission.
271
+ shareModal: USER_OVERRIDES.shareModal
272
+ || 'form.cmp-modal-share, form[class*="cmp-modal-share"], [class*="cmp-modal-share__wrapper"]',
273
+ // Comma-delimited e-mail recipients. The DOM uses
274
+ // <input type="email" multiple name="email">; the type+name
275
+ // pairing is the most stable anchor.
276
+ shareEmailInput: USER_OVERRIDES.shareEmailInput
277
+ || 'form.cmp-modal-share input[name="email"], form[class*="cmp-modal-share"] input[name="email"], [class*="cmp-modal-share"] input[type="email"][name="email"]',
278
+ // "Share Publicly" checkbox. ASC renders this without a data
279
+ // attribute, so we use the Semantic UI wrapper + child input
280
+ // pattern. The reader (below) also accepts a label-text match
281
+ // so a future template tweak that renames the wrapper won't
282
+ // silently dark-launch.
283
+ sharePublicCheckbox: USER_OVERRIDES.sharePublicCheckbox
284
+ || 'form.cmp-modal-share .ui.checkbox input[type="checkbox"], form[class*="cmp-modal-share"] .ui.checkbox input[type="checkbox"]',
285
+ // External-share date range. Only required when the public
286
+ // checkbox is ticked; the inputs use plain HTML id anchors that
287
+ // the ASC template controls.
288
+ shareStartDateInput: USER_OVERRIDES.shareStartDateInput
289
+ || 'form.cmp-modal-share #startDate, form[class*="cmp-modal-share"] #startDate, form.cmp-modal-share input[name="startDate"], form[class*="cmp-modal-share"] input[name="startDate"]',
290
+ shareExpiryDateInput: USER_OVERRIDES.shareExpiryDateInput
291
+ || 'form.cmp-modal-share #expiryDate, form[class*="cmp-modal-share"] #expiryDate, form.cmp-modal-share input[name="expiryDate"], form[class*="cmp-modal-share"] input[name="expiryDate"]',
292
+
293
+ // ── Asset Explorer (React) ─────────────────────────────
294
+ // /asset-explorer.html is a React SPA whose CSS Modules generate class
295
+ // names with a build-hash suffix that changes on every deploy
296
+ // (`_input_x72oj_15` → `_input_a1b2c_42` on the next build). Every
297
+ // selector below uses [class*="_<stable-prefix>_"] so it survives the
298
+ // hash refresh; we anchor on the human-stable prefix from the source
299
+ // CSS Module file name (`*.module.css`) which is reused across deploys.
300
+ assetExplorerRoot: USER_OVERRIDES.assetExplorerRoot
301
+ || '[class*="_directoryExplorer_"], .directory-explorer',
302
+ assetExplorerForm: USER_OVERRIDES.assetExplorerForm
303
+ || '[class*="_directoryExplorer_"] form, [class*="_form_"]',
304
+ assetExplorerSearchInput: USER_OVERRIDES.assetExplorerSearchInput
305
+ || '[class*="_directoryExplorer_"] input[type="text"], [class*="_inputWrapper_"] input, [class*="_input_"][type="text"]',
306
+ // Each row in the open Search Options modal. We identify "Semantic
307
+ // Search" vs "Fuzzy Search" by visible text, not by row index, in case
308
+ // the modal grows additional rows in a future release.
309
+ assetExplorerSearchOptionRow: USER_OVERRIDES.assetExplorerSearchOptionRow
310
+ || '[class*="_searchOptionRow_"], [class*="_searchOption_Row_"]',
311
+ // The right-side filter rail. Contains both the facet sections and
312
+ // the search-statistics tiles (`N DISPLAYED / N TOTAL / N MILLISECONDS`)
313
+ // at the bottom on damgtr-sit.productioncloud.io.
314
+ assetExplorerFilterRail: USER_OVERRIDES.assetExplorerFilterRail
315
+ || '[class*="_filterWrapper_"], [class*="_wrapper_tqpgs"], [class*="_filtersPanel_"]',
316
+ // Stats container. Live builds use `_statContainer_<hash>` (note the
317
+ // CamelCase — `_stat_` with a trailing underscore would not match) so
318
+ // we look for that prefix first, then progressively widen to other
319
+ // common React naming patterns. The reader pairs `_value_` children
320
+ // with `_label_` siblings inside whichever container matches, and
321
+ // falls back to a regex parse over the filter rail's text content
322
+ // when no structured tiles can be located.
323
+ assetExplorerSearchStatistics: USER_OVERRIDES.assetExplorerSearchStatistics
324
+ || '[class*="_statContainer_"], [class*="_statsContainer_"], [class*="_statisticsContainer_"], [class*="_statistics_"], [class*="_searchStats_"], [class*="_resultStats_"], [class*="_filterWrapper_"]',
325
+ // Filter sections inside the rail. Each section has a heading
326
+ // ("ASSET TYPE", "MARKETING FUNCTION / INDUSTRY", ...) plus its
327
+ // selectable options. We anchor on `_filterSection_`, `_facet_`,
328
+ // `_filterGroup_` and similar React naming conventions, and fall
329
+ // through to `<section>` / `<fieldset>` for un-prefixed builds.
330
+ assetExplorerFilterSection: USER_OVERRIDES.assetExplorerFilterSection
331
+ || '[class*="_filterSection_"], [class*="_filterGroup_"], [class*="_facet_"], [class*="_category_"], [class*="_section_"], section, fieldset',
332
+ // Left-side folder tree inside the explorer's content panel.
333
+ // `_directoryTree_<hash>` wraps the entire tree, `_directoryTreeContent_`
334
+ // is the scrollable inner container. Inner items also carry the
335
+ // `_directoryTree_<another-hash>` prefix from a nested CSS Module so
336
+ // the selector is intentionally permissive.
337
+ assetExplorerFolderTree: USER_OVERRIDES.assetExplorerFolderTree
338
+ || '[class*="_directoryTreeContent_"], [class*="_directoryTreeWrapper_"], [class*="_directoryTree_"]'
339
+ };
340
+
341
+ // Visible-text regexes used as a last-resort fallback when none of the CSS
342
+ // selectors above match. Intentionally case-insensitive and tolerant of
343
+ // surrounding whitespace / punctuation.
344
+ var TEXT_PATTERNS = {
345
+ signIn: /\b(sign[- ]?in|log[- ]?in|login)\b/i,
346
+ lionSso: /\blion\s*login\b/i,
347
+ terms: /\bterms?\s*(?:&|and)\s*conditions?\b/i,
348
+ cart: /\badd\s*to\s*(?:cart|collection)\b/i,
349
+ share: /\bshare\b/i,
350
+ semantic: /\bsemantic\s*search\b/i,
351
+ fuzzy: /\bfuzzy\s*search\b/i,
352
+ // "What are you looking for?" placeholder anchors the search input on
353
+ // /asset-explorer.html when the CSS-Module prefix has not yet been
354
+ // covered by the [class*="_input_"] selectors (themed builds, etc.).
355
+ aeSearchPlaceholder: /\b(looking for|search assets?|find assets?)\b/i
356
+ };
357
+
358
+ // ASC network endpoints — matched against the request URL. Patterns are
359
+ // intentionally tolerant so they survive moves in the AEM content tree
360
+ // (the `/content/<tenant>/<region>/.../actions/...` prefix varies but the
361
+ // suffix `/actions/download/.../download.download-asset-renditions.zip`
362
+ // is the ASC convention).
363
+ var ENDPOINTS = {
364
+ // POST /content/.../actions/download/.../download.download-asset-renditions.zip
365
+ // body: timezone=…&path=<assetPath>&renditionName=<name>&:cq_csrf_token=…
366
+ download: /\/actions\/download(?:\/[^?\s]*)?\/download\.download-asset-renditions\.zip(?:[?#]|$)/,
367
+ // Best-effort cart/share — used to OPPORTUNISTICALLY capture network calls
368
+ // if the click hook missed them. Not authoritative.
369
+ addToCart: /\/(?:cart|asset-collection|asset-collections)(?:\/[^?\s]*)?\.(?:add|json|html)(?:[?#]|$)|\/bin\/asset-share-commons\/cart/i,
370
+ share: /\/(?:share|email-asset|send)(?:\/[^?\s]*)?\.(?:send|json|html)(?:[?#]|$)/i
371
+ };
372
+
373
+ // Granite endpoint that returns { authorizableId, id, ... } for the
374
+ // currently logged-in AEM user. Public; requires the session cookie.
375
+ var CURRENT_USER_ENDPOINT = '/libs/granite/security/currentuser.json';
376
+
377
+ // localStorage cache key for the resolved user. Bypasses the Granite
378
+ // fetch on subsequent page loads so user_id is attached to events
379
+ // synchronously (avoids the "first event after navigation lacks user_id"
380
+ // race during a roundtrip to /libs/granite/...).
381
+ var CACHE_KEY_USER = 'IL_PMIGTR_USER_ID';
382
+
383
+ // ─── 3. SDK access + safe wrappers ───────────────────────
384
+ function getApi() {
385
+ var raw = window.Infralytiqs;
386
+ if (!raw) return null;
387
+ return raw && raw.default && typeof raw.default.init === 'function' ? raw.default : raw;
388
+ }
389
+
390
+ function track(eventType, dims, metrics, subtype) {
391
+ var api = getApi();
392
+ if (!api) return;
393
+ try {
394
+ api.track(eventType, dims || {}, metrics || {}, subtype);
395
+ } catch (e) {
396
+ console.error('[IL-PMIGTR] track failed', eventType, e);
397
+ }
398
+ }
399
+
400
+ function identify(userId) {
401
+ var api = getApi();
402
+ if (!api) return;
403
+ try { api.identify(userId); } catch (e) { /* noop */ }
404
+ }
405
+
406
+ function flush() {
407
+ var api = getApi();
408
+ if (!api) return Promise.resolve();
409
+ try { return api.flush(); } catch (e) { return Promise.resolve(); }
410
+ }
411
+
412
+ // ─── 4. Browser-side enrichment ──────────────────────────
413
+ // Each event picks up these as `custom_dimensions` so reports can break
414
+ // traffic down by browser, screen, timezone, etc. without per-event work.
415
+ function detectBrowser() {
416
+ var ua = (navigator && navigator.userAgent) || '';
417
+ var rules = [
418
+ { name: 'Edge', re: /\bEdg(?:e|A|iOS)?\/([\d\.]+)/i },
419
+ { name: 'Opera', re: /\bOPR\/([\d\.]+)/i },
420
+ { name: 'Samsung', re: /\bSamsungBrowser\/([\d\.]+)/i },
421
+ { name: 'Firefox', re: /\bFirefox\/([\d\.]+)/i },
422
+ { name: 'Chrome', re: /\bChrom(?:e|ium)\/([\d\.]+)/i },
423
+ { name: 'Safari', re: /\bVersion\/([\d\.]+).*Safari\b/i },
424
+ { name: 'IE', re: /\bMSIE\s([\d\.]+)|\bTrident\/.*\brv:([\d\.]+)/i }
425
+ ];
426
+ for (var i = 0; i < rules.length; i++) {
427
+ var m = ua.match(rules[i].re);
428
+ if (m) {
429
+ return { name: rules[i].name, version: (m[1] || m[2] || '').split('.')[0] };
430
+ }
431
+ }
432
+ return { name: 'Other', version: '' };
433
+ }
434
+
435
+ function safeStr(v) {
436
+ return (v === undefined || v === null) ? '' : String(v);
437
+ }
438
+
439
+ function getViewportSize() {
440
+ try {
441
+ var w = window.innerWidth || (document.documentElement && document.documentElement.clientWidth) || 0;
442
+ var h = window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0;
443
+ return w && h ? (w + 'x' + h) : '';
444
+ } catch (e) { return ''; }
445
+ }
446
+
447
+ function getScreenResolution() {
448
+ try {
449
+ var s = window.screen || {};
450
+ var w = s.width || 0;
451
+ var h = s.height || 0;
452
+ return w && h ? (w + 'x' + h) : '';
453
+ } catch (e) { return ''; }
454
+ }
455
+
456
+ function getIanaTimezone() {
457
+ try {
458
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
459
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
460
+ }
461
+ } catch (e) { /* noop */ }
462
+ return '';
463
+ }
464
+
465
+ function getColorScheme() {
466
+ try {
467
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
468
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
469
+ } catch (e) { /* noop */ }
470
+ return '';
471
+ }
472
+
473
+ function buildEnrichmentDims() {
474
+ var browser = detectBrowser();
475
+ var dims = {
476
+ client_app: 'publicis-ps',
477
+ client_origin: location.origin,
478
+ client_tenant: TENANT_ID,
479
+ client_site: SITE_ID,
480
+ browser_name: browser.name,
481
+ browser_version: browser.version,
482
+ viewport_size: getViewportSize(),
483
+ screen_resolution: getScreenResolution(),
484
+ timezone_iana: getIanaTimezone(),
485
+ language_full: safeStr(navigator && navigator.language),
486
+ color_scheme: getColorScheme()
487
+ };
488
+ var clean = {};
489
+ for (var k in dims) {
490
+ if (dims.hasOwnProperty(k) && dims[k] !== '' && dims[k] !== undefined) {
491
+ clean[k] = dims[k];
492
+ }
493
+ }
494
+ return clean;
495
+ }
496
+
497
+ // ─── 5. SDK initialization ───────────────────────────────
498
+ function initSdk() {
499
+ var api = getApi();
500
+ if (!api) return false;
501
+ try {
502
+ api.init({
503
+ serverUrl: SERVER_URL,
504
+ tenantId: TENANT_ID,
505
+ siteId: SITE_ID,
506
+ dbName: DB_NAME,
507
+ debug: !!window.IL_DEBUG,
508
+ captureLocation: window.IL_CAPTURE_LOCATION === true,
509
+ globalDimensions: buildEnrichmentDims()
510
+ });
511
+ } catch (e) {
512
+ console.error('[IL-PMIGTR] init failed', e);
513
+ return false;
514
+ }
515
+ return true;
516
+ }
517
+
518
+ // ─── 6. DOM + URL helpers ────────────────────────────────
519
+ function $(selectorList) {
520
+ if (!selectorList) return null;
521
+ try {
522
+ return document.querySelector(selectorList);
523
+ } catch (e) {
524
+ return null;
525
+ }
526
+ }
527
+
528
+ function visibleText(el) {
529
+ if (!el) return '';
530
+ var t = el.textContent || el.value || el.getAttribute('aria-label') || '';
531
+ return t.replace(/\s+/g, ' ').trim();
532
+ }
533
+
534
+ function closestMatchByText(root, tag, pattern) {
535
+ var nodes = root.querySelectorAll(tag);
536
+ for (var i = 0; i < nodes.length; i++) {
537
+ if (pattern.test(visibleText(nodes[i]))) {
538
+ return nodes[i];
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+
544
+ /**
545
+ * Login-page heuristic: explicit URL hint, or DOM signals (password input
546
+ * inside a form). Anything that matches counts — false positives are
547
+ * harmless (just adds a `login_page` subtype to the page-view).
548
+ */
549
+ function isLoginPage() {
550
+ var href = (location.pathname + location.search + location.hash).toLowerCase();
551
+ // Match /login.html as well as PMI GTR's /login-external.html SSO entry
552
+ // point (login followed by '-' / '_' is still a login page).
553
+ if (/(^|\/)login([-_.]|\/|$|\?)/.test(href)) return true;
554
+ if (/signin/.test(href)) return true;
555
+ if (document.querySelector('input[type="password"]')) return true;
556
+ return false;
557
+ }
558
+
559
+ /**
560
+ * Parses `location.search` into a flat key→value (or key→string[]) map,
561
+ * URL-decoding both keys and values and tolerating duplicate keys.
562
+ */
563
+ function parseQuery() {
564
+ var out = {};
565
+ var qs = (location.search || '').replace(/^\?/, '');
566
+ if (!qs) return out;
567
+ var parts = qs.split('&');
568
+ for (var i = 0; i < parts.length; i++) {
569
+ if (!parts[i]) continue;
570
+ var eqIdx = parts[i].indexOf('=');
571
+ var k = eqIdx >= 0 ? parts[i].slice(0, eqIdx) : parts[i];
572
+ var v = eqIdx >= 0 ? parts[i].slice(eqIdx + 1) : '';
573
+ try { k = decodeURIComponent(k.replace(/\+/g, ' ')); } catch (e) { /* keep raw */ }
574
+ try { v = decodeURIComponent(v.replace(/\+/g, ' ')); } catch (e) { /* keep raw */ }
575
+ if (out[k] === undefined) out[k] = v;
576
+ else if (Array.isArray(out[k])) out[k].push(v);
577
+ else out[k] = [out[k], v];
578
+ }
579
+ return out;
580
+ }
581
+
582
+ /**
583
+ * Parses a form-encoded body (POST request) into a flat map. Accepts a
584
+ * raw string, a FormData, or a URLSearchParams. Returns {} on any error.
585
+ */
586
+ function parseFormBody(body) {
587
+ var out = {};
588
+ if (!body) return out;
589
+ try {
590
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
591
+ body.forEach(function (v, k) { if (!(k in out)) out[k] = String(v); });
592
+ return out;
593
+ }
594
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
595
+ body.forEach(function (v, k) { if (!(k in out)) out[k] = String(v); });
596
+ return out;
597
+ }
598
+ } catch (e) { /* fall through to string parse */ }
599
+
600
+ var s = typeof body === 'string' ? body : '';
601
+ if (!s) return out;
602
+ var parts = s.split('&');
603
+ for (var i = 0; i < parts.length; i++) {
604
+ var p = parts[i];
605
+ if (!p) continue;
606
+ var eqIdx = p.indexOf('=');
607
+ var k = eqIdx >= 0 ? p.slice(0, eqIdx) : p;
608
+ var v = eqIdx >= 0 ? p.slice(eqIdx + 1) : '';
609
+ try { k = decodeURIComponent(k.replace(/\+/g, ' ')); } catch (e) { /* keep raw */ }
610
+ try { v = decodeURIComponent(v.replace(/\+/g, ' ')); } catch (e) { /* keep raw */ }
611
+ if (!(k in out)) out[k] = v;
612
+ }
613
+ return out;
614
+ }
615
+
616
+ /**
617
+ * ASC encodes search-rail filters into the URL using indexed groups, e.g.:
618
+ * 0_group.propertyvalues.property = jcr:content/metadata/ps:assettype
619
+ * 0_group.propertyvalues.operation = equals
620
+ * 0_group.propertyvalues.0_values = Image
621
+ * 1_group.propertyvalues.property = ...
622
+ *
623
+ * This helper walks the parsed query string, buckets params by their
624
+ * `<idx>_group.` prefix, and returns a normalized array of
625
+ * { group, property, operation, values: [...] }
626
+ * suitable for JSON-serializing into a single dimension.
627
+ */
628
+ function extractFilters(q) {
629
+ var groups = {};
630
+ for (var k in q) {
631
+ if (!q.hasOwnProperty(k)) continue;
632
+ var m = k.match(/^(\d+)_group\.(.+)$/);
633
+ if (!m) continue;
634
+ var idx = m[1];
635
+ var rest = m[2];
636
+ if (!groups[idx]) groups[idx] = {};
637
+ groups[idx][rest] = q[k];
638
+ }
639
+ var list = [];
640
+ var keys = Object.keys(groups).sort(function (a, b) { return parseInt(a, 10) - parseInt(b, 10); });
641
+ for (var i = 0; i < keys.length; i++) {
642
+ var g = groups[keys[i]];
643
+ var prop = g['propertyvalues.property'] || g['property.property'] || '';
644
+ var op = g['propertyvalues.operation'] || g['property.operation'] || 'equals';
645
+ var values = [];
646
+ for (var p in g) {
647
+ if (!g.hasOwnProperty(p)) continue;
648
+ // `propertyvalues.0_values`, `propertyvalues.1_values`, ...
649
+ // or `property.0_value`, or singular `value` / `values`.
650
+ if (/^propertyvalues\.\d+_values$|^property\.\d+_value$|^propertyvalues\.values?$|^property\.values?$/.test(p)) {
651
+ var raw = g[p];
652
+ if (Array.isArray(raw)) {
653
+ for (var j = 0; j < raw.length; j++) values.push(raw[j]);
654
+ } else {
655
+ values.push(raw);
656
+ }
657
+ }
658
+ }
659
+ if (prop || values.length) {
660
+ list.push({ group: keys[i], property: prop, operation: op, values: values });
661
+ }
662
+ }
663
+ return list;
664
+ }
665
+
666
+ /**
667
+ * Locates the ASC right-rail filter container.
668
+ *
669
+ * The visible filter sections (ASSET TYPE, MARKETING FUNCTION / INDUSTRY,
670
+ * APPLICATION & USE, ORIGIN, ASSET USAGE RIGHTS, LANGUAGE / REGION, YEAR,
671
+ * LAST MODIFIED, CREATED, …) live inside this container along with the
672
+ * hidden inputs that ASC's predicate components use to encode the
673
+ * `<idx>_group.propertyvalues.property` URL params.
674
+ */
675
+ function findFilterRail() {
676
+ return $(SELECTORS.filterRail);
677
+ }
678
+
679
+ function cleanText(s) {
680
+ return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
681
+ }
682
+
683
+ /**
684
+ * Builds a property → human-readable label map by scanning the rail for
685
+ * predicate blocks. Each ASC predicate (`cmp-predicate`) carries a hidden
686
+ * `…propertyvalues.property` input and a visible heading. We walk up from
687
+ * the hidden input until we find the predicate block, then take its
688
+ * heading text as the label.
689
+ *
690
+ * Returns e.g.
691
+ * { 'jcr:content/metadata/ps:assettype': 'ASSET TYPE',
692
+ * 'jcr:content/metadata/cq:tags': 'MARKETING FUNCTION / INDUSTRY',
693
+ * ... }
694
+ */
695
+ function buildPropertyLabelMap(rail) {
696
+ var map = {};
697
+ if (!rail || !rail.querySelectorAll) return map;
698
+ var propInputs;
699
+ try {
700
+ // Hidden inputs whose name ends with `.property` or `.propertyvalues.property`.
701
+ propInputs = rail.querySelectorAll(
702
+ 'input[name$=".property"], ' +
703
+ 'input[name$=".propertyvalues.property"], ' +
704
+ '[data-cmp-predicate-property]'
705
+ );
706
+ } catch (e) { return map; }
707
+
708
+ for (var i = 0; i < propInputs.length; i++) {
709
+ var node = propInputs[i];
710
+ var prop = (node.tagName === 'INPUT' ? node.value : node.getAttribute('data-cmp-predicate-property')) || '';
711
+ prop = String(prop);
712
+ if (!prop || map[prop]) continue;
713
+
714
+ var section = (node.closest && node.closest(
715
+ '.cmp-predicate, [data-cmp-is="predicate"], .predicate, ' +
716
+ '.cmp-search-predicate, .filter-section, section, fieldset'
717
+ )) || node.parentNode;
718
+
719
+ // Walk up looking for a section heading. Stop at the rail boundary
720
+ // so we don't accidentally pick up the entire panel's outer label.
721
+ while (section && section !== rail && section.nodeType === 1) {
722
+ var heading = section.querySelector(
723
+ '.cmp-predicate__title, [data-cmp-hook-predicate-title], ' +
724
+ '[data-predicate-title], legend, h2, h3, h4, h5, ' +
725
+ '.predicate-title, .filter-title'
726
+ );
727
+ if (heading && cleanText(heading.textContent)) {
728
+ map[prop] = cleanText(heading.textContent);
729
+ break;
730
+ }
731
+ section = section.parentNode;
732
+ }
733
+ }
734
+ return map;
735
+ }
736
+
737
+ /**
738
+ * Scans the rail for currently-selected filter inputs and groups them by
739
+ * the visible section heading they live under. This is a belt-and-braces
740
+ * capture: it picks up predicates whose state lives only in the DOM (and
741
+ * isn't visible in the URL) — and it lets us emit a clean
742
+ * [{ name: "ASSET TYPE", values: ["Image"] }, ...]
743
+ * even when the property→label map didn't resolve.
744
+ */
745
+ function scanRailSelectedFilters(rail) {
746
+ if (!rail || !rail.querySelectorAll) return [];
747
+ var groupsByLabel = {};
748
+
749
+ var selected;
750
+ try {
751
+ selected = rail.querySelectorAll(
752
+ 'input[type="checkbox"]:checked, ' +
753
+ 'input[type="radio"]:checked, ' +
754
+ '[aria-checked="true"], ' +
755
+ '[data-selected="true"], ' +
756
+ '.is-selected:not(.disabled), ' +
757
+ '.cmp-predicate__option--selected'
758
+ );
759
+ } catch (e) { return []; }
760
+
761
+ for (var i = 0; i < selected.length; i++) {
762
+ var n = selected[i];
763
+ // Hidden, submit, and button inputs are infrastructure, not selections.
764
+ if (n.type === 'hidden' || n.type === 'submit' || n.type === 'button') continue;
765
+
766
+ // Prefer the visible label associated with the input.
767
+ var value = '';
768
+ try {
769
+ if (n.labels && n.labels.length) value = cleanText(n.labels[0].textContent);
770
+ } catch (e) { /* `labels` not supported */ }
771
+ if (!value) {
772
+ var labelEl = (n.closest && n.closest('label')) || null;
773
+ if (labelEl) value = cleanText(labelEl.textContent);
774
+ }
775
+ if (!value) value = cleanText(n.value);
776
+ if (!value || value.toLowerCase() === 'on') continue;
777
+
778
+ // Walk up to the predicate section + its heading.
779
+ var label = '';
780
+ var section = (n.closest && n.closest(
781
+ '.cmp-predicate, [data-cmp-is="predicate"], .predicate, ' +
782
+ '.cmp-search-predicate, .filter-section, section, fieldset'
783
+ )) || null;
784
+ var cur = section;
785
+ while (cur && cur !== rail && cur.nodeType === 1) {
786
+ var heading = cur.querySelector(
787
+ '.cmp-predicate__title, [data-cmp-hook-predicate-title], ' +
788
+ '[data-predicate-title], legend, h2, h3, h4, h5, ' +
789
+ '.predicate-title, .filter-title'
790
+ );
791
+ if (heading && cleanText(heading.textContent)) {
792
+ label = cleanText(heading.textContent);
793
+ break;
794
+ }
795
+ cur = cur.parentNode;
796
+ }
797
+ if (!label) label = 'filter';
798
+
799
+ if (!groupsByLabel[label]) groupsByLabel[label] = [];
800
+ if (groupsByLabel[label].indexOf(value) === -1) groupsByLabel[label].push(value);
801
+ }
802
+
803
+ var out = [];
804
+ for (var k in groupsByLabel) {
805
+ if (groupsByLabel.hasOwnProperty(k)) out.push({ name: k, values: groupsByLabel[k] });
806
+ }
807
+ return out;
808
+ }
809
+
810
+ /**
811
+ * Parses a stat value text into a Float64. Tolerates thousand separators,
812
+ * whitespace, and trailing unit suffixes like "ms", "s", "seconds".
813
+ * Returns null when the text doesn't contain a usable number.
814
+ */
815
+ function parseStatNumber(txt) {
816
+ if (txt == null) return null;
817
+ var s = String(txt).replace(/[\s,]/g, '');
818
+ var m = s.match(/-?\d+(?:\.\d+)?/);
819
+ if (!m) return null;
820
+ var n = parseFloat(m[0]);
821
+ return isFinite(n) ? n : null;
822
+ }
823
+
824
+ /**
825
+ * Classifies a stat-widget label to one of the metrics we surface.
826
+ *
827
+ * Observed ASC label variants (verified against `damgtr-sit.productioncloud.io`
828
+ * search-stats raw_json captured 2026-05-28):
829
+ * "Displayed" → result_displayed (count shown on current page)
830
+ * "Total" → result_total (total assets matched)
831
+ * "Milliseconds" → search_time_ms (server-side query latency)
832
+ * And the wider OOTB ASC family:
833
+ * "Time", "Time (ms)", "Search Time", "Elapsed", "Duration",
834
+ * "Latency", "Response" → search_time_ms
835
+ * "Seconds" → search_time_ms (×1000 → ms)
836
+ * "Assets", "Results", "Hits", "Matches", "Found", "Count"
837
+ * → result_total
838
+ * "Pages" → result_pages
839
+ * "Showing", "Shown" → result_displayed (alternate displayed labels)
840
+ *
841
+ * Returns null for unrecognized labels — the caller must not invent an
842
+ * attribution from position alone unless EVERY label in the widget came
843
+ * back null (see readSearchStatistics() pass 2).
844
+ */
845
+ function classifyStatLabel(label) {
846
+ var s = String(label || '').toLowerCase();
847
+ if (!s) return null;
848
+ if (/\b(millisecond|msec|\bms\b)/.test(s)) return { key: 'time_ms', unit: 'ms' };
849
+ if (/\b(second|\bs\b)\b/.test(s)) return { key: 'time_ms', unit: 's' };
850
+ if (/\b(time|elapsed|duration|latency|response)\b/.test(s))
851
+ return { key: 'time_ms', unit: 'ms' };
852
+ if (/\b(displayed|showing|shown|visible)\b/.test(s))
853
+ return { key: 'displayed', unit: '' };
854
+ if (/\b(asset|result|hit|match|found|total|count)\b/.test(s))
855
+ return { key: 'total', unit: '' };
856
+ if (/\bpages?\b/.test(s)) return { key: 'pages', unit: '' };
857
+ return null;
858
+ }
859
+
860
+ /**
861
+ * Reads the ASC `cmp-search-statistics` widget into a normalized shape:
862
+ * { container,
863
+ * stats: {
864
+ * time_ms, total, pages,
865
+ * raw, // [{ idx, label, value }] in DOM order — diagnostic
866
+ * claimed // Set<idx> of blocks already attributed to a metric
867
+ * }
868
+ * }
869
+ * `time_ms`, `total`, `pages` are Float64s (or null when not displayed);
870
+ * `raw` is an ordered list so we can inspect classifier mistakes from
871
+ * the back-end (we JSON-serialize it onto every search_executed event
872
+ * as `custom_dimensions.search_stats_raw_json`). Returns
873
+ * `{ container: <el|null>, stats: null }` when the widget hasn't been
874
+ * mounted yet OR has no populated `.value` cells.
875
+ *
876
+ * Positional fallback (1st=total, 2nd=time_ms, 3rd=pages) only fires
877
+ * for blocks NOT already claimed by the label classifier — so a single
878
+ * cell can never be attributed to two metrics at once.
879
+ */
880
+ function readSearchStatistics() {
881
+ var container = $(SELECTORS.searchStatistics);
882
+ if (!container || !container.querySelectorAll) {
883
+ return { container: null, stats: null };
884
+ }
885
+
886
+ // First-pass: gather (index, label, value) tuples from every leaf
887
+ // `.statistic` block. We deliberately exclude the outer `.statistics`
888
+ // wrapper that `[class*="statistic"]` would otherwise match.
889
+ var statBlocks = container.querySelectorAll(
890
+ '.statistic, [class*="statistic"]:not(.statistics)'
891
+ );
892
+ var rows = [];
893
+ for (var i = 0; i < statBlocks.length; i++) {
894
+ var block = statBlocks[i];
895
+ if (block.querySelector && block.querySelector('.statistic')) continue;
896
+
897
+ var labelEl = block.querySelector('.label, [class*="label"]');
898
+ var valueEl = block.querySelector('.value, [class*="value"]');
899
+ if (!valueEl) continue;
900
+
901
+ var labelText = labelEl ? cleanText(labelEl.textContent) : '';
902
+ var rawText = cleanText(valueEl.textContent);
903
+ var n = parseStatNumber(rawText);
904
+ rows.push({ idx: rows.length, label: labelText, valueText: rawText, value: n });
905
+ }
906
+ if (!rows.length || !rows.some(function (r) { return r.value !== null; })) {
907
+ return { container: container, stats: null };
908
+ }
909
+
910
+ var stats = {
911
+ time_ms: null,
912
+ total: null,
913
+ pages: null,
914
+ displayed: null,
915
+ raw: rows.map(function (r) { return { idx: r.idx, label: r.label, value: r.valueText }; })
916
+ };
917
+ var claimed = {};
918
+ var anyClassified = false;
919
+
920
+ // Pass 1: label-driven classification — authoritative whenever the
921
+ // label is recognized. Skips blocks whose `.value` was empty.
922
+ for (var k = 0; k < rows.length; k++) {
923
+ var r = rows[k];
924
+ if (r.value === null) continue;
925
+ var c = classifyStatLabel(r.label);
926
+ if (!c) continue;
927
+ anyClassified = true;
928
+ // First match wins for each metric, so a duplicate label (rare) on
929
+ // a later block doesn't clobber an earlier one.
930
+ if (c.key === 'time_ms' && stats.time_ms === null) {
931
+ // Convert seconds → ms when label explicitly says seconds, so
932
+ // the metric column is always milliseconds regardless of label.
933
+ stats.time_ms = c.unit === 's' ? r.value * 1000 : r.value;
934
+ claimed[r.idx] = true;
935
+ } else if (c.key === 'total' && stats.total === null) {
936
+ stats.total = r.value;
937
+ claimed[r.idx] = true;
938
+ } else if (c.key === 'pages' && stats.pages === null) {
939
+ stats.pages = r.value;
940
+ claimed[r.idx] = true;
941
+ } else if (c.key === 'displayed' && stats.displayed === null) {
942
+ stats.displayed = r.value;
943
+ claimed[r.idx] = true;
944
+ }
945
+ }
946
+
947
+ // Pass 2: positional fallback. We deliberately keep this STRICT — it
948
+ // only fires when *no* label in the widget was recognized at all (a
949
+ // re-themed deployment with non-English / custom labels). Otherwise we
950
+ // trust the labels we DID recognize and leave any unclassified blocks
951
+ // out of the event, so an unrelated tile (e.g. "Displayed") can never
952
+ // bleed into `result_pages` or `search_time_ms`.
953
+ if (!anyClassified) {
954
+ // Stock ASC OOTB column order: 1) total assets, 2) time (ms), 3) pages.
955
+ if (rows[0] && rows[0].value !== null) {
956
+ stats.total = rows[0].value;
957
+ claimed[rows[0].idx] = true;
958
+ }
959
+ if (rows[1] && rows[1].value !== null) {
960
+ stats.time_ms = rows[1].value;
961
+ claimed[rows[1].idx] = true;
962
+ }
963
+ if (rows[2] && rows[2].value !== null) {
964
+ stats.pages = rows[2].value;
965
+ claimed[rows[2].idx] = true;
966
+ }
967
+ }
968
+
969
+ return { container: container, stats: stats };
970
+ }
971
+
972
+ /**
973
+ * Waits for a search-statistics widget to be populated for the CURRENT
974
+ * search and then invokes `cb(stats)`. Falls back to `cb(stats|null)`
975
+ * after `timeoutMs` (default 3000) so the search_executed event is never
976
+ * silenced indefinitely just because the widget didn't show up.
977
+ *
978
+ * `reader` is the page-specific stats-reader function returning the
979
+ * shared `{ container, stats: { time_ms, total, pages, displayed, raw } }`
980
+ * shape — `readSearchStatistics` for ASC, `readAssetExplorerSearchStatistics`
981
+ * for the React-based explorer. This way the wait/observe/baseline logic
982
+ * is written once and both contexts get the same race-safety guarantees.
983
+ *
984
+ * On the first invocation per page load the currently-displayed value is
985
+ * trusted (server-side rendered for the URL we just loaded). On every
986
+ * subsequent invocation — i.e. a pushState navigation — we cache the
987
+ * pre-call snapshot and require a MutationObserver hit AND a value
988
+ * difference vs the snapshot before resolving. That avoids attributing
989
+ * the previous search's "Time" reading to the new search.
990
+ *
991
+ * Returns a `cancel()` function so a fresh search that fires before the
992
+ * previous wait completes can abort the previous wait without leaving
993
+ * dangling observers.
994
+ */
995
+ function waitForSearchStats(reader, timeoutMs, cb) {
996
+ var read = typeof reader === 'function' ? reader : readSearchStatistics;
997
+ var done = false;
998
+ var mo = null;
999
+ var timer = null;
1000
+
1001
+ function teardown() {
1002
+ if (mo) { try { mo.disconnect(); } catch (e) {} mo = null; }
1003
+ if (timer) { clearTimeout(timer); timer = null; }
1004
+ }
1005
+
1006
+ function finish(stats) {
1007
+ if (done) return;
1008
+ done = true;
1009
+ teardown();
1010
+ try { cb(stats); } catch (e) { /* swallow */ }
1011
+ }
1012
+
1013
+ var initial = read();
1014
+ var baselineTime = initial.stats ? initial.stats.time_ms : null;
1015
+ var baselineTot = initial.stats ? initial.stats.total : null;
1016
+
1017
+ // First search of the page: the widget's current value WAS rendered for
1018
+ // this URL (either SSR or by the SDK's earlier auto-init pass), so we
1019
+ // can fire immediately.
1020
+ if (__searchFireCount === 0 && initial.stats && baselineTime !== null) {
1021
+ finish(initial.stats);
1022
+ return function noopCancel() {};
1023
+ }
1024
+
1025
+ var observeRoot = initial.container || document.body || document.documentElement;
1026
+
1027
+ timer = setTimeout(function () {
1028
+ // Hard timeout: fire with whatever the widget is showing right now,
1029
+ // even if it didn't change from the baseline. Better to record the
1030
+ // search with no time than to drop it.
1031
+ var late = read();
1032
+ finish(late.stats || null);
1033
+ }, timeoutMs || 3000);
1034
+
1035
+ if (typeof MutationObserver === 'function' && observeRoot) {
1036
+ try {
1037
+ mo = new MutationObserver(function () {
1038
+ var probe = read();
1039
+ if (!probe.stats || probe.stats.time_ms === null) return;
1040
+ // Require at least one of the two key cells to have changed vs
1041
+ // baseline. Cards, pagination, and skeleton loaders also mutate
1042
+ // the DOM while the search XHR is in flight — we don't want any
1043
+ // of those to count as "stats ready".
1044
+ if (probe.stats.time_ms !== baselineTime || probe.stats.total !== baselineTot) {
1045
+ finish(probe.stats);
1046
+ }
1047
+ });
1048
+ mo.observe(observeRoot, {
1049
+ childList: true,
1050
+ subtree: true,
1051
+ characterData: true
1052
+ });
1053
+ } catch (e) { /* fall back to timer-only path */ }
1054
+ }
1055
+
1056
+ return function cancel() {
1057
+ if (done) return;
1058
+ done = true;
1059
+ teardown();
1060
+ };
1061
+ }
1062
+
1063
+ /**
1064
+ * Pretty-fallback when the property→label map didn't have an entry. Picks
1065
+ * the most meaningful trailing token of a JCR-style path so reports don't
1066
+ * have to render `jcr:content/metadata/ps:assettype` unaltered.
1067
+ *
1068
+ * jcr:content/metadata/ps:assettype → ps:assettype
1069
+ * jcr:content/metadata/cq:tags → cq:tags
1070
+ * jcr:content/jcr:lastModified → jcr:lastModified
1071
+ */
1072
+ function deriveFallbackName(prop) {
1073
+ if (!prop) return '';
1074
+ var s = String(prop);
1075
+ var slash = s.lastIndexOf('/');
1076
+ return slash >= 0 ? s.slice(slash + 1) : s;
1077
+ }
1078
+
1079
+ /**
1080
+ * Detects an asset-preview / details URL anywhere in the path. Matches
1081
+ * both layouts we've observed on damgtr-sit.productioncloud.io:
1082
+ *
1083
+ * ASC (root-level):
1084
+ * /details/image.html/content/dam/ps/branded-assets/Rolex-1.jpeg
1085
+ *
1086
+ * Asset Explorer (nested under the AEM page path):
1087
+ * /content/ps/global/us/en/home/asset-explorer/details/image.html/content/dam/ps/brand-toolkit/bmwi4_029.jpg
1088
+ *
1089
+ * We deliberately require the captured `assetPath` to start with
1090
+ * `/content/`, so a page accidentally named `details.html` somewhere else
1091
+ * in the tree can't match. Returns null when the URL isn't a details
1092
+ * page in either layout.
1093
+ */
1094
+ function detectDetailsAssetPath() {
1095
+ var m = location.pathname.match(/\/details\/([^/]+)\.html(\/content\/.+)$/);
1096
+ if (!m) return null;
1097
+ return { assetType: m[1], assetPath: m[2] };
1098
+ }
1099
+
1100
+ /**
1101
+ * Walks up from a clicked element looking for a DAM asset path:
1102
+ * 1. `data-asset-path`, `data-asset`, `data-cmp-asset`, or
1103
+ * `data-foundation-collection-item-id` carrying `/content/dam/…`.
1104
+ * 2. An ancestor `<a href="/details/…">` from which we extract the path.
1105
+ * Returns '' if nothing matches.
1106
+ */
1107
+ function extractAssetPathFromEl(el) {
1108
+ var cur = el;
1109
+ while (cur && cur !== document.body && cur.nodeType === 1) {
1110
+ // `data-asset-share-asset` is the ASC convention used by the
1111
+ // explorer tiles (`<button data-asset-share-id="add-to-cart"
1112
+ // data-asset-share-asset="/content/dam/...">`). Check it first so
1113
+ // we don't accidentally walk up to a card-level fallback when the
1114
+ // button itself already carries the precise asset.
1115
+ var dataAttrs = [
1116
+ 'data-asset-share-asset',
1117
+ 'data-asset-path',
1118
+ 'data-asset',
1119
+ 'data-cmp-asset',
1120
+ 'data-foundation-collection-item-id',
1121
+ 'data-path'
1122
+ ];
1123
+ for (var i = 0; i < dataAttrs.length; i++) {
1124
+ var v = cur.getAttribute && cur.getAttribute(dataAttrs[i]);
1125
+ if (v && /^\/content\/dam\//.test(v)) return v;
1126
+ }
1127
+ var href = (cur.getAttribute && (cur.getAttribute('data-href') || cur.getAttribute('href'))) || '';
1128
+ if (href) {
1129
+ var mm = href.match(/\/details\/[^/]+\.html(\/content\/dam\/[^\s?#]+)/);
1130
+ if (mm) return mm[1];
1131
+ }
1132
+ cur = cur.parentNode;
1133
+ }
1134
+ return '';
1135
+ }
1136
+
1137
+ /**
1138
+ * Parses a URL or query-string fragment into a plain object. Trivial
1139
+ * shim around `URLSearchParams` that survives IE / older Safari where
1140
+ * the constructor isn't available. Used to pull `downloadId` and
1141
+ * `artifactId` from the asset-download anchor href.
1142
+ */
1143
+ function parseUrlParams(href) {
1144
+ var out = {};
1145
+ if (!href) return out;
1146
+ var q = String(href);
1147
+ var hashIdx = q.indexOf('#');
1148
+ if (hashIdx >= 0) q = q.slice(0, hashIdx);
1149
+ var qIdx = q.indexOf('?');
1150
+ if (qIdx < 0) return out;
1151
+ var pairs = q.slice(qIdx + 1).split('&');
1152
+ for (var i = 0; i < pairs.length; i++) {
1153
+ var kv = pairs[i].split('=');
1154
+ if (!kv[0]) continue;
1155
+ var k, v;
1156
+ try { k = decodeURIComponent(kv[0].replace(/\+/g, ' ')); } catch (e) { k = kv[0]; }
1157
+ try { v = kv.length > 1 ? decodeURIComponent(kv[1].replace(/\+/g, ' ')) : ''; } catch (e) { v = kv[1] || ''; }
1158
+ out[k] = v;
1159
+ }
1160
+ return out;
1161
+ }
1162
+
1163
+ /**
1164
+ * Reads the current contents of the ASC cart modal (if open). Returns
1165
+ * an array of unique `/content/dam/...` paths discovered via, in
1166
+ * order:
1167
+ *
1168
+ * 1. `data-asset-share-asset` attributes (preferred — the cart
1169
+ * template renders these on each row),
1170
+ * 2. `data-asset-path` / `data-asset` / `data-cmp-asset` as
1171
+ * fallbacks for older / themed ASC builds,
1172
+ * 3. `<a href="/.../details/<type>.html/content/dam/...">` links,
1173
+ * 4. hidden `<input name="path">` form fields (legacy ASC).
1174
+ *
1175
+ * Returns `[]` when the modal isn't currently in the DOM — the
1176
+ * caller can attach an empty `cart_asset_paths_json=""` and a
1177
+ * `cart_item_count=0` so reports can distinguish "no cart open" from
1178
+ * "cart open but empty".
1179
+ */
1180
+ function readCartContents() {
1181
+ var modal = document.querySelector(SELECTORS.cartModal);
1182
+ if (!modal) return [];
1183
+ var paths = [];
1184
+ var seen = {};
1185
+ function add(p) {
1186
+ if (!p) return;
1187
+ if (!/^\/content\/dam\//.test(p)) return;
1188
+ if (seen[p]) return;
1189
+ seen[p] = true;
1190
+ paths.push(p);
1191
+ }
1192
+ var attrAttempts = ['data-asset-share-asset', 'data-asset-path', 'data-asset', 'data-cmp-asset'];
1193
+ for (var a = 0; a < attrAttempts.length; a++) {
1194
+ var els = modal.querySelectorAll('[' + attrAttempts[a] + ']');
1195
+ for (var i = 0; i < els.length; i++) {
1196
+ add(els[i].getAttribute(attrAttempts[a]));
1197
+ }
1198
+ }
1199
+ var anchors = modal.querySelectorAll('a[href*="/details/"]');
1200
+ for (var j = 0; j < anchors.length; j++) {
1201
+ var href = anchors[j].getAttribute('href') || '';
1202
+ var m = href.match(/\/details\/[^/]+\.html(\/content\/dam\/[^\s?#]+)/);
1203
+ if (m) add(m[1]);
1204
+ }
1205
+ var inputs = modal.querySelectorAll('input[name="path"], input[name="paths"]');
1206
+ for (var k = 0; k < inputs.length; k++) {
1207
+ add(inputs[k].value);
1208
+ }
1209
+ return paths;
1210
+ }
1211
+
1212
+ // ─── 7. User identity resolution ─────────────────────────
1213
+ function readCachedUserId() {
1214
+ try { return localStorage.getItem(CACHE_KEY_USER) || ''; } catch (e) { return ''; }
1215
+ }
1216
+ function writeCachedUserId(u) {
1217
+ try { localStorage.setItem(CACHE_KEY_USER, String(u || '')); } catch (e) { /* private mode etc. */ }
1218
+ }
1219
+
1220
+ /**
1221
+ * Resolves the AEM authorizable id of the currently logged-in user.
1222
+ * Resolution order:
1223
+ * 1. host-page-provided `window.IL_PMIGTR_USER_RESOLVER` (sync string or Promise)
1224
+ * 2. `<meta name="cq:user" | "user" | "user-id">`
1225
+ * 3. `/libs/granite/security/currentuser.json` (Granite endpoint)
1226
+ *
1227
+ * Calls `cb('')` for anonymous sessions; never throws.
1228
+ *
1229
+ * We deliberately use the unwrapped (pre-install) `fetch` so that our
1230
+ * own identity probe doesn't echo through the network interception
1231
+ * installed below for asset_download.
1232
+ */
1233
+ var ORIG_FETCH = (typeof window.fetch === 'function') ? window.fetch.bind(window) : null;
1234
+
1235
+ function resolveCurrentUser(cb) {
1236
+ try {
1237
+ if (typeof window.IL_PMIGTR_USER_RESOLVER === 'function') {
1238
+ var v = window.IL_PMIGTR_USER_RESOLVER();
1239
+ if (typeof v === 'string') { cb(v || ''); return; }
1240
+ if (v && typeof v.then === 'function') {
1241
+ v.then(function (s) { cb(typeof s === 'string' ? s : ''); }, function () { cb(''); });
1242
+ return;
1243
+ }
1244
+ }
1245
+ } catch (e) { /* fall through */ }
1246
+
1247
+ var meta = document.querySelector('meta[name="cq:user"], meta[name="user"], meta[name="user-id"]');
1248
+ if (meta && meta.getAttribute('content')) { cb(meta.getAttribute('content')); return; }
1249
+
1250
+ if (ORIG_FETCH) {
1251
+ ORIG_FETCH(CURRENT_USER_ENDPOINT, {
1252
+ credentials: 'same-origin',
1253
+ headers: { 'Accept': 'application/json' }
1254
+ })
1255
+ .then(function (r) { return r && r.ok ? r.json() : null; })
1256
+ .then(function (j) {
1257
+ if (!j) { cb(''); return; }
1258
+ cb(j.authorizableId || j.id || j.userId || j.name || '');
1259
+ })
1260
+ .catch(function () { cb(''); });
1261
+ return;
1262
+ }
1263
+ cb('');
1264
+ }
1265
+
1266
+ function resolveAndIdentifyUser() {
1267
+ var cached = readCachedUserId();
1268
+ if (cached) identify(cached);
1269
+ resolveCurrentUser(function (u) {
1270
+ if (u && u !== cached) {
1271
+ writeCachedUserId(u);
1272
+ identify(u);
1273
+ }
1274
+ });
1275
+ }
1276
+
1277
+ // ─── 8. URL-driven events ────────────────────────────────
1278
+
1279
+ // Cross-call state for trackSearchExecuted ↔ waitForSearchStats. Kept at
1280
+ // the bootstrap scope (not per-call) so we can flush an in-flight wait
1281
+ // when the user navigates to a new search before the previous one's
1282
+ // statistics widget finished updating — otherwise the old call's
1283
+ // MutationObserver would pick up the NEW search's stats.
1284
+ var __searchFireCount = 0;
1285
+ var __cancelInflightStatsWait = null;
1286
+ var __flushInflightSearchNow = null;
1287
+
1288
+ /**
1289
+ * EVENT (login page only): Anonymous landing on the Login page.
1290
+ *
1291
+ * The SDK already auto-fires a `page_view` on every load (including this
1292
+ * one), but we add an explicit `login_page_view` event so the Reports
1293
+ * "login funnel" panel can distinguish login-page traffic from generic
1294
+ * site traffic and segment anonymous visitors before identify() fires.
1295
+ */
1296
+ function trackLoginPageView() {
1297
+ if (!isLoginPage()) return;
1298
+ track('login_page_view', {
1299
+ page_type: 'login',
1300
+ auth_state: 'anonymous',
1301
+ page_url: location.href,
1302
+ referrer: document.referrer || ''
1303
+ }, {}, 'anonymous');
1304
+ }
1305
+
1306
+ /**
1307
+ * EVENT 1 (search): fires when the current URL is a search-results page.
1308
+ *
1309
+ * ASC submits the search form via GET, so every search materializes as
1310
+ * a navigation. The new page-load runs this bootstrap fresh; if the URL
1311
+ * carries `?fulltext=…` we have a verified search execution AND all the
1312
+ * inputs the user supplied (term, semantic toggle, fuzzy toggle, rail
1313
+ * filters, ordering, layout, pagination) right there in the query string.
1314
+ *
1315
+ * This is more reliable than hooking the submit handler because it also
1316
+ * captures bookmarked searches, browser-back navigations, and shareable
1317
+ * search URLs — every one of which counts as a "search executed" event.
1318
+ */
1319
+ function trackSearchExecuted(q) {
1320
+ if (!q.hasOwnProperty('fulltext')) return;
1321
+
1322
+ // A new search arrived before the previous wait completed. Fire the
1323
+ // previous event NOW with whatever it has — we cannot let its
1324
+ // MutationObserver pick up THIS search's stats and mis-attribute them.
1325
+ if (__flushInflightSearchNow) {
1326
+ try { __flushInflightSearchNow(); } catch (e) {}
1327
+ __flushInflightSearchNow = null;
1328
+ }
1329
+ if (__cancelInflightStatsWait) {
1330
+ try { __cancelInflightStatsWait(); } catch (e) {}
1331
+ __cancelInflightStatsWait = null;
1332
+ }
1333
+
1334
+ // 1. URL is the canonical source of WHICH filters were applied — group,
1335
+ // property, operation, values are all encoded there.
1336
+ var urlFilters = extractFilters(q);
1337
+
1338
+ // 2. Enrich each URL filter with the human-readable section heading
1339
+ // from the right rail (ASSET TYPE, MARKETING FUNCTION / INDUSTRY,
1340
+ // APPLICATION & USE, ORIGIN, ASSET USAGE RIGHTS, LANGUAGE / REGION,
1341
+ // YEAR, LAST MODIFIED, CREATED, …). Falls back to the property's
1342
+ // trailing JCR segment if the rail isn't present (e.g. event fired
1343
+ // from a shared URL on a page without the rail).
1344
+ var rail = findFilterRail();
1345
+ var labelMap = buildPropertyLabelMap(rail);
1346
+ for (var i = 0; i < urlFilters.length; i++) {
1347
+ var f = urlFilters[i];
1348
+ f.name = labelMap[f.property] || deriveFallbackName(f.property);
1349
+ }
1350
+
1351
+ // 3. DOM-only sweep as a safety net — captures any predicate that is
1352
+ // selected in the rail but didn't appear in the URL (e.g. local UI
1353
+ // state that hadn't been submitted yet, or predicates encoded by a
1354
+ // non-standard component). Indexed by visible section heading so
1355
+ // reports can render it without per-filter mapping.
1356
+ var domFilters = scanRailSelectedFilters(rail);
1357
+
1358
+ var dims = {
1359
+ search_term: safeStr(q.fulltext),
1360
+ semantic_search: String(safeStr(q.semanticSearchEnabled).toLowerCase() === 'true'),
1361
+ fuzzy_search: String(safeStr(q.fuzzySearchEnabled).toLowerCase() === 'true'),
1362
+ filters_json: urlFilters.length ? JSON.stringify(urlFilters) : '',
1363
+ filters_dom_json: domFilters.length ? JSON.stringify(domFilters) : '',
1364
+ orderby: safeStr(q.orderby),
1365
+ order_dir: safeStr(q['orderby.sort']),
1366
+ layout: safeStr(q.layout),
1367
+ search_context: 'asc',
1368
+ page_url: location.href,
1369
+ page_referrer: document.referrer || ''
1370
+ };
1371
+ var metrics = {
1372
+ filter_count: urlFilters.length,
1373
+ filter_dom_count: domFilters.length,
1374
+ result_offset: parseInt(safeStr(q['p.offset']) || '0', 10) || 0,
1375
+ result_limit: parseInt(safeStr(q['p.limit']) || '0', 10) || 0
1376
+ };
1377
+
1378
+ var fired = false;
1379
+ function fireWithStats(stats) {
1380
+ if (fired) return;
1381
+ fired = true;
1382
+ __cancelInflightStatsWait = null;
1383
+ __flushInflightSearchNow = null;
1384
+
1385
+ if (stats) {
1386
+ if (stats.time_ms !== null) metrics.search_time_ms = stats.time_ms;
1387
+ if (stats.total !== null) metrics.result_total = stats.total;
1388
+ if (stats.pages !== null) metrics.result_pages = stats.pages;
1389
+ if (stats.displayed !== null) metrics.result_displayed = stats.displayed;
1390
+ dims.search_stats_ready = 'true';
1391
+ // Persist the raw label→value pairs in DOM order so any classifier
1392
+ // misattribution can be diagnosed from the back-end (e.g. ASC
1393
+ // re-labels "Time (ms)" → "Search Speed" in a future release).
1394
+ // Truncated to 240 chars to stay well under any ClickHouse Map
1395
+ // value size limit and avoid bloating event payloads.
1396
+ try {
1397
+ if (stats.raw && stats.raw.length) {
1398
+ var rawJson = JSON.stringify(stats.raw);
1399
+ dims.search_stats_raw_json = rawJson.length > 240
1400
+ ? rawJson.slice(0, 240)
1401
+ : rawJson;
1402
+ }
1403
+ } catch (e) { /* swallow */ }
1404
+ } else {
1405
+ dims.search_stats_ready = 'false';
1406
+ }
1407
+
1408
+ // Fourth arg is the event subtype — surfaces in ClickHouse's
1409
+ // event_subtype column. ASC fires our bootstrap once per full page
1410
+ // load, but also navigates via history.pushState when the user
1411
+ // tweaks a filter / pagination / layout / sort. We re-fire search
1412
+ // events on those pushState transitions too (see installSpaNavHooks
1413
+ // below), and tag both initial + SPA fires with the SAME subtype so
1414
+ // reports don't have to special-case how the URL was reached.
1415
+ track('search_executed', dims, metrics, 'asset_search');
1416
+ __searchFireCount++;
1417
+ }
1418
+
1419
+ // Stash a synchronous flush hook so the next trackSearchExecuted call
1420
+ // can force this one to fire (without stats) before starting its own
1421
+ // wait — see the early-return block at the top of this function.
1422
+ __flushInflightSearchNow = function () { fireWithStats(null); };
1423
+ __cancelInflightStatsWait = waitForSearchStats(readSearchStatistics, 3000, fireWithStats);
1424
+ }
1425
+
1426
+ /**
1427
+ * EVENT 2 (asset preview): fires when the current URL is an ASC details
1428
+ * page — i.e. a thumbnail click in the results grid navigated here.
1429
+ *
1430
+ * The asset path (`/content/dam/…`) is the suffix of `location.pathname`
1431
+ * after `/details/<type>.html`; the asset type is the segment in front
1432
+ * of `.html` (image, video, document, 3d, audio, …).
1433
+ */
1434
+ function trackAssetPreview() {
1435
+ var info = detectDetailsAssetPath();
1436
+ if (!info) return;
1437
+ var assetName = (info.assetPath.split('/').pop() || '');
1438
+ var dotIdx = assetName.lastIndexOf('.');
1439
+ var assetExt = dotIdx > 0 ? assetName.slice(dotIdx + 1).toLowerCase() : '';
1440
+ track('asset_preview', {
1441
+ asset_path: info.assetPath,
1442
+ asset_type: info.assetType,
1443
+ asset_name: assetName,
1444
+ asset_extension: assetExt,
1445
+ page_url: location.href,
1446
+ page_referrer: document.referrer || ''
1447
+ }, {}, 'asset_detail');
1448
+ }
1449
+
1450
+ /**
1451
+ * Wraps `history.pushState` / `history.replaceState` (and listens for
1452
+ * `popstate`) so we re-evaluate the URL on every SPA navigation and
1453
+ * re-fire `search_executed` / `asset_preview` if appropriate.
1454
+ *
1455
+ * Why this matters: Asset Share Commons rewires its in-page filter /
1456
+ * sort / layout / pagination controls to call `history.pushState`
1457
+ * rather than doing a full page reload. Without this hook the SDK's
1458
+ * own pushState wrapper still fires a `page_view` with
1459
+ * `event_subtype = "spa_navigation"`, but our search_executed event
1460
+ * (with `search_term`, `semantic_search`, `fuzzy_search`,
1461
+ * `filters_json`, etc. on the `custom_dimensions` map) would never
1462
+ * fire on those in-page transitions.
1463
+ *
1464
+ * Dedup: we keep the previous URL in a closure variable and skip
1465
+ * re-firing when the URL hasn't actually changed (e.g. some libraries
1466
+ * call replaceState with the current state on hash-only updates).
1467
+ */
1468
+ function installSpaNavHooks() {
1469
+ if (window.__ilPmigtrSpaHooked) return;
1470
+ window.__ilPmigtrSpaHooked = true;
1471
+
1472
+ var lastUrl = location.href;
1473
+
1474
+ function reevaluate() {
1475
+ try {
1476
+ var nowUrl = location.href;
1477
+ if (nowUrl === lastUrl) return;
1478
+ lastUrl = nowUrl;
1479
+ var q = parseQuery();
1480
+ trackSearchExecuted(q);
1481
+ trackAssetPreview();
1482
+ } catch (e) { /* swallow — never break the host page */ }
1483
+ }
1484
+
1485
+ // Defer slightly so the URL change has actually settled in the address
1486
+ // bar by the time we read `location.href` (some routers call pushState
1487
+ // synchronously inside an event handler that hasn't yet committed).
1488
+ function scheduleReevaluate() {
1489
+ try { (window.requestAnimationFrame || setTimeout)(reevaluate, 0); }
1490
+ catch (e) { setTimeout(reevaluate, 0); }
1491
+ }
1492
+
1493
+ window.addEventListener('popstate', scheduleReevaluate);
1494
+ window.addEventListener('hashchange', scheduleReevaluate);
1495
+
1496
+ if (window.history && typeof history.pushState === 'function') {
1497
+ var origPush = history.pushState;
1498
+ history.pushState = function () {
1499
+ var ret = origPush.apply(this, arguments);
1500
+ scheduleReevaluate();
1501
+ return ret;
1502
+ };
1503
+ }
1504
+ if (window.history && typeof history.replaceState === 'function') {
1505
+ var origReplace = history.replaceState;
1506
+ history.replaceState = function () {
1507
+ var ret = origReplace.apply(this, arguments);
1508
+ scheduleReevaluate();
1509
+ return ret;
1510
+ };
1511
+ }
1512
+ }
1513
+
1514
+ // ─── 8b. Asset Explorer (React) ──────────────────────────
1515
+
1516
+ /**
1517
+ * Returns true when the current URL is the React-based Asset Explorer
1518
+ * (e.g. /asset-explorer.html#/content/dam/ps). We accept the URL signal
1519
+ * first because it doesn't require the DOM to have settled, and fall
1520
+ * back to a DOM probe for themed deployments that mount the explorer
1521
+ * under a different path.
1522
+ */
1523
+ function isAssetExplorerPage() {
1524
+ var p = location.pathname;
1525
+ // Asset-preview / details URLs are a *child* of the explorer page
1526
+ // (`.../asset-explorer/details/<type>.html/<asset-path>`) and we want
1527
+ // those to fall into the ASC-style branch so trackAssetPreview() fires.
1528
+ // The details-detector above is the authoritative match for that.
1529
+ if (detectDetailsAssetPath()) return false;
1530
+ // URL path heuristic: matches both `/asset-explorer.html` (vanity URL)
1531
+ // and `/content/.../asset-explorer.html` (resolved page path).
1532
+ if (/\/asset[-_]?explorer(\.html|\/|$)/i.test(p)) return true;
1533
+ if (document.querySelector('[class*="_directoryExplorer_"], .directory-explorer')) return true;
1534
+ return false;
1535
+ }
1536
+
1537
+ function findAssetExplorerRoot() {
1538
+ return $(SELECTORS.assetExplorerRoot);
1539
+ }
1540
+
1541
+ /**
1542
+ * Locates the visible search input. Falls back to placeholder-text match
1543
+ * if the [class*="_input_"] selectors haven't been baked into a themed
1544
+ * deployment yet.
1545
+ */
1546
+ function findAssetExplorerSearchInput() {
1547
+ var byClass = $(SELECTORS.assetExplorerSearchInput);
1548
+ if (byClass) return byClass;
1549
+ var inputs = document.querySelectorAll('input[type="text"], input:not([type])');
1550
+ for (var i = 0; i < inputs.length; i++) {
1551
+ var p = inputs[i].getAttribute('placeholder') || '';
1552
+ if (TEXT_PATTERNS.aeSearchPlaceholder.test(p)) return inputs[i];
1553
+ }
1554
+ return null;
1555
+ }
1556
+
1557
+ /**
1558
+ * Returns the current `#/content/dam/...` folder path the explorer is
1559
+ * showing. Strips the leading `#` and any embedded query suffix so the
1560
+ * value is directly comparable to AEM JCR paths.
1561
+ */
1562
+ function readAssetExplorerFolderPath() {
1563
+ var h = String(location.hash || '');
1564
+ h = h.replace(/^#/, '');
1565
+ var qIdx = h.indexOf('?');
1566
+ if (qIdx >= 0) h = h.slice(0, qIdx);
1567
+ return h;
1568
+ }
1569
+
1570
+ /**
1571
+ * Reads the on/off state of a labelled toggle inside the Search Options
1572
+ * modal. Toggle markup varies across React component libraries so we
1573
+ * walk several known patterns before giving up:
1574
+ *
1575
+ * 1. native checkbox / radio (`checked`)
1576
+ * 2. `aria-checked` / `aria-pressed` / `aria-selected`
1577
+ * 3. class names containing "checked", "active", "enabled", "on"
1578
+ * 4. `data-state="on"` (Radix UI) / `data-selected="true"`
1579
+ *
1580
+ * Returns `'true'` / `'false'` strings so the value drops directly into
1581
+ * the ClickHouse `custom_dimensions` map without further serialization,
1582
+ * and `''` when the labelled row isn't present (modal not open). The
1583
+ * caller treats `''` as "unknown" and won't emit a misleading default.
1584
+ */
1585
+ function readAssetExplorerToggleState(labelRe) {
1586
+ var rows = document.querySelectorAll(SELECTORS.assetExplorerSearchOptionRow);
1587
+ for (var i = 0; i < rows.length; i++) {
1588
+ var row = rows[i];
1589
+ var text = cleanText(row.textContent || '');
1590
+ if (!labelRe.test(text)) continue;
1591
+
1592
+ // 1. native form control
1593
+ var checkbox = row.querySelector('input[type="checkbox"], input[type="radio"]');
1594
+ if (checkbox && checkbox.checked !== undefined) {
1595
+ return String(!!checkbox.checked);
1596
+ }
1597
+
1598
+ // 2. ARIA
1599
+ var ariaChecked = row.querySelector('[aria-checked], [aria-pressed], [aria-selected]');
1600
+ if (ariaChecked) {
1601
+ var v = ariaChecked.getAttribute('aria-checked')
1602
+ || ariaChecked.getAttribute('aria-pressed')
1603
+ || ariaChecked.getAttribute('aria-selected');
1604
+ if (v === 'true' || v === 'false') return v;
1605
+ }
1606
+
1607
+ // 3. data-state / data-selected
1608
+ var dataStated = row.querySelector('[data-state], [data-selected]');
1609
+ if (dataStated) {
1610
+ var ds = (dataStated.getAttribute('data-state') || '').toLowerCase();
1611
+ if (ds === 'on' || ds === 'checked' || ds === 'true') return 'true';
1612
+ if (ds === 'off' || ds === 'unchecked' || ds === 'false') return 'false';
1613
+ var sel = (dataStated.getAttribute('data-selected') || '').toLowerCase();
1614
+ if (sel === 'true') return 'true';
1615
+ if (sel === 'false') return 'false';
1616
+ }
1617
+
1618
+ // 4. class-name heuristic on the row OR its toggle child
1619
+ var classRoot = row.querySelector('[class*="toggle"], [class*="switch"], [role="switch"]') || row;
1620
+ var cls = (classRoot.className && classRoot.className.baseVal !== undefined)
1621
+ ? classRoot.className.baseVal // SVG element
1622
+ : String(classRoot.className || '');
1623
+ if (/(^|[\s_-])(active|checked|enabled|on|selected)([\s_-]|$)/i.test(cls)) return 'true';
1624
+ if (/(^|[\s_-])(inactive|unchecked|disabled|off)([\s_-]|$)/i.test(cls)) return 'false';
1625
+
1626
+ // Row matched but we couldn't determine state — return empty so the
1627
+ // caller emits "" rather than a misleading default.
1628
+ return '';
1629
+ }
1630
+ return '';
1631
+ }
1632
+
1633
+ /**
1634
+ * Reads the Asset Explorer search-statistics tiles.
1635
+ *
1636
+ * The live damgtr-sit.productioncloud.io build renders three tiles inside a
1637
+ * `<div class="_statContainer_1qyta_1">` (camelCase suffix → not
1638
+ * matched by `[class*="_stat_"]`!) where each tile is a sibling
1639
+ * `<div>` containing a `<div class="_value_1qyta_9">N</div>` and a
1640
+ * sibling `<div class="_label_*">LABEL</div>`. We try in order:
1641
+ *
1642
+ * 1. find the stats container (or fall back to the filter rail),
1643
+ * 2. for every `[class*="_value_"]` descendant, pair it with a
1644
+ * `[class*="_label_"]` peer found by climbing the closest tile
1645
+ * wrapper (`<div>` parent) — covers both
1646
+ * "value-then-label" and "label-then-value" tile layouts,
1647
+ * 3. as a last resort, regex-parse the container's textContent for
1648
+ * `<number><whitespace?><LABEL>` so a stripped-down themed build
1649
+ * with no _value_/_label_ class hints still resolves.
1650
+ *
1651
+ * Returns the same `{ container, stats: { time_ms, total, pages,
1652
+ * displayed, raw } }` shape as readSearchStatistics() so the shared
1653
+ * waitForSearchStats() works unchanged.
1654
+ */
1655
+ function readAssetExplorerSearchStatistics() {
1656
+ var container = $(SELECTORS.assetExplorerSearchStatistics);
1657
+ if (!container) return { container: null, stats: null };
1658
+
1659
+ var rows = [];
1660
+ var seen = {};
1661
+
1662
+ function pushRow(label, valueText, value) {
1663
+ var key = (label || '').toLowerCase() + ':' + valueText;
1664
+ if (seen[key]) return;
1665
+ seen[key] = true;
1666
+ rows.push({ idx: rows.length, label: label, valueText: valueText, value: value });
1667
+ }
1668
+
1669
+ // ── Strategy 1: _value_ / _label_ pairing ───────────────
1670
+ // Walk every `[class*="_value_"]` in the container, then look for a
1671
+ // sibling/uncle/parent text that's the label. This handles the
1672
+ // `_statContainer_ > div[i] > {_value_, _label_}` shape and the
1673
+ // common variant where value and label are wrapped together in
1674
+ // a `<div>` tile.
1675
+ var valueEls = container.querySelectorAll('[class*="_value_"]');
1676
+ for (var i = 0; i < valueEls.length; i++) {
1677
+ var vEl = valueEls[i];
1678
+ var valueText = cleanText(vEl.textContent);
1679
+ var n = parseStatNumber(valueText);
1680
+ if (n === null) continue;
1681
+
1682
+ // Skip $value$ blocks that aren't direct text values (e.g., a
1683
+ // nested wrapper). Only first-level numeric content qualifies.
1684
+ if (!/^[-+]?\d/.test(valueText)) continue;
1685
+
1686
+ var labelText = '';
1687
+
1688
+ // a) Sibling [class*="_label_"]
1689
+ if (vEl.parentElement) {
1690
+ var sibLabel = vEl.parentElement.querySelector('[class*="_label_"]');
1691
+ if (sibLabel && sibLabel !== vEl) labelText = cleanText(sibLabel.textContent);
1692
+ }
1693
+
1694
+ // b) Walk up one level — tile may wrap value+label in a div whose
1695
+ // parent has a separate [class*="_label_"] child.
1696
+ if (!labelText && vEl.parentElement && vEl.parentElement.parentElement) {
1697
+ var unc = vEl.parentElement.parentElement.querySelector('[class*="_label_"]');
1698
+ if (unc && unc !== vEl) labelText = cleanText(unc.textContent);
1699
+ }
1700
+
1701
+ // c) Adjacent textContent — parent's text minus the value
1702
+ if (!labelText && vEl.parentElement) {
1703
+ var parentText = cleanText(vEl.parentElement.textContent);
1704
+ if (parentText && parentText.indexOf(valueText) >= 0) {
1705
+ var residual = parentText.replace(valueText, '').trim();
1706
+ // Only keep if residual is short (a label) not a paragraph.
1707
+ if (residual && residual.length <= 80) labelText = residual;
1708
+ }
1709
+ }
1710
+
1711
+ pushRow(labelText, valueText, n);
1712
+ }
1713
+
1714
+ // ── Strategy 2: regex over container text ───────────────
1715
+ // Pattern: `<number><whitespace?><LABEL>` repeated (e.g.
1716
+ // "4 DISPLAYED 4 TOTAL 11 MILLISECONDS" or "4DISPLAYED…").
1717
+ if (!rows.length) {
1718
+ var fullText = cleanText(container.textContent || '');
1719
+ var re = /(\d+(?:[.,]\d+)?)\s*(displayed|showing|shown|visible|total|results?|assets?|hits?|matches?|found|count|milliseconds?|seconds?|elapsed|duration|time|pages?)\b/gi;
1720
+ var m;
1721
+ while ((m = re.exec(fullText)) !== null) {
1722
+ var rv = parseStatNumber(m[1]);
1723
+ if (rv === null) continue;
1724
+ pushRow(m[2], m[1], rv);
1725
+ }
1726
+ }
1727
+
1728
+ if (!rows.length || !rows.some(function (r) { return r.value !== null; })) {
1729
+ return { container: container, stats: null };
1730
+ }
1731
+
1732
+ var stats = {
1733
+ time_ms: null,
1734
+ total: null,
1735
+ pages: null,
1736
+ displayed: null,
1737
+ raw: rows.map(function (r) { return { idx: r.idx, label: r.label, value: r.valueText }; })
1738
+ };
1739
+
1740
+ // Label-driven classification. We deliberately DO NOT carry the
1741
+ // positional fallback over from the ASC reader — React layouts
1742
+ // have no stable left-to-right ordering convention and a silent
1743
+ // no-op is safer than a confident mis-attribution.
1744
+ for (var k = 0; k < rows.length; k++) {
1745
+ var r = rows[k];
1746
+ if (r.value === null) continue;
1747
+ var c = classifyStatLabel(r.label);
1748
+ if (!c) continue;
1749
+ if (c.key === 'time_ms' && stats.time_ms === null) {
1750
+ stats.time_ms = c.unit === 's' ? r.value * 1000 : r.value;
1751
+ } else if (c.key === 'total' && stats.total === null) {
1752
+ stats.total = r.value;
1753
+ } else if (c.key === 'pages' && stats.pages === null) {
1754
+ stats.pages = r.value;
1755
+ } else if (c.key === 'displayed' && stats.displayed === null) {
1756
+ stats.displayed = r.value;
1757
+ }
1758
+ }
1759
+
1760
+ return { container: container, stats: stats };
1761
+ }
1762
+
1763
+ /**
1764
+ * Reads the currently-selected filters from the right-rail facet panel.
1765
+ * Returns an array of `{ name, values[] }` blocks keyed by section
1766
+ * heading text — same shape `filters_dom_json` carries on the ASC
1767
+ * search_executed event so dashboards can pivot on either context
1768
+ * uniformly. An empty array (no selections) is the normal default;
1769
+ * the caller emits `filters_dom_json=""` in that case.
1770
+ *
1771
+ * Detection strategies, applied in order until something is found:
1772
+ * 1. Native form controls (`input[type="checkbox"]:checked`,
1773
+ * `input[type="radio"]:checked`).
1774
+ * 2. ARIA: `[aria-checked="true"]`, `[aria-selected="true"]`,
1775
+ * `[aria-pressed="true"]`.
1776
+ * 3. data-state: `[data-state="checked" | "on" | "selected"]`,
1777
+ * `[data-selected="true"]`.
1778
+ * 4. Class-name heuristic — any element under the rail with a class
1779
+ * containing "_selected", "_checked", "_active", or "_on" that
1780
+ * ISN'T paired with an "un-" prefix on the same chip.
1781
+ *
1782
+ * Each match is grouped under its enclosing section heading; the
1783
+ * heading is resolved by walking up to the closest filter-section
1784
+ * container and reading its first heading-class child.
1785
+ */
1786
+ function readAssetExplorerFilters() {
1787
+ var rail = $(SELECTORS.assetExplorerFilterRail);
1788
+ if (!rail) return [];
1789
+
1790
+ // Live damgtr-sit.productioncloud.io filter rail captures show section
1791
+ // headings landing on classes the original `_filterSection_`-anchored
1792
+ // logic never saw. Climb up to 8 ancestor levels and, at each step,
1793
+ // look for either a previous-sibling or first-child element whose
1794
+ // class matches the heading patterns OR whose textContent is a
1795
+ // short ALL-CAPS label. The ALL-CAPS fallback handles builds
1796
+ // where the heading element has no recognisable class (just a plain
1797
+ // <div>FACET NAME</div>).
1798
+ //
1799
+ // We deliberately keep `[class*="_label_"]` / `[class*="_name_"]` /
1800
+ // `[class*="_header_"]` OUT of the heading selector — in this React
1801
+ // build the CHIP itself wraps its visible text in `_label_<hash>`,
1802
+ // and the chip's label appears in DOM order BEFORE its input, so
1803
+ // matching `_label_` would walk straight into the chip's own text
1804
+ // and return it as the section heading (v1.1.3 bug: "2026" / "Image"
1805
+ // ended up as section names). The ALL-CAPS visibility test below
1806
+ // is the more reliable signal for actual section names.
1807
+ var HEADING_SELECTOR =
1808
+ '[class*="_heading_"], [class*="_title_"], [class*="_sectionTitle_"], ' +
1809
+ '[class*="_categoryName_"], [class*="_facetTitle_"], [class*="_groupName_"], ' +
1810
+ '[class*="_groupTitle_"], [class*="_sectionHeader_"], [class*="_facetHeader_"], ' +
1811
+ '[class*="_categoryHeader_"], [class*="_filterHeader_"], [class*="_filterTitle_"], ' +
1812
+ 'h1, h2, h3, h4, h5, h6, legend, summary';
1813
+
1814
+ function looksLikeFacetHeading(txt) {
1815
+ if (!txt) return false;
1816
+ if (txt.length < 3 || txt.length > 80) return false;
1817
+ // Reject stat tiles ("4 DISPLAYED") and pure numbers ("2026").
1818
+ if (/^\d/.test(txt)) return false;
1819
+ // Must contain at least one A-Z letter and NO lowercase letters
1820
+ // — observed facets on this build are 100% uppercase with optional
1821
+ // punctuation: ASSET TYPE, MARKETING FUNCTION / INDUSTRY,
1822
+ // APPLICATION & USE, ORIGIN, ASSET USAGE RIGHTS,
1823
+ // LANGUAGE / REGION, YEAR.
1824
+ if (!/[A-Z]/.test(txt)) return false;
1825
+ if (/[a-z]/.test(txt)) return false;
1826
+ // Allowed character set — uppercase letters, digits (rare),
1827
+ // whitespace, and a handful of punctuation marks seen in facet
1828
+ // names. Anything else is probably a chip with weird casing.
1829
+ if (!/^[A-Z0-9 &/().,\-]+$/.test(txt)) return false;
1830
+ // Reject pure punctuation / pure whitespace.
1831
+ if (!/[A-Z]{2,}/.test(txt) && txt.length < 4) return false;
1832
+ return true;
1833
+ }
1834
+
1835
+ function sectionHeadingFor(el) {
1836
+ var ancestor = el;
1837
+ for (var depth = 0; depth < 8 && ancestor && ancestor !== rail; depth++) {
1838
+ // a) Heading-class previous sibling at this depth
1839
+ var prev = ancestor.previousElementSibling;
1840
+ while (prev) {
1841
+ if (prev.matches && prev.matches(HEADING_SELECTOR)) {
1842
+ var t1 = cleanText(prev.textContent || '');
1843
+ if (t1) return t1.slice(0, 80);
1844
+ }
1845
+ // ALL-CAPS plain element with short text counts too
1846
+ var pt = cleanText(prev.textContent || '');
1847
+ if (looksLikeFacetHeading(pt)) return pt.slice(0, 80);
1848
+ prev = prev.previousElementSibling;
1849
+ }
1850
+ // b) Heading-class first child of THIS ancestor (before the chip)
1851
+ var children = ancestor.children;
1852
+ for (var i = 0; i < children.length; i++) {
1853
+ var c = children[i];
1854
+ if (c === el) break;
1855
+ if (c.contains && c.contains(el)) continue; // skip the chip's branch
1856
+ if (c.matches && c.matches(HEADING_SELECTOR)) {
1857
+ var t2 = cleanText(c.textContent || '');
1858
+ if (t2) return t2.slice(0, 80);
1859
+ }
1860
+ // ALL-CAPS plain element
1861
+ var ct = cleanText(c.textContent || '');
1862
+ if (looksLikeFacetHeading(ct)) return ct.slice(0, 80);
1863
+ }
1864
+ ancestor = ancestor.parentElement;
1865
+ }
1866
+ return '';
1867
+ }
1868
+
1869
+ function chipLabel(el) {
1870
+ var inp;
1871
+ if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'OPTION') {
1872
+ inp = el;
1873
+ }
1874
+ // Prefer an associated <label>, then aria-label, then visible text.
1875
+ if (inp && inp.id) {
1876
+ var lab = document.querySelector('label[for="' + CSS.escape(inp.id) + '"]');
1877
+ if (lab) return cleanText(lab.textContent || '');
1878
+ }
1879
+ var aria = el.getAttribute && el.getAttribute('aria-label');
1880
+ if (aria) return aria;
1881
+ // Climb to the nearest row/chip wrapper for the visible text — the
1882
+ // raw element itself might be an inner input/svg with no text.
1883
+ var wrapper = el.closest('label, li, [role="option"], [role="treeitem"], [role="menuitem"], [class*="_option_"], [class*="_chip_"], [class*="_item_"], [class*="_row_"]') || el;
1884
+ return cleanText(wrapper.textContent || '').slice(0, 120);
1885
+ }
1886
+
1887
+ var bySection = {};
1888
+ function record(el) {
1889
+ var section = sectionHeadingFor(el) || 'unknown';
1890
+ var label = chipLabel(el);
1891
+ if (!label) return;
1892
+ if (!bySection[section]) bySection[section] = [];
1893
+ // Dedup within the same section (a checkbox + its <label> wrapper
1894
+ // can both match the class-name heuristic).
1895
+ if (bySection[section].indexOf(label) < 0) bySection[section].push(label);
1896
+ }
1897
+
1898
+ // 1. native form controls
1899
+ var natives = rail.querySelectorAll('input[type="checkbox"]:checked, input[type="radio"]:checked');
1900
+ for (var n = 0; n < natives.length; n++) record(natives[n]);
1901
+
1902
+ // 2. ARIA / data-state
1903
+ var ariaSel = rail.querySelectorAll(
1904
+ '[aria-checked="true"], [aria-selected="true"], [aria-pressed="true"], ' +
1905
+ '[data-state="checked"], [data-state="on"], [data-state="selected"], ' +
1906
+ '[data-selected="true"]'
1907
+ );
1908
+ for (var a = 0; a < ariaSel.length; a++) record(ariaSel[a]);
1909
+
1910
+ // 3. class-name heuristic — narrow to elements whose className is
1911
+ // SHORT (likely a chip) and contains a positive state marker.
1912
+ var clsCandidates = rail.querySelectorAll(
1913
+ '[class*="_selected_"], [class*="_checked_"], [class*="_active_"], [class*="_on_"], [class*="_isSelected"]'
1914
+ );
1915
+ for (var c = 0; c < clsCandidates.length; c++) {
1916
+ var el = clsCandidates[c];
1917
+ var cls = (el.className && el.className.baseVal !== undefined) ? el.className.baseVal : String(el.className || '');
1918
+ if (cls.length > 240) continue; // skip container-level matches
1919
+ if (/_unselected|_unchecked|_inactive|_off/i.test(cls)) continue;
1920
+ // Skip elements that are descendants of an already-matched native
1921
+ // input — record() already handled them.
1922
+ if (el.querySelector('input[type="checkbox"]:checked, input[type="radio"]:checked')) continue;
1923
+ record(el);
1924
+ }
1925
+
1926
+ var out = [];
1927
+ for (var s in bySection) {
1928
+ if (bySection.hasOwnProperty(s)) {
1929
+ out.push({ name: s, values: bySection[s] });
1930
+ }
1931
+ }
1932
+ return out;
1933
+ }
1934
+
1935
+ // Cross-call state for trackAssetExplorerSearch (mirrors the ASC pair).
1936
+ var __aeCancelInflightStatsWait = null;
1937
+ var __aeFlushInflightSearchNow = null;
1938
+ // Dedup: avoid re-firing the same {term, semantic, fuzzy, folder} combo
1939
+ // when several triggers (Enter + form submit + click) overlap on a
1940
+ // single user action. Compared by string equality with the LAST fired
1941
+ // signature; cleared on hashchange.
1942
+ var __aeLastFiredSig = '';
1943
+
1944
+ /**
1945
+ * Emits `search_executed` (subtype `asset_search`) for the React Asset
1946
+ * Explorer page. Captures search term + semantic/fuzzy toggles + the
1947
+ * current folder + the stats widget once it settles. The `trigger`
1948
+ * argument records HOW the search was initiated (enter, submit, click,
1949
+ * options_close, filter_click, input_debounced) so we can prune the
1950
+ * hooks later once we see which paths actually fire in production.
1951
+ */
1952
+ function trackAssetExplorerSearch(searchTerm, opts) {
1953
+ opts = opts || {};
1954
+ searchTerm = safeStr(searchTerm).trim();
1955
+ if (!searchTerm) return;
1956
+
1957
+ var semantic = (typeof opts.semantic === 'string')
1958
+ ? opts.semantic
1959
+ : readAssetExplorerToggleState(TEXT_PATTERNS.semantic);
1960
+ var fuzzy = (typeof opts.fuzzy === 'string')
1961
+ ? opts.fuzzy
1962
+ : readAssetExplorerToggleState(TEXT_PATTERNS.fuzzy);
1963
+ var folder = readAssetExplorerFolderPath();
1964
+ var trigger = safeStr(opts.trigger);
1965
+ var filters = readAssetExplorerFilters();
1966
+ var filtersJson = filters.length ? JSON.stringify(filters) : '';
1967
+
1968
+ // Dedup signature includes the filter selection — flipping a single
1969
+ // facet on/off should re-fire even if the search term is unchanged,
1970
+ // since the result set itself changes.
1971
+ var sig = [searchTerm, semantic, fuzzy, folder, filtersJson].join('|');
1972
+ if (sig === __aeLastFiredSig && !opts.forceDedupBypass) return;
1973
+ __aeLastFiredSig = sig;
1974
+
1975
+ if (__aeFlushInflightSearchNow) {
1976
+ try { __aeFlushInflightSearchNow(); } catch (e) {}
1977
+ __aeFlushInflightSearchNow = null;
1978
+ }
1979
+ if (__aeCancelInflightStatsWait) {
1980
+ try { __aeCancelInflightStatsWait(); } catch (e) {}
1981
+ __aeCancelInflightStatsWait = null;
1982
+ }
1983
+
1984
+ var dims = {
1985
+ search_term: searchTerm,
1986
+ semantic_search: semantic,
1987
+ fuzzy_search: fuzzy,
1988
+ search_context: 'asset_explorer',
1989
+ search_trigger: trigger,
1990
+ folder_path: folder,
1991
+ filters_dom_json: filtersJson,
1992
+ page_url: location.href,
1993
+ page_referrer: document.referrer || ''
1994
+ };
1995
+ var metrics = {
1996
+ filter_dom_count: filters.reduce(function (acc, g) { return acc + (g.values ? g.values.length : 0); }, 0)
1997
+ };
1998
+
1999
+ var fired = false;
2000
+ function fireWithStats(stats) {
2001
+ if (fired) return;
2002
+ fired = true;
2003
+ __aeCancelInflightStatsWait = null;
2004
+ __aeFlushInflightSearchNow = null;
2005
+
2006
+ if (stats) {
2007
+ if (stats.time_ms !== null) metrics.search_time_ms = stats.time_ms;
2008
+ if (stats.total !== null) metrics.result_total = stats.total;
2009
+ if (stats.pages !== null) metrics.result_pages = stats.pages;
2010
+ if (stats.displayed !== null) metrics.result_displayed = stats.displayed;
2011
+ dims.search_stats_ready = 'true';
2012
+ try {
2013
+ if (stats.raw && stats.raw.length) {
2014
+ var rawJson = JSON.stringify(stats.raw);
2015
+ dims.search_stats_raw_json = rawJson.length > 240 ? rawJson.slice(0, 240) : rawJson;
2016
+ }
2017
+ } catch (e) { /* swallow */ }
2018
+ } else {
2019
+ dims.search_stats_ready = 'false';
2020
+ }
2021
+
2022
+ track('search_executed', dims, metrics, 'asset_search');
2023
+ __searchFireCount++;
2024
+ }
2025
+
2026
+ __aeFlushInflightSearchNow = function () { fireWithStats(null); };
2027
+ __aeCancelInflightStatsWait = waitForSearchStats(readAssetExplorerSearchStatistics, 3000, fireWithStats);
2028
+ }
2029
+
2030
+ /**
2031
+ * Emits a `folder_navigation` event with subtype `asset_browse`. The
2032
+ * `tree_action` dim distinguishes how the navigation was triggered so
2033
+ * a single event type can describe every kind of folder interaction:
2034
+ *
2035
+ * - "hash_change" — the URL hash route changed (true navigation)
2036
+ * - "click" — user clicked a folder label in the side tree
2037
+ * - "expand" — user expanded a tree node
2038
+ * - "collapse" — user collapsed a tree node
2039
+ *
2040
+ * The tree-action variants also carry `tree_target_label` (the visible
2041
+ * text of the clicked node) and `tree_target_class_hint` (the first
2042
+ * 240 chars of the target's className) so we can refine the action
2043
+ * detection later without re-instrumenting the page.
2044
+ */
2045
+ function trackAssetExplorerFolderNavigation(opts) {
2046
+ opts = opts || {};
2047
+ var folder = readAssetExplorerFolderPath();
2048
+ var action = safeStr(opts.tree_action) || 'hash_change';
2049
+ // Skip the initial page_load / hash_change variants when the React
2050
+ // app hasn't set its default route yet — the next real hashchange
2051
+ // will fire a clean event. We still fire on click/expand/collapse
2052
+ // even with an empty folder so a no-op gesture is observable.
2053
+ if (!folder && (action === 'hash_change' || action === 'page_load')) return;
2054
+ var dims = {
2055
+ folder_path: folder,
2056
+ search_context: 'asset_explorer',
2057
+ tree_action: action,
2058
+ page_url: location.href,
2059
+ page_referrer: document.referrer || ''
2060
+ };
2061
+ if (opts.tree_target_label) {
2062
+ dims.tree_target_label = safeStr(opts.tree_target_label).slice(0, 120);
2063
+ }
2064
+ if (opts.tree_target_class_hint) {
2065
+ dims.tree_target_class_hint = safeStr(opts.tree_target_class_hint).slice(0, 240);
2066
+ }
2067
+ track('folder_navigation', dims, {}, 'asset_browse');
2068
+ }
2069
+
2070
+ // ─── 9. DOM-driven hooks (clicks) ────────────────────────
2071
+
2072
+ // ── Login page hooks (preserved from earlier login-only bootstrap) ──
2073
+
2074
+ function hookSignInSubmit() {
2075
+ var form = $(SELECTORS.loginForm);
2076
+ var btn = $(SELECTORS.signInButton);
2077
+ if (!btn) {
2078
+ btn = closestMatchByText(document, 'button, a, [role="button"], input[type="submit"]', TEXT_PATTERNS.signIn);
2079
+ }
2080
+ function fire(reason) {
2081
+ var userInput = $(SELECTORS.userField);
2082
+ var typedUser = (userInput && (userInput.value || '').trim()) || '';
2083
+ if (typedUser) identify(typedUser);
2084
+ track('login_attempt', {
2085
+ method: 'credentials',
2086
+ attempt_user: typedUser,
2087
+ submit_reason: reason || 'click',
2088
+ page_url: location.href
2089
+ });
2090
+ flush();
2091
+ }
2092
+ if (btn) btn.addEventListener('click', function () { fire('click'); }, { capture: true });
2093
+ if (form) form.addEventListener('submit', function () { fire('submit'); }, { capture: true });
2094
+ }
2095
+
2096
+ function hookSsoClick() {
2097
+ var btn = $(SELECTORS.ssoButton);
2098
+ if (!btn) {
2099
+ btn = closestMatchByText(document, 'button, a, [role="button"]', TEXT_PATTERNS.lionSso);
2100
+ }
2101
+ if (!btn) return;
2102
+ btn.addEventListener('click', function () {
2103
+ track('sso_click', {
2104
+ method: 'lion_login',
2105
+ sso_provider: 'lion',
2106
+ button_text: visibleText(btn).substring(0, 120),
2107
+ page_url: location.href
2108
+ });
2109
+ flush();
2110
+ }, { capture: true });
2111
+ }
2112
+
2113
+ function hookTermsClick() {
2114
+ var link = $(SELECTORS.termsLink);
2115
+ if (!link) {
2116
+ link = closestMatchByText(document, 'a, button, [role="link"]', TEXT_PATTERNS.terms);
2117
+ }
2118
+ if (!link) return;
2119
+ link.addEventListener('click', function () {
2120
+ track('terms_click', {
2121
+ link_url: (link.getAttribute && link.getAttribute('href')) || '',
2122
+ link_text: visibleText(link).substring(0, 120),
2123
+ page_url: location.href
2124
+ });
2125
+ flush();
2126
+ }, { capture: true });
2127
+ }
2128
+
2129
+ // ── ASC click delegation (one body-level listener, survives SPA mutations) ──
2130
+
2131
+ /**
2132
+ * Body-level click listener that matches Add-to-Cart and Share buttons
2133
+ * across both the search-results grid (tile + table) and the asset detail
2134
+ * page. Uses `closest()` so it works whether the user clicks the icon,
2135
+ * the label, or any descendant of the button.
2136
+ */
2137
+ function installAscClickDelegation() {
2138
+ if (window.__ilPmigtrClickDelegated) return;
2139
+ window.__ilPmigtrClickDelegated = true;
2140
+
2141
+ function tryClosest(target, selector) {
2142
+ try { return target.closest(selector); } catch (e) { return null; }
2143
+ }
2144
+
2145
+ document.addEventListener('click', function (e) {
2146
+ var target = e.target;
2147
+ if (!target || target.nodeType !== 1 || !target.closest) return;
2148
+
2149
+ // The dispatch ordering matters — match the MOST SPECIFIC
2150
+ // data-asset-share-id first so cart-level controls don't fall
2151
+ // through to asset-level handlers. Each branch returns to
2152
+ // prevent a single user click from emitting multiple events.
2153
+
2154
+ // ── 1. Cart-level "Download Cart" (data-asset-share-id="download-all") ──
2155
+ // The user-confirmed intent to start downloading the entire
2156
+ // cart. A second confirmation modal appears AFTER this click;
2157
+ // the actual binaries are produced asynchronously and surface
2158
+ // in the downloads modal as individual downloadbinaries.json
2159
+ // anchors (covered by branch 3 below).
2160
+ var dlAllBtn = tryClosest(target, SELECTORS.cartDownloadButton);
2161
+ if (dlAllBtn) {
2162
+ var dlContents = readCartContents();
2163
+ var dlContentsJson = dlContents.length ? JSON.stringify(dlContents) : '';
2164
+ if (dlContentsJson.length > 480) dlContentsJson = dlContentsJson.slice(0, 480);
2165
+ track('cart_download', {
2166
+ cart_asset_paths_json: dlContentsJson,
2167
+ page_url: location.href,
2168
+ page_referrer: document.referrer || ''
2169
+ }, {
2170
+ cart_item_count: dlContents.length
2171
+ }, 'cart_action');
2172
+ return;
2173
+ }
2174
+
2175
+ // ── 2. Cart-level "Share Cart" (data-asset-share-id="share-all") ──
2176
+ // Clicking this OPENS a second modal where the user enters
2177
+ // recipient emails + public flag + optional date range. The
2178
+ // real share doesn't happen until that form is submitted,
2179
+ // which we capture in `installShareFormHook()`. To survive the
2180
+ // cart-modal closing before the share form is filled in, we
2181
+ // stash the cart snapshot module-globally so the submit
2182
+ // handler can attach it to the cart_share event.
2183
+ var shareAllBtn = tryClosest(target, SELECTORS.cartShareButton);
2184
+ if (shareAllBtn) {
2185
+ var shContents = readCartContents();
2186
+ var shContentsJson = shContents.length ? JSON.stringify(shContents) : '';
2187
+ if (shContentsJson.length > 480) shContentsJson = shContentsJson.slice(0, 480);
2188
+ // Stash for the submit handler.
2189
+ window.__ilPmigtrPendingCartShare = {
2190
+ paths: shContents,
2191
+ paths_json: shContentsJson,
2192
+ captured_at: Date.now()
2193
+ };
2194
+ // "Intent to share" event — useful for the funnel
2195
+ // (clicked → completed) and fires regardless of whether
2196
+ // the user actually submits the form.
2197
+ track('cart_share_initiated', {
2198
+ cart_asset_paths_json: shContentsJson,
2199
+ page_url: location.href,
2200
+ page_referrer: document.referrer || ''
2201
+ }, {
2202
+ cart_item_count: shContents.length
2203
+ }, 'cart_action');
2204
+ return;
2205
+ }
2206
+
2207
+ // ── 3. Per-artifact download link (downloadbinaries.json) ──
2208
+ // Fired when a user clicks any individual download anchor in
2209
+ // the downloads modal. This is the REAL "asset_download" event
2210
+ // — the binary leaves the server on this click, unlike the
2211
+ // cart-download button above which is just the user's intent
2212
+ // to start the download pipeline.
2213
+ var dlLink = tryClosest(target, SELECTORS.downloadBinaryLink);
2214
+ if (dlLink) {
2215
+ var href = dlLink.getAttribute('href') || '';
2216
+ var params = parseUrlParams(href);
2217
+ var endpoint = href.split('?')[0];
2218
+ track('asset_download', {
2219
+ download_id: params.downloadId || '',
2220
+ artifact_id: params.artifactId || '',
2221
+ download_endpoint: endpoint,
2222
+ download_source: 'downloads_modal',
2223
+ download_method: 'binary_link_click',
2224
+ page_url: location.href,
2225
+ page_referrer: document.referrer || ''
2226
+ });
2227
+ return;
2228
+ }
2229
+
2230
+ // ── 4. Per-artifact "remove from downloads" button ──
2231
+ var dlRemoveBtn = tryClosest(target, SELECTORS.downloadRemoveButton);
2232
+ if (dlRemoveBtn) {
2233
+ track('download_removed', {
2234
+ download_id: dlRemoveBtn.getAttribute('data-asset-share-download-id') || '',
2235
+ page_url: location.href
2236
+ }, {}, 'cart_action');
2237
+ return;
2238
+ }
2239
+
2240
+ // ── 5. Asset-level "Add to Cart" tile button ──
2241
+ // Now that the cart-level controls are handled above, anything
2242
+ // remaining with `data-asset-share-id="add-to-cart"` is the
2243
+ // per-asset variant. We DO NOT rely on the button's visible
2244
+ // text — the icon-only variant has none — and pull the asset
2245
+ // path straight off `data-asset-share-asset` via the extractor.
2246
+ var cartBtn = tryClosest(target, SELECTORS.addToCart);
2247
+ if (!cartBtn) {
2248
+ // Visible-text fallback for installations that haven't been
2249
+ // migrated to the data-asset-share-id convention yet.
2250
+ var cartCandidate = tryClosest(target, 'button, a, [role="button"], [role="menuitem"]');
2251
+ if (cartCandidate && TEXT_PATTERNS.cart.test(visibleText(cartCandidate))) {
2252
+ cartBtn = cartCandidate;
2253
+ }
2254
+ }
2255
+ if (cartBtn) {
2256
+ track('asset_add_to_cart', {
2257
+ asset_path: extractAssetPathFromEl(cartBtn),
2258
+ page_url: location.href
2259
+ });
2260
+ return;
2261
+ }
2262
+
2263
+ // ── 6. Header cart-icon click (opens cart modal) ──
2264
+ var cartOpen = tryClosest(target, SELECTORS.cartOpenButton);
2265
+ if (cartOpen) {
2266
+ track('cart_open', { page_url: location.href }, {}, 'cart_action');
2267
+ return;
2268
+ }
2269
+
2270
+ // ── 7. Header download-icon click (opens downloads modal) ──
2271
+ var dlOpen = tryClosest(target, SELECTORS.downloadsOpenButton);
2272
+ if (dlOpen) {
2273
+ track('downloads_open', { page_url: location.href }, {}, 'cart_action');
2274
+ return;
2275
+ }
2276
+
2277
+ // ── 8. Asset-level Share button ──
2278
+ var shareBtn = tryClosest(target, SELECTORS.shareButton);
2279
+ if (!shareBtn) {
2280
+ var shareCandidate = tryClosest(target, 'button, a, [role="button"], [role="menuitem"]');
2281
+ // Visible-text fallback — explicitly reject anything that sits
2282
+ // under the cart-modal or matches the cart-share selector, so
2283
+ // a generic /share/i click on Share Cart can't fall through
2284
+ // to asset_share here.
2285
+ if (shareCandidate &&
2286
+ TEXT_PATTERNS.share.test(visibleText(shareCandidate)) &&
2287
+ !TEXT_PATTERNS.cart.test(visibleText(shareCandidate)) &&
2288
+ !tryClosest(shareCandidate, SELECTORS.cartShareButton) &&
2289
+ !tryClosest(shareCandidate, SELECTORS.cartModal)) {
2290
+ shareBtn = shareCandidate;
2291
+ }
2292
+ }
2293
+ if (shareBtn) {
2294
+ track('asset_share', {
2295
+ asset_path: extractAssetPathFromEl(shareBtn),
2296
+ page_url: location.href
2297
+ });
2298
+ }
2299
+ }, { capture: true });
2300
+ }
2301
+
2302
+ /**
2303
+ * Parses the recipient e-mail field. The input is `<input
2304
+ * type="email" multiple name="email">` and the user typically types
2305
+ * `a@b.com, c@d.com, e@f.com` (the placeholder advertises a
2306
+ * comma-delimited list). We accept commas, semicolons, and
2307
+ * whitespace as separators, trim and de-dupe (case-insensitive),
2308
+ * and drop anything that doesn't look vaguely like an e-mail. The
2309
+ * returned array preserves the user's typed order for the first
2310
+ * occurrence of each address.
2311
+ */
2312
+ function parseRecipientEmails(raw) {
2313
+ if (!raw) return [];
2314
+ var parts = String(raw).split(/[,;\s]+/);
2315
+ var out = [];
2316
+ var seen = {};
2317
+ for (var i = 0; i < parts.length; i++) {
2318
+ var t = (parts[i] || '').trim();
2319
+ if (!t) continue;
2320
+ // Loose validation — we only want to drop obvious garbage
2321
+ // like trailing punctuation; the server will do real
2322
+ // validation downstream. We DO require an `@` plus a `.`
2323
+ // somewhere after it.
2324
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) continue;
2325
+ var key = t.toLowerCase();
2326
+ if (seen[key]) continue;
2327
+ seen[key] = true;
2328
+ out.push(t);
2329
+ }
2330
+ return out;
2331
+ }
2332
+
2333
+ /**
2334
+ * Captures the share-modal form submission. The cart-level "Share
2335
+ * Cart" button (handled in installAscClickDelegation, branch 2)
2336
+ * fires a `cart_share_initiated` intent event AND stashes the cart
2337
+ * contents in `window.__ilPmigtrPendingCartShare` because the cart
2338
+ * modal usually closes before the share form is filled in. When
2339
+ * the user actually submits the share form, this hook fires the
2340
+ * REAL `cart_share` event with:
2341
+ *
2342
+ * - share_emails – comma-joined list (480-char cap)
2343
+ * - share_emails_count – metric, distinct after dedupe
2344
+ * - share_is_public – "true" / "false"
2345
+ * - share_start_date – yyyy-mm-dd (public-only)
2346
+ * - share_end_date – yyyy-mm-dd (public-only)
2347
+ * - share_date_range_days – metric, end-start in days (>=0)
2348
+ * - cart_asset_paths_json – snapshot from button-click stash,
2349
+ * falls back to a fresh
2350
+ * readCartContents() if the stash
2351
+ * expired (e.g. the user reloaded
2352
+ * between clicking and submitting)
2353
+ *
2354
+ * Capture phase + non-passive so we get the submit BEFORE ASC's
2355
+ * own jQuery validator can call preventDefault on an invalid form;
2356
+ * we still fire the event so the funnel sees the attempt. The
2357
+ * stash is cleared after a successful read so a subsequent
2358
+ * unrelated form submission can't accidentally inherit it.
2359
+ */
2360
+ function installShareFormHook() {
2361
+ if (window.__ilPmigtrShareFormHooked) return;
2362
+ window.__ilPmigtrShareFormHooked = true;
2363
+
2364
+ document.addEventListener('submit', function (e) {
2365
+ var form = e.target;
2366
+ if (!form || form.nodeType !== 1) return;
2367
+ var matchSelf = false;
2368
+ try { matchSelf = form.matches && form.matches(SELECTORS.shareModal); } catch (err) { /* ignore */ }
2369
+ // The event target IS the form, but be defensive in case the
2370
+ // template ever wraps the submission in a parent element.
2371
+ if (!matchSelf) {
2372
+ var ancestor = null;
2373
+ try { ancestor = form.closest && form.closest(SELECTORS.shareModal); } catch (err) { /* ignore */ }
2374
+ if (!ancestor) return;
2375
+ form = ancestor;
2376
+ }
2377
+
2378
+ var emailInput = form.querySelector(SELECTORS.shareEmailInput);
2379
+ var emails = parseRecipientEmails(emailInput && emailInput.value);
2380
+ var emailsJoined = emails.join(',');
2381
+ if (emailsJoined.length > 480) emailsJoined = emailsJoined.slice(0, 480);
2382
+
2383
+ // "Share Publicly" — the explicit checkbox match comes first.
2384
+ // If multiple checkboxes exist in the form (some themes add a
2385
+ // T&C tickbox), we look for the one paired with the "Share
2386
+ // Publicly" label by walking up to the .ui.checkbox wrapper.
2387
+ var publicChecked = false;
2388
+ var cbCandidates = form.querySelectorAll(SELECTORS.sharePublicCheckbox);
2389
+ for (var i = 0; i < cbCandidates.length; i++) {
2390
+ var cb = cbCandidates[i];
2391
+ var wrap = cb.closest('.ui.checkbox, .field');
2392
+ var lbl = wrap && (wrap.querySelector('label') || wrap);
2393
+ var txt = lbl ? (lbl.textContent || '').trim() : '';
2394
+ if (cbCandidates.length === 1 || /share\s*publicly/i.test(txt)) {
2395
+ publicChecked = !!cb.checked;
2396
+ break;
2397
+ }
2398
+ }
2399
+
2400
+ var startEl = form.querySelector(SELECTORS.shareStartDateInput);
2401
+ var expiryEl = form.querySelector(SELECTORS.shareExpiryDateInput);
2402
+ var startDate = (startEl && startEl.value) || '';
2403
+ var expiryDate = (expiryEl && expiryEl.value) || '';
2404
+ // Heuristic — if the dates are populated but the checkbox
2405
+ // matcher missed (e.g. the theme rebrands the label), treat
2406
+ // the share as public anyway. Better to over-tag than miss
2407
+ // the public-share population entirely.
2408
+ if (!publicChecked && (startDate || expiryDate)) publicChecked = true;
2409
+
2410
+ var rangeDays = 0;
2411
+ if (startDate && expiryDate) {
2412
+ var s = Date.parse(startDate);
2413
+ var e2 = Date.parse(expiryDate);
2414
+ if (!isNaN(s) && !isNaN(e2)) rangeDays = Math.max(0, Math.round((e2 - s) / 86400000));
2415
+ }
2416
+
2417
+ var stash = window.__ilPmigtrPendingCartShare;
2418
+ var stashFresh = stash && (Date.now() - stash.captured_at < 10 * 60 * 1000);
2419
+ var pathsJson, pathsCount;
2420
+ if (stashFresh) {
2421
+ pathsJson = stash.paths_json || '';
2422
+ pathsCount = (stash.paths && stash.paths.length) || 0;
2423
+ } else {
2424
+ var fresh = readCartContents();
2425
+ pathsJson = fresh.length ? JSON.stringify(fresh) : '';
2426
+ if (pathsJson.length > 480) pathsJson = pathsJson.slice(0, 480);
2427
+ pathsCount = fresh.length;
2428
+ }
2429
+ window.__ilPmigtrPendingCartShare = null;
2430
+
2431
+ track('cart_share', {
2432
+ share_emails: emailsJoined,
2433
+ share_is_public: publicChecked ? 'true' : 'false',
2434
+ share_start_date: publicChecked ? startDate : '',
2435
+ share_end_date: publicChecked ? expiryDate : '',
2436
+ cart_asset_paths_json: pathsJson,
2437
+ page_url: location.href,
2438
+ page_referrer: document.referrer || ''
2439
+ }, {
2440
+ share_emails_count: emails.length,
2441
+ share_date_range_days: rangeDays,
2442
+ cart_item_count: pathsCount
2443
+ }, 'cart_action');
2444
+ }, { capture: true });
2445
+ }
2446
+
2447
+ // ─── 9b. Asset Explorer (React) hooks ────────────────────
2448
+
2449
+ /**
2450
+ * Wires every plausible search trigger on /asset-explorer.html. The
2451
+ * page is a React SPA that doesn't expose a single canonical "search
2452
+ * submit" event, so we listen at the document level (capture phase)
2453
+ * for the four observed user intents:
2454
+ *
2455
+ * - Enter pressed inside the search input
2456
+ * - Native form submit (Enter or a button reachable via submit())
2457
+ * - The Search Options modal closing (treated as "user accepted
2458
+ * semantic/fuzzy choice and the search will now run")
2459
+ * - A click on any element inside the right-rail filter wrapper
2460
+ * (treated as "user changed a facet and a new search will run")
2461
+ *
2462
+ * Plus a debounced `input` listener so live-typing scenarios — if any —
2463
+ * still record the eventual term. Every fire carries a `search_trigger`
2464
+ * dimension so we can drop the redundant hooks once we see what the
2465
+ * page actually emits in ClickHouse.
2466
+ *
2467
+ * Dedup is centralized inside trackAssetExplorerSearch() via
2468
+ * __aeLastFiredSig so overlapping triggers (Enter + form submit) on the
2469
+ * same user action collapse into a single event.
2470
+ */
2471
+ function installAssetExplorerHooks() {
2472
+ if (window.__ilPmigtrAEHooked) return;
2473
+ window.__ilPmigtrAEHooked = true;
2474
+
2475
+ function currentTerm() {
2476
+ var inp = findAssetExplorerSearchInput();
2477
+ return inp ? (inp.value || '').trim() : '';
2478
+ }
2479
+
2480
+ function fireWithTrigger(trigger) {
2481
+ var term = currentTerm();
2482
+ if (!term) return;
2483
+ trackAssetExplorerSearch(term, { trigger: trigger });
2484
+ }
2485
+
2486
+ // ── 1. Enter inside the search input ─────────────────────
2487
+ document.addEventListener('keydown', function (e) {
2488
+ if (e.key !== 'Enter' && e.keyCode !== 13) return;
2489
+ var t = e.target;
2490
+ if (!t || t.nodeType !== 1) return;
2491
+ // Match either the explorer's React input OR any text input that
2492
+ // sits under the explorer root (themed deployments may swap the
2493
+ // CSS-Module class).
2494
+ var inExplorer = t.closest && t.closest(SELECTORS.assetExplorerRoot);
2495
+ if (!inExplorer) return;
2496
+ if (t.tagName !== 'INPUT' && t.tagName !== 'TEXTAREA') return;
2497
+ setTimeout(function () { fireWithTrigger('enter'); }, 0);
2498
+ }, { capture: true });
2499
+
2500
+ // ── 2. Native form submit ────────────────────────────────
2501
+ document.addEventListener('submit', function (e) {
2502
+ var f = e.target;
2503
+ if (!f || !f.closest) return;
2504
+ if (!f.closest(SELECTORS.assetExplorerRoot)) return;
2505
+ setTimeout(function () { fireWithTrigger('submit'); }, 0);
2506
+ }, { capture: true });
2507
+
2508
+ // ── 3. Search Options modal close ───────────────────────
2509
+ // We can't always observe the modal-close gesture directly (escape
2510
+ // key, clicking the backdrop, clicking a confirm button). Instead,
2511
+ // watch for the modal element being REMOVED from the DOM and treat
2512
+ // that as "user finished tweaking semantic/fuzzy".
2513
+ if (typeof MutationObserver === 'function') {
2514
+ var modalState = { open: false };
2515
+ try {
2516
+ var mo = new MutationObserver(function () {
2517
+ var modal = document.querySelector(
2518
+ '[class*="_searchOptionModal_"], [class*="_searchOption_Modal_"], [class*="_searchOptionsModal_"]'
2519
+ );
2520
+ var nowOpen = !!modal;
2521
+ if (modalState.open && !nowOpen) {
2522
+ setTimeout(function () { fireWithTrigger('options_close'); }, 0);
2523
+ }
2524
+ modalState.open = nowOpen;
2525
+ });
2526
+ mo.observe(document.body || document.documentElement, {
2527
+ childList: true,
2528
+ subtree: true
2529
+ });
2530
+ } catch (e) { /* fall through */ }
2531
+ }
2532
+
2533
+ // ── 4. Filter-rail clicks ───────────────────────────────
2534
+ document.addEventListener('click', function (e) {
2535
+ var t = e.target;
2536
+ if (!t || !t.closest) return;
2537
+ var rail = t.closest(SELECTORS.assetExplorerFilterRail);
2538
+ if (!rail) return;
2539
+ // Defer past React's re-render so the term + stats reflect the
2540
+ // new filter state when we read them.
2541
+ setTimeout(function () {
2542
+ // We bypass the term-empty check via forceDedupBypass=false so
2543
+ // a filter-only narrow over an empty search isn't tracked here;
2544
+ // it falls into folder_navigation territory.
2545
+ fireWithTrigger('filter_click');
2546
+ }, 30);
2547
+ }, { capture: true });
2548
+
2549
+ // ── 5. Debounced typing (catch-all for auto-search builds) ──
2550
+ var debounceTimer = null;
2551
+ document.addEventListener('input', function (e) {
2552
+ var t = e.target;
2553
+ if (!t || t.nodeType !== 1) return;
2554
+ if (t.tagName !== 'INPUT') return;
2555
+ if (!t.closest || !t.closest(SELECTORS.assetExplorerRoot)) return;
2556
+ var inp = findAssetExplorerSearchInput();
2557
+ if (!inp || t !== inp) return;
2558
+ if (debounceTimer) clearTimeout(debounceTimer);
2559
+ debounceTimer = setTimeout(function () {
2560
+ fireWithTrigger('input_debounced');
2561
+ }, 800);
2562
+ }, { capture: true });
2563
+
2564
+ // ── 6. Hash-route folder navigation ─────────────────────
2565
+ window.addEventListener('hashchange', function () {
2566
+ // Reset the search-event dedup signature so the new folder's first
2567
+ // search records cleanly even if its (term, toggles) happen to
2568
+ // match the previous folder's.
2569
+ __aeLastFiredSig = '';
2570
+ trackAssetExplorerFolderNavigation({ tree_action: 'hash_change' });
2571
+ });
2572
+
2573
+ // ── 7. Folder-tree click delegation ─────────────────────
2574
+ // Every click anywhere inside the side directory tree fires
2575
+ // folder_navigation(subtype=asset_browse) so we observe folder
2576
+ // selections (which usually also trigger hashchange) AND
2577
+ // expand/collapse gestures (which do not). We best-effort classify
2578
+ // the action via aria-expanded mutation, fall back to "click" if
2579
+ // we can't tell, and tag the event with diagnostic dims so the
2580
+ // refinement is data-driven from ClickHouse.
2581
+ document.addEventListener('click', function (e) {
2582
+ var target = e.target;
2583
+ if (!target || target.nodeType !== 1 || !target.closest) return;
2584
+ var tree = target.closest(SELECTORS.assetExplorerFolderTree);
2585
+ if (!tree) return;
2586
+ // Skip clicks that landed in the rail's facet panel — that's the
2587
+ // right side (search_executed/filter_click territory), not the
2588
+ // left tree. The selectors *can* overlap on extremely permissive
2589
+ // builds where both panels share `_wrapper_` ancestors.
2590
+ if (target.closest(SELECTORS.assetExplorerFilterRail)) return;
2591
+
2592
+ // Find the closest folder-row element so we can capture the
2593
+ // folder label (visible text) and the aria-expanded state.
2594
+ // Tree-row markup we expect (best-effort):
2595
+ // [role="treeitem"] | [class*="_treeNode_"] | [class*="_node_"]
2596
+ // | the immediate clickable wrapper (`<li>` / `<button>`).
2597
+ var node = target.closest(
2598
+ '[role="treeitem"], [class*="_treeNode_"], [class*="_node_"], [class*="_folder_"], [class*="_item_"], li, button, a'
2599
+ ) || target;
2600
+
2601
+ var labelEl = node.querySelector('[class*="_label_"], [class*="_name_"], [class*="_title_"]') || node;
2602
+ var label = cleanText(labelEl.textContent || '').slice(0, 120);
2603
+
2604
+ var preExpanded = node.getAttribute && node.getAttribute('aria-expanded');
2605
+
2606
+ // Sample the className for diagnostic use — strip whitespace and
2607
+ // cap aggressively to avoid blowing the custom_dimensions column.
2608
+ var cls = (target.className && target.className.baseVal !== undefined)
2609
+ ? target.className.baseVal
2610
+ : String(target.className || '');
2611
+
2612
+ // Snapshot the node's current className too — some React tree
2613
+ // libs put the expanded/collapsed state directly on the row
2614
+ // (`_isOpen`, `_expanded`, `_collapsed`, `_open`) rather than on
2615
+ // an aria-expanded attribute, so we'll need both pre- and post-
2616
+ // click className samples to classify the gesture.
2617
+ var preNodeCls = (node.className && node.className.baseVal !== undefined)
2618
+ ? node.className.baseVal
2619
+ : String(node.className || '');
2620
+
2621
+ // Live React Asset Explorer uses classes like `_toggleIcon_<hash>`
2622
+ // on the expand/collapse chevron. Anything matching one of these
2623
+ // unambiguous "this is a toggle gesture" prefixes is treated as
2624
+ // an expand/collapse even when aria-expanded never shows up.
2625
+ var TOGGLE_CLS_RE = /(?:^|[\s_-])(?:_?toggleIcon|_?toggle|_?chevron|_?arrow|_?caret|_?expand(?:er|Icon)?|_?collapse(?:r|Icon)?)(?:[\s_-]|$)/i;
2626
+ var isToggle = TOGGLE_CLS_RE.test(cls);
2627
+
2628
+ // Defer to the next tick so the React state has had time to apply
2629
+ // the click (aria-expanded flips, classNames toggled, hash routes
2630
+ // pushed, etc.) before we classify. The hashchange listener above
2631
+ // is independent and will fire its own event if the URL changes.
2632
+ setTimeout(function () {
2633
+ var postExpanded = node.getAttribute && node.getAttribute('aria-expanded');
2634
+ var postNodeCls = (node.className && node.className.baseVal !== undefined)
2635
+ ? node.className.baseVal
2636
+ : String(node.className || '');
2637
+
2638
+ // ── Strategy 1: aria-expanded mutation ───────────────
2639
+ var action = null;
2640
+ if (preExpanded !== null && postExpanded !== null && preExpanded !== postExpanded) {
2641
+ action = (postExpanded === 'true') ? 'expand' : 'collapse';
2642
+ } else if (postExpanded === 'true' && preExpanded === null) {
2643
+ action = 'expand';
2644
+ } else if (postExpanded === 'false' && preExpanded === null) {
2645
+ action = 'collapse';
2646
+ }
2647
+
2648
+ // ── Strategy 2: className state on the row ───────────
2649
+ // React tree libs commonly emit `_isOpen_<hash>` / `_expanded_<hash>`
2650
+ // / `_isCollapsed_<hash>` / `_collapsed_<hash>` on the row when
2651
+ // the open state changes. Compare pre vs post — if either flag
2652
+ // appeared / disappeared, that's our action.
2653
+ if (!action) {
2654
+ var EXP_RE = /_(?:isOpen|expanded|open(?![A-Za-z]))/i;
2655
+ var COLL_RE = /_(?:isClosed|collapsed|closed)/i;
2656
+ var preExp = EXP_RE.test(preNodeCls);
2657
+ var postExp = EXP_RE.test(postNodeCls);
2658
+ var preColl = COLL_RE.test(preNodeCls);
2659
+ var postColl = COLL_RE.test(postNodeCls);
2660
+ if (!preExp && postExp) action = 'expand';
2661
+ else if (preExp && !postExp) action = 'collapse';
2662
+ else if (!preColl && postColl) action = 'collapse';
2663
+ else if (preColl && !postColl) action = 'expand';
2664
+ }
2665
+
2666
+ // ── Strategy 3: toggle-class hint without direction ──
2667
+ // Click landed on an unambiguous toggle icon but we couldn't
2668
+ // determine direction from the row state. Record the gesture
2669
+ // as a generic `toggle` — still strictly more useful than
2670
+ // `click` because dashboards can split tree-navigation events
2671
+ // from open/close gestures.
2672
+ if (!action) {
2673
+ action = isToggle ? 'toggle' : 'click';
2674
+ }
2675
+
2676
+ // The handler also fires for clicks that ALSO trigger a hash
2677
+ // change. We DO NOT dedup against the hashchange-fired event
2678
+ // here — the user explicitly asked for every click / expand /
2679
+ // collapse gesture to be tracked, so emitting both a click
2680
+ // event AND a hash_change event for the same gesture is the
2681
+ // desired behaviour. Differentiate via the `tree_action` dim
2682
+ // in reports.
2683
+ trackAssetExplorerFolderNavigation({
2684
+ tree_action: action,
2685
+ tree_target_label: label,
2686
+ tree_target_class_hint: cls
2687
+ });
2688
+ }, 0);
2689
+ }, { capture: true });
2690
+ }
2691
+
2692
+ // ─── 10. Network interception for downloads ──────────────
2693
+
2694
+ /**
2695
+ * Emits `asset_download` if a request matches the ASC download endpoint.
2696
+ * Extracts `asset_path` (`path`) and `rendition_name` (`renditionName`)
2697
+ * directly from the form-encoded POST body so the event reflects exactly
2698
+ * what the user requested (single-asset, ZIP of renditions, etc.).
2699
+ */
2700
+ function emitDownloadIfMatch(method, url, body) {
2701
+ if (!url) return;
2702
+ if (method && String(method).toUpperCase() !== 'POST') return;
2703
+ if (!ENDPOINTS.download.test(url)) return;
2704
+ var info = parseFormBody(body);
2705
+ track('asset_download', {
2706
+ asset_path: safeStr(info.path),
2707
+ rendition_name: safeStr(info.renditionName),
2708
+ download_endpoint: url,
2709
+ page_url: location.href
2710
+ });
2711
+ }
2712
+
2713
+ /**
2714
+ * Wraps `window.fetch` and `XMLHttpRequest.prototype.{open,send}` once
2715
+ * per page. Idempotent across hot-reloads of the bootstrap. Always calls
2716
+ * the original implementation so site behavior is unchanged.
2717
+ */
2718
+ function installNetworkHooks() {
2719
+ if (typeof window.fetch === 'function' && !window.__ilPmigtrFetchPatched) {
2720
+ window.__ilPmigtrFetchPatched = true;
2721
+ var origFetch = window.fetch;
2722
+ window.fetch = function (input, init) {
2723
+ try {
2724
+ var url = typeof input === 'string' ? input : (input && input.url) || '';
2725
+ var method = (init && init.method) || (input && input.method) || 'GET';
2726
+ var body = init && init.body;
2727
+ emitDownloadIfMatch(method, url, body);
2728
+ } catch (e) { /* swallow — never break the page */ }
2729
+ return origFetch.apply(this, arguments);
2730
+ };
2731
+ }
2732
+
2733
+ if (typeof window.XMLHttpRequest === 'function' && !window.__ilPmigtrXhrPatched) {
2734
+ window.__ilPmigtrXhrPatched = true;
2735
+ var XHRProto = window.XMLHttpRequest.prototype;
2736
+ var origOpen = XHRProto.open;
2737
+ var origSend = XHRProto.send;
2738
+ XHRProto.open = function (method, url) {
2739
+ this.__ilPmigtrMethod = method;
2740
+ this.__ilPmigtrUrl = url;
2741
+ return origOpen.apply(this, arguments);
2742
+ };
2743
+ XHRProto.send = function (body) {
2744
+ try { emitDownloadIfMatch(this.__ilPmigtrMethod, this.__ilPmigtrUrl, body); } catch (e) {}
2745
+ return origSend.apply(this, arguments);
2746
+ };
2747
+ }
2748
+ }
2749
+
2750
+ // ─── 11. Orchestration ───────────────────────────────────
2751
+
2752
+ var HOOK_FLAG = '__il_pmigtr_hooked__';
2753
+
2754
+ /**
2755
+ * Login-page hook installer. Idempotent thanks to per-element flags so the
2756
+ * MutationObserver can re-run safely as the SPA mounts late controls.
2757
+ */
2758
+ function safeHookLoginAll() {
2759
+ function once(el, name, fn) {
2760
+ if (!el || el[HOOK_FLAG + name]) return;
2761
+ el[HOOK_FLAG + name] = true;
2762
+ fn();
2763
+ }
2764
+ var form = $(SELECTORS.loginForm);
2765
+ var signBtn = $(SELECTORS.signInButton)
2766
+ || closestMatchByText(document, 'button, a, [role="button"], input[type="submit"]', TEXT_PATTERNS.signIn);
2767
+ var ssoBtn = $(SELECTORS.ssoButton)
2768
+ || closestMatchByText(document, 'button, a, [role="button"]', TEXT_PATTERNS.lionSso);
2769
+ var terms = $(SELECTORS.termsLink)
2770
+ || closestMatchByText(document, 'a, button, [role="link"]', TEXT_PATTERNS.terms);
2771
+
2772
+ once(form, 'form', function () {
2773
+ form.addEventListener('submit', function () {
2774
+ var userInput = $(SELECTORS.userField);
2775
+ var typedUser = (userInput && (userInput.value || '').trim()) || '';
2776
+ if (typedUser) identify(typedUser);
2777
+ track('login_attempt', {
2778
+ method: 'credentials',
2779
+ attempt_user: typedUser,
2780
+ submit_reason: 'submit',
2781
+ page_url: location.href
2782
+ });
2783
+ flush();
2784
+ }, { capture: true });
2785
+ });
2786
+
2787
+ once(signBtn, 'sign', function () {
2788
+ signBtn.addEventListener('click', function () {
2789
+ var userInput = $(SELECTORS.userField);
2790
+ var typedUser = (userInput && (userInput.value || '').trim()) || '';
2791
+ if (typedUser) identify(typedUser);
2792
+ track('login_attempt', {
2793
+ method: 'credentials',
2794
+ attempt_user: typedUser,
2795
+ submit_reason: 'click',
2796
+ page_url: location.href
2797
+ });
2798
+ flush();
2799
+ }, { capture: true });
2800
+ });
2801
+
2802
+ once(ssoBtn, 'sso', function () {
2803
+ ssoBtn.addEventListener('click', function () {
2804
+ track('sso_click', {
2805
+ method: 'lion_login',
2806
+ sso_provider: 'lion',
2807
+ button_text: visibleText(ssoBtn).substring(0, 120),
2808
+ page_url: location.href
2809
+ });
2810
+ flush();
2811
+ }, { capture: true });
2812
+ });
2813
+
2814
+ once(terms, 'terms', function () {
2815
+ terms.addEventListener('click', function () {
2816
+ track('terms_click', {
2817
+ link_url: (terms.getAttribute && terms.getAttribute('href')) || '',
2818
+ link_text: visibleText(terms).substring(0, 120),
2819
+ page_url: location.href
2820
+ });
2821
+ flush();
2822
+ }, { capture: true });
2823
+ });
2824
+ }
2825
+
2826
+ function start() {
2827
+ if (!initSdk()) {
2828
+ console.warn('[IL-PMIGTR] Infralytiqs SDK not loaded — bootstrap aborted');
2829
+ return;
2830
+ }
2831
+
2832
+ // Identity FIRST so subsequent events carry user_id.
2833
+ // (Cache-hit path is sync; Granite-fetch path updates identity for the
2834
+ // next event batch — pre-flush events are already correctly tagged via
2835
+ // the SDK's identify-then-flush ordering.)
2836
+ resolveAndIdentifyUser();
2837
+
2838
+ // URL-driven events (fire once on page load), dispatched by context.
2839
+ if (isLoginPage()) {
2840
+ trackLoginPageView();
2841
+ safeHookLoginAll();
2842
+
2843
+ if (typeof MutationObserver === 'function') {
2844
+ var mo = new MutationObserver(function () { safeHookLoginAll(); });
2845
+ mo.observe(document.documentElement || document.body, {
2846
+ childList: true,
2847
+ subtree: true
2848
+ });
2849
+ // Stop watching after 20s — by then the SPA has settled and anything
2850
+ // we missed isn't going to appear from a page-load mutation.
2851
+ setTimeout(function () { mo.disconnect(); }, 20000);
2852
+ }
2853
+ } else if (isAssetExplorerPage()) {
2854
+ // React-based Asset Explorer (/asset-explorer.html#/content/dam/...)
2855
+ // — search and folder browsing live entirely in client-side state,
2856
+ // so we rely on DOM event hooks rather than URL parsing.
2857
+ installAssetExplorerHooks();
2858
+ // Asset preview / download / cart / share interactions piggy-back on
2859
+ // the same delegated listeners as ASC. They're a no-op if the page
2860
+ // doesn't render those affordances.
2861
+ installAscClickDelegation();
2862
+ installShareFormHook();
2863
+ installNetworkHooks();
2864
+ // Initial folder-view ping so reports see the user landed.
2865
+ // tagged "page_load" to distinguish from later interactions.
2866
+ trackAssetExplorerFolderNavigation({ tree_action: 'page_load' });
2867
+ } else {
2868
+ var q = parseQuery();
2869
+ trackSearchExecuted(q);
2870
+ trackAssetPreview();
2871
+ installAscClickDelegation();
2872
+ installShareFormHook();
2873
+ installNetworkHooks();
2874
+ // ASC drives in-page filter / sort / layout / pagination changes
2875
+ // through history.pushState — without this our URL-driven events
2876
+ // would only fire on hard reloads.
2877
+ installSpaNavHooks();
2878
+ }
2879
+ }
2880
+
2881
+ // ─── 12. Entry point ─────────────────────────────────────
2882
+ function waitForSdkAndStart() {
2883
+ var attempts = 0;
2884
+ var max = 50;
2885
+ (function tick() {
2886
+ if (getApi()) {
2887
+ start();
2888
+ return;
2889
+ }
2890
+ if (++attempts >= max) {
2891
+ console.warn('[IL-PMIGTR] Infralytiqs SDK never appeared after ' + (max * 100) + 'ms');
2892
+ return;
2893
+ }
2894
+ setTimeout(tick, 100);
2895
+ })();
2896
+ }
2897
+
2898
+ if (document.readyState === 'loading') {
2899
+ document.addEventListener('DOMContentLoaded', waitForSdkAndStart, { once: true });
2900
+ } else {
2901
+ waitForSdkAndStart();
2902
+ }
2903
+ })();