nowaikit-utils 1.1.0

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.
@@ -0,0 +1,2527 @@
1
+ /**
2
+ * NowAIKit Utils — Content Script
3
+ *
4
+ * Injected into ServiceNow pages. Provides:
5
+ * - Technical name resolver (show field names on hover)
6
+ * - Update set indicator banner
7
+ * - Field type tooltips (with sys_dictionary metadata)
8
+ * - Quick copy sys_id / field values
9
+ * - Node switcher (cookie + performance API detection)
10
+ * - Script syntax highlighting in script fields
11
+ * - Quick navigation shortcuts
12
+ * - Record info overlay
13
+ * - Theme detection (Polaris dark/light)
14
+ */
15
+
16
+ (function() {
17
+ 'use strict';
18
+
19
+ // ─── Iframe Detection (early — used to skip heavy work in iframes) ────────
20
+ var isIframe = window !== window.top;
21
+
22
+ // ─── Shared State Namespace ─────────────────────────────────────────────────
23
+ // Exposed on window so ai-sidebar.js, code-templates.js, etc. can access
24
+ window.nowaikitState = window.nowaikitState || {
25
+ currentTable: '',
26
+ currentSysId: '',
27
+ instanceUrl: '',
28
+ isForm: false,
29
+ isList: false,
30
+ isMac: /Mac|iPod|iPhone|iPad/.test(navigator.platform || navigator.userAgent),
31
+ };
32
+ var NS = window.nowaikitState;
33
+
34
+ // ─── Settings ───────────────────────────────────────────────────────────────
35
+
36
+ let settings = {
37
+ showTechnicalNames: false,
38
+ showUpdateSetBanner: false,
39
+ showFieldTypes: false,
40
+ enableNodeSwitcher: false,
41
+ enableQuickNav: false,
42
+ enableScriptHighlight: false,
43
+ enableFieldCopy: false,
44
+ enableAISidebar: false,
45
+ darkOverlay: false,
46
+ };
47
+
48
+ // One-time migration: force all features off for users who had old defaults
49
+ // Only run in top frame — iframes don't need to repeat this
50
+ if (!isIframe) {
51
+ chrome.storage.sync.get({ _defaultsV2Migrated: false }, function(m) {
52
+ if (!m._defaultsV2Migrated) {
53
+ chrome.storage.sync.set({
54
+ showTechnicalNames: false,
55
+ showUpdateSetBanner: false,
56
+ showFieldTypes: false,
57
+ enableNodeSwitcher: false,
58
+ enableQuickNav: false,
59
+ enableScriptHighlight: false,
60
+ enableFieldCopy: false,
61
+ enableAISidebar: false,
62
+ _defaultsV2Migrated: true,
63
+ });
64
+ }
65
+ });
66
+ }
67
+
68
+ // Load settings and init
69
+ if (isIframe) {
70
+ // In iframes, read settings directly from storage — skip service worker round-trip
71
+ chrome.storage.sync.get(settings, function(stored) {
72
+ if (chrome.runtime.lastError) {
73
+ // Storage access failed — init with defaults (all features off)
74
+ } else if (stored) {
75
+ settings = stored;
76
+ }
77
+ init();
78
+ });
79
+ } else {
80
+ // Top frame: load via service worker (supports richer settings pipeline)
81
+ try {
82
+ chrome.runtime.sendMessage({ action: 'getSettings' }, (response) => {
83
+ if (chrome.runtime.lastError) {
84
+ console.warn('NowAIKit Utils: Settings load failed, using defaults');
85
+ } else if (response) {
86
+ settings = response;
87
+ }
88
+ init();
89
+ });
90
+ } catch (e) {
91
+ // Service worker completely unavailable — init with defaults
92
+ console.warn('NowAIKit Utils: Service worker unavailable, using defaults');
93
+ init();
94
+ }
95
+ }
96
+
97
+ // ─── State (local aliases — also synced to NS) ────────────────────────────
98
+
99
+ let currentTable = '';
100
+ let currentSysId = '';
101
+ let instanceUrl = '';
102
+ let isForm = false;
103
+ let isList = false;
104
+
105
+ /** @type {Object<string, Object[]>} Cache of sys_dictionary metadata keyed by table name */
106
+ const fieldMetadataCache = {};
107
+
108
+ // ─── 3A. MutationObserver Utility ─────────────────────────────────────────
109
+
110
+ /**
111
+ * Waits for an element matching `selector` to appear in the DOM.
112
+ * Uses MutationObserver for reliable, fast detection instead of setTimeout.
113
+ *
114
+ * @param {string} selector - CSS selector to watch for
115
+ * @param {function} callback - Called with the first matched element
116
+ * @param {number} [timeout=10000] - Max wait time in ms before giving up
117
+ * @returns {function} Dispose function to cancel the observer early
118
+ */
119
+ function waitForElement(selector, callback, timeout) {
120
+ if (timeout === undefined) timeout = 10000;
121
+
122
+ // Check if element already exists
123
+ const existing = document.querySelector(selector);
124
+ if (existing) {
125
+ callback(existing);
126
+ return function() {};
127
+ }
128
+
129
+ let disposed = false;
130
+ let timer = null;
131
+
132
+ const observer = new MutationObserver(function() {
133
+ if (disposed) return;
134
+ const el = document.querySelector(selector);
135
+ if (el) {
136
+ disposed = true;
137
+ observer.disconnect();
138
+ if (timer) clearTimeout(timer);
139
+ callback(el);
140
+ }
141
+ });
142
+
143
+ observer.observe(document.body || document.documentElement, {
144
+ childList: true,
145
+ subtree: true,
146
+ });
147
+
148
+ if (timeout > 0) {
149
+ timer = setTimeout(function() {
150
+ if (!disposed) {
151
+ disposed = true;
152
+ observer.disconnect();
153
+ }
154
+ }, timeout);
155
+ }
156
+
157
+ return function() {
158
+ if (!disposed) {
159
+ disposed = true;
160
+ observer.disconnect();
161
+ if (timer) clearTimeout(timer);
162
+ }
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Waits for all elements matching `selector` to stabilize (no new matches
168
+ * for `debounceMs`), then calls callback with the NodeList.
169
+ *
170
+ * @param {string} selector - CSS selector
171
+ * @param {function} callback - Called with querySelectorAll results
172
+ * @param {number} [timeout=10000] - Max wait in ms
173
+ * @param {number} [debounceMs=500] - Debounce interval
174
+ * @returns {function} Dispose function
175
+ */
176
+ function waitForElements(selector, callback, timeout, debounceMs) {
177
+ if (timeout === undefined) timeout = 10000;
178
+ if (debounceMs === undefined) debounceMs = 500;
179
+
180
+ const existing = document.querySelectorAll(selector);
181
+ if (existing.length > 0) {
182
+ // Still observe briefly in case more are loading
183
+ let settled = false;
184
+ let debounceTimer = null;
185
+ let disposed = false;
186
+
187
+ const observer = new MutationObserver(function() {
188
+ if (disposed) return;
189
+ if (debounceTimer) clearTimeout(debounceTimer);
190
+ debounceTimer = setTimeout(function() {
191
+ if (!disposed) {
192
+ disposed = true;
193
+ observer.disconnect();
194
+ callback(document.querySelectorAll(selector));
195
+ }
196
+ }, debounceMs);
197
+ });
198
+
199
+ observer.observe(document.body || document.documentElement, {
200
+ childList: true,
201
+ subtree: true,
202
+ });
203
+
204
+ // Trigger initial debounce
205
+ debounceTimer = setTimeout(function() {
206
+ if (!disposed) {
207
+ disposed = true;
208
+ observer.disconnect();
209
+ callback(document.querySelectorAll(selector));
210
+ }
211
+ }, debounceMs);
212
+
213
+ const maxTimer = setTimeout(function() {
214
+ if (!disposed) {
215
+ disposed = true;
216
+ observer.disconnect();
217
+ if (debounceTimer) clearTimeout(debounceTimer);
218
+ callback(document.querySelectorAll(selector));
219
+ }
220
+ }, timeout);
221
+
222
+ return function() {
223
+ if (!disposed) {
224
+ disposed = true;
225
+ observer.disconnect();
226
+ if (debounceTimer) clearTimeout(debounceTimer);
227
+ clearTimeout(maxTimer);
228
+ }
229
+ };
230
+ }
231
+
232
+ // Nothing found yet — watch until something appears, then debounce
233
+ let disposed = false;
234
+ let debounceTimer = null;
235
+
236
+ const observer = new MutationObserver(function() {
237
+ if (disposed) return;
238
+ const els = document.querySelectorAll(selector);
239
+ if (els.length > 0) {
240
+ if (debounceTimer) clearTimeout(debounceTimer);
241
+ debounceTimer = setTimeout(function() {
242
+ if (!disposed) {
243
+ disposed = true;
244
+ observer.disconnect();
245
+ callback(document.querySelectorAll(selector));
246
+ }
247
+ }, debounceMs);
248
+ }
249
+ });
250
+
251
+ observer.observe(document.body || document.documentElement, {
252
+ childList: true,
253
+ subtree: true,
254
+ });
255
+
256
+ const maxTimer = setTimeout(function() {
257
+ if (!disposed) {
258
+ disposed = true;
259
+ observer.disconnect();
260
+ if (debounceTimer) clearTimeout(debounceTimer);
261
+ const els = document.querySelectorAll(selector);
262
+ if (els.length > 0) callback(els);
263
+ }
264
+ }, timeout);
265
+
266
+ return function() {
267
+ if (!disposed) {
268
+ disposed = true;
269
+ observer.disconnect();
270
+ if (debounceTimer) clearTimeout(debounceTimer);
271
+ clearTimeout(maxTimer);
272
+ }
273
+ };
274
+ }
275
+
276
+ // ─── Workspace / SPA Detection ──────────────────────────────────────────────
277
+
278
+ /** True if we're on a workspace / Next Experience / Polaris route */
279
+ let isWorkspace = false;
280
+ let lastUrl = '';
281
+
282
+ function detectWorkspace() {
283
+ const url = window.location.href;
284
+ isWorkspace = /\/now\/(sow|workspace|nav\/ui|agent)/.test(url) ||
285
+ !!document.querySelector('sn-polaris-layout, macroponent-f51912f4, now-record-form, [data-component="sn-polaris-header"]');
286
+ NS.isWorkspace = isWorkspace;
287
+ }
288
+
289
+ /**
290
+ * Observe workspace SPA navigation — re-run feature injection when the
291
+ * route changes without a full page reload.
292
+ */
293
+ function watchSpaNavigation() {
294
+ lastUrl = window.location.href;
295
+
296
+ // Method 1: popstate for back/forward
297
+ window.addEventListener('popstate', onRouteChange);
298
+
299
+ // Method 2: Intercept pushState/replaceState (with guard to prevent double-patching)
300
+ if (!history._nowaikitPatched) {
301
+ const origPush = history.pushState;
302
+ const origReplace = history.replaceState;
303
+ history.pushState = function() {
304
+ origPush.apply(this, arguments);
305
+ onRouteChange();
306
+ };
307
+ history.replaceState = function() {
308
+ origReplace.apply(this, arguments);
309
+ onRouteChange();
310
+ };
311
+ history._nowaikitPatched = true;
312
+ }
313
+
314
+ // Method 3: DOM mutation fallback for frameworks that bypass history API
315
+ var urlCheckTimer = setInterval(function() {
316
+ if (window.location.href !== lastUrl) {
317
+ onRouteChange();
318
+ }
319
+ }, 1500);
320
+ }
321
+
322
+ function onRouteChange() {
323
+ var newUrl = window.location.href;
324
+ if (newUrl === lastUrl) return;
325
+ lastUrl = newUrl;
326
+
327
+ // Re-detect page state
328
+ currentTable = '';
329
+ currentSysId = '';
330
+ isForm = false;
331
+ isList = false;
332
+ detectPage();
333
+ detectWorkspace();
334
+
335
+ NS.currentTable = currentTable;
336
+ NS.currentSysId = currentSysId;
337
+ NS.isForm = isForm;
338
+ NS.isList = isList;
339
+
340
+ // Re-inject features after a short delay (let the SPA render)
341
+ setTimeout(injectFeatures, 600);
342
+ }
343
+
344
+ // ─── Initialization ─────────────────────────────────────────────────────────
345
+
346
+ function init() {
347
+ detectPage();
348
+ if (!instanceUrl) return; // Not a ServiceNow page
349
+ detectWorkspace();
350
+
351
+ // Sync state to shared namespace for other scripts (ai-sidebar, etc.)
352
+ NS.currentTable = currentTable;
353
+ NS.currentSysId = currentSysId;
354
+ NS.instanceUrl = instanceUrl;
355
+ NS.isForm = isForm;
356
+ NS.isList = isList;
357
+
358
+ // ─── Background Script Auto-Paste ─────────────────────────────────────────
359
+ // When "Run in BG Script" is clicked from templates, code is stored and
360
+ // sys.scripts.do is opened. Detect that page and inject the code.
361
+ if (window.location.pathname === '/sys.scripts.do' || window.location.pathname === '/sys.scripts.modern.do' || (window.location.pathname === '/nav_to.do' && window.location.search.indexOf('sys.scripts.do') !== -1)) {
362
+ chrome.storage.local.get({ nowaikitPendingScript: '' }, function(data) {
363
+ var pendingScript = data.nowaikitPendingScript;
364
+ if (!pendingScript) return;
365
+ // Clear immediately so it doesn't re-inject on refresh
366
+ chrome.storage.local.remove('nowaikitPendingScript');
367
+ // Wait for the page editor to load, then inject
368
+ var attempts = 0;
369
+ var injectTimer = setInterval(function() {
370
+ attempts++;
371
+ if (attempts > 40) { clearInterval(injectTimer); return; } // Give up after 8s
372
+ // Try CodeMirror first
373
+ var cmEl = document.querySelector('.CodeMirror');
374
+ if (cmEl && cmEl.CodeMirror) {
375
+ cmEl.CodeMirror.setValue(pendingScript);
376
+ clearInterval(injectTimer);
377
+ return;
378
+ }
379
+ // Try textarea
380
+ var ta = document.querySelector('textarea[name="script"]') || document.querySelector('#runscript');
381
+ if (ta) {
382
+ ta.value = pendingScript;
383
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
384
+ clearInterval(injectTimer);
385
+ return;
386
+ }
387
+ }, 200);
388
+ });
389
+ }
390
+
391
+ // ─── Global Theme ─────────────────────────────────────────────────────────
392
+ // Apply stored theme to body so all extension UI respects dark/light mode.
393
+ // Runs in both top frame and iframes (tech names, badges use theme classes).
394
+ chrome.storage.local.get({ nowaikitTheme: 'dark' }, function(data) {
395
+ var theme = data.nowaikitTheme || 'dark';
396
+ document.body.classList.remove('nowaikit-light', 'nowaikit-dark');
397
+ document.body.classList.add('nowaikit-' + theme);
398
+ });
399
+
400
+ // Expose global theme API for other scripts (ai-sidebar, templates, etc.)
401
+ if (!isIframe) {
402
+ window.nowaikitGetTheme = function() {
403
+ return document.body.classList.contains('nowaikit-light') ? 'light' : 'dark';
404
+ };
405
+ window.nowaikitSetTheme = function(theme) {
406
+ document.body.classList.remove('nowaikit-light', 'nowaikit-dark');
407
+ document.body.classList.add('nowaikit-' + theme);
408
+ chrome.storage.local.set({ nowaikitTheme: theme });
409
+ };
410
+ window.nowaikitToggleTheme = function() {
411
+ var current = document.body.classList.contains('nowaikit-light') ? 'light' : 'dark';
412
+ var next = current === 'dark' ? 'light' : 'dark';
413
+ window.nowaikitSetTheme(next);
414
+ return next;
415
+ };
416
+ }
417
+
418
+ // Check if ANY feature is enabled — skip heavy init if all off
419
+ var anyFeatureOn = settings.showTechnicalNames || settings.showUpdateSetBanner ||
420
+ settings.showFieldTypes || settings.enableNodeSwitcher || settings.enableQuickNav ||
421
+ settings.enableScriptHighlight || settings.enableFieldCopy || settings.enableAISidebar;
422
+
423
+ // Always register keyboard shortcuts and context menu (lightweight, no DOM observers)
424
+ if (!isIframe) {
425
+ listenForContextMenuActions();
426
+ injectKeyboardShortcuts();
427
+ }
428
+
429
+ if (!anyFeatureOn) {
430
+ return;
431
+ }
432
+
433
+ // Inject g_ck bridge only when features need API calls
434
+ injectGckBridge();
435
+
436
+ detectTheme();
437
+ injectFeatures();
438
+
439
+ // These features only make sense in the top frame — not inside classic-in-iframe
440
+ if (!isIframe) {
441
+ injectInfoOverlay();
442
+
443
+ // Workspace-only: SPA navigation watcher + form mutation observer
444
+ if (isWorkspace) {
445
+ watchSpaNavigation();
446
+ watchWorkspaceForms();
447
+ }
448
+
449
+ // AI Sidebar — initialize if enabled and the function exists
450
+ if (settings.enableAISidebar && typeof initAISidebar === 'function') {
451
+ initAISidebar();
452
+ }
453
+ }
454
+ }
455
+
456
+ /** Inject all toggleable features (called on init and on SPA navigation) */
457
+ function injectFeatures() {
458
+ if (settings.showUpdateSetBanner) injectUpdateSetBanner();
459
+ if (settings.showTechnicalNames) injectTechnicalNames();
460
+ if (settings.enableFieldCopy) injectFieldCopy();
461
+ if (settings.enableQuickNav) injectQuickNav();
462
+ if (settings.enableScriptHighlight) injectScriptHighlighting();
463
+ if (settings.showFieldTypes) injectFieldTypeTooltips();
464
+ }
465
+
466
+ /**
467
+ * Watch for workspace form fields to appear after SPA render.
468
+ * Workspace/Polaris renders forms asynchronously — fields appear after
469
+ * the initial page shell loads. This observer triggers re-injection
470
+ * when new form fields are detected.
471
+ */
472
+ var _wsFormObserver = null;
473
+ function watchWorkspaceForms() {
474
+ if (_wsFormObserver) return; // Only one observer
475
+
476
+ var debounceTimer = null;
477
+ _wsFormObserver = new MutationObserver(function(mutations) {
478
+ // Check if any new form-related elements were added
479
+ var hasFormContent = false;
480
+ for (var i = 0; i < mutations.length; i++) {
481
+ var addedNodes = mutations[i].addedNodes;
482
+ for (var j = 0; j < addedNodes.length; j++) {
483
+ var node = addedNodes[j];
484
+ if (node.nodeType !== 1) continue;
485
+ if (node.matches && (
486
+ node.matches('sn-form-field, now-record-form, [data-field-name], .form-group') ||
487
+ node.querySelector && node.querySelector('sn-form-field, now-record-form, [data-field-name], .form-group, label')
488
+ )) {
489
+ hasFormContent = true;
490
+ break;
491
+ }
492
+ }
493
+ if (hasFormContent) break;
494
+ }
495
+
496
+ if (hasFormContent) {
497
+ if (debounceTimer) clearTimeout(debounceTimer);
498
+ debounceTimer = setTimeout(function() {
499
+ // Re-detect page state in case context changed
500
+ detectPage();
501
+ NS.currentTable = currentTable;
502
+ NS.currentSysId = currentSysId;
503
+ NS.isForm = isForm;
504
+
505
+ if (settings.showTechnicalNames) injectTechnicalNames();
506
+ if (settings.enableFieldCopy) injectFieldCopy();
507
+ if (settings.showFieldTypes) injectFieldTypeTooltips();
508
+ }, 1500);
509
+ }
510
+ });
511
+
512
+ // Only observe workspace pages — classic UI doesn't need this
513
+ if (!isWorkspace) return;
514
+
515
+ _wsFormObserver.observe(document.body || document.documentElement, {
516
+ childList: true,
517
+ subtree: true,
518
+ });
519
+ }
520
+
521
+ function detectPage() {
522
+ const url = window.location.href;
523
+ instanceUrl = window.location.origin;
524
+
525
+ // Method 1: Classic form — table.do with sys_id anywhere in query string
526
+ const doMatch = url.match(/\/([a-z_][a-z0-9_]*)\.do(?:\?|#|$)/);
527
+ if (doMatch) {
528
+ currentTable = doMatch[1];
529
+ // Look for sys_id anywhere in the query string (not just first param)
530
+ const sysIdMatch = url.match(/[?&]sys_id=([a-f0-9]{32})/);
531
+ if (sysIdMatch) {
532
+ currentSysId = sysIdMatch[1];
533
+ isForm = true;
534
+ }
535
+ }
536
+
537
+ // Method 2: Classic list — table_list.do
538
+ if (!isForm) {
539
+ const listMatch = url.match(/\/([a-z_][a-z0-9_]*)_list\.do/);
540
+ if (listMatch) {
541
+ currentTable = listMatch[1];
542
+ isList = true;
543
+ }
544
+ }
545
+
546
+ // Method 3: Next Experience navigation (classic UI in iframe)
547
+ if (!currentTable) {
548
+ const nxMatch = url.match(/\/now\/nav\/ui\/classic\/params\/target\/([a-z_][a-z0-9_]*)/);
549
+ if (nxMatch) {
550
+ currentTable = nxMatch[1];
551
+ // Try to find sys_id in encoded params
552
+ const nxSysId = url.match(/sys_id(?:%3D|=)([a-f0-9]{32})/i);
553
+ if (nxSysId) {
554
+ currentSysId = nxSysId[1];
555
+ isForm = true;
556
+ }
557
+ }
558
+ }
559
+
560
+ // Method 4: Workspace record view (/now/sow/record/table/sys_id or /now/workspace/...)
561
+ if (!currentTable) {
562
+ const wsMatch = url.match(/\/now\/(?:sow|workspace|agent)[^/]*\/(?:.*\/)?record\/([a-z_][a-z0-9_]*)\/([a-f0-9]{32})/);
563
+ if (wsMatch) {
564
+ currentTable = wsMatch[1];
565
+ currentSysId = wsMatch[2];
566
+ isForm = true;
567
+ }
568
+ }
569
+
570
+ // Method 5: Workspace list view (/now/workspace/.../list/table)
571
+ if (!currentTable) {
572
+ const wsListMatch = url.match(/\/now\/(?:sow|workspace|agent)[^/]*\/(?:.*\/)?list\/([a-z_][a-z0-9_]*)/);
573
+ if (wsListMatch) {
574
+ currentTable = wsListMatch[1];
575
+ isList = true;
576
+ }
577
+ }
578
+
579
+ // Method 6: Try to detect from GlideForm if available (classic UI)
580
+ if (!currentTable && typeof g_form !== 'undefined') {
581
+ try {
582
+ currentTable = g_form.getTableName();
583
+ currentSysId = g_form.getUniqueValue();
584
+ isForm = true;
585
+ } catch(e) { /* ignore */ }
586
+ }
587
+
588
+ // Method 7: Detect from DOM data attributes (workspace forms)
589
+ if (!currentTable) {
590
+ var formEl = document.querySelector('now-record-form[table], [data-table-name]');
591
+ if (formEl) {
592
+ currentTable = formEl.getAttribute('table') || formEl.getAttribute('data-table-name') || '';
593
+ var sysIdAttr = formEl.getAttribute('sys-id') || formEl.getAttribute('data-sys-id') || '';
594
+ if (sysIdAttr && /^[a-f0-9]{32}$/.test(sysIdAttr)) {
595
+ currentSysId = sysIdAttr;
596
+ isForm = true;
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ // ─── 3E. Theme Detection ──────────────────────────────────────────────────
603
+
604
+ let themeObserverActive = false;
605
+
606
+ function applyThemeClass() {
607
+ let isDark = false;
608
+
609
+ // Method 1: Polaris theme uses data-theme attribute
610
+ const htmlEl = document.documentElement;
611
+ const dataTheme = htmlEl.getAttribute('data-theme') || '';
612
+ if (dataTheme.includes('dark')) {
613
+ isDark = true;
614
+ } else if (dataTheme.includes('light')) {
615
+ isDark = false;
616
+ }
617
+
618
+ // Method 2: Check body classes used by newer Polaris builds
619
+ const bodyClasses = document.body ? document.body.className : '';
620
+ if (!dataTheme) {
621
+ if (bodyClasses.includes('navpage-theme-dark') || bodyClasses.includes('dark-theme') || bodyClasses.includes('sn-polaris-dark')) {
622
+ isDark = true;
623
+ }
624
+ }
625
+
626
+ // Method 3: Check CSS custom property set by Polaris
627
+ if (!dataTheme && !isDark) {
628
+ const bgColor = getComputedStyle(document.documentElement).getPropertyValue('--now-color--background--primary');
629
+ if (bgColor) {
630
+ // Parse the background color — dark themes have low luminance
631
+ const temp = document.createElement('div');
632
+ temp.style.color = bgColor.trim();
633
+ document.body.appendChild(temp);
634
+ const computed = getComputedStyle(temp).color;
635
+ document.body.removeChild(temp);
636
+ const rgb = computed.match(/\d+/g);
637
+ if (rgb && rgb.length >= 3) {
638
+ const luminance = (0.299 * parseInt(rgb[0]) + 0.587 * parseInt(rgb[1]) + 0.114 * parseInt(rgb[2])) / 255;
639
+ isDark = luminance < 0.5;
640
+ }
641
+ }
642
+ }
643
+
644
+ // Method 4: Fallback — check background color of body
645
+ if (!dataTheme && document.body) {
646
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
647
+ if (bodyBg && bodyBg !== 'rgba(0, 0, 0, 0)') {
648
+ const rgb = bodyBg.match(/\d+/g);
649
+ if (rgb && rgb.length >= 3) {
650
+ const luminance = (0.299 * parseInt(rgb[0]) + 0.587 * parseInt(rgb[1]) + 0.114 * parseInt(rgb[2])) / 255;
651
+ if (luminance < 0.4) isDark = true;
652
+ }
653
+ }
654
+ }
655
+
656
+ // Apply theme classes
657
+ document.body.classList.remove('nowaikit-light', 'nowaikit-dark');
658
+ document.body.classList.add(isDark ? 'nowaikit-dark' : 'nowaikit-light');
659
+ }
660
+
661
+ function detectTheme() {
662
+ applyThemeClass();
663
+
664
+ // Set up a single observer (only once) to re-check when theme attributes change
665
+ if (!themeObserverActive) {
666
+ themeObserverActive = true;
667
+ const themeObserver = new MutationObserver(function(mutations) {
668
+ for (const m of mutations) {
669
+ if (m.type === 'attributes' && (m.attributeName === 'data-theme' || m.attributeName === 'class')) {
670
+ applyThemeClass();
671
+ break;
672
+ }
673
+ }
674
+ });
675
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
676
+ if (document.body) {
677
+ themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
678
+ }
679
+ }
680
+ }
681
+
682
+ // ─── Update Set Banner ──────────────────────────────────────────────────────
683
+
684
+ function injectUpdateSetBanner() {
685
+ // Don't duplicate
686
+ if (document.getElementById('nowaikit-updateset-banner')) {
687
+ // On SPA nav, just re-fetch the update set name
688
+ fetchUpdateSetName(function(usName) {
689
+ var textEl = document.getElementById('nowaikit-us-text');
690
+ if (textEl && usName) {
691
+ textEl.innerHTML = '<strong>Update Set:</strong> ' + escapeHtml(usName);
692
+ }
693
+ });
694
+ return;
695
+ }
696
+
697
+ // Only inject on forms/lists/workspace
698
+ if (!isForm && !isList && !isWorkspace) return;
699
+
700
+ var banner = document.createElement('div');
701
+ banner.id = 'nowaikit-updateset-banner';
702
+ banner.innerHTML = '<span id="nowaikit-us-text">Loading update set...</span>';
703
+ document.body.prepend(banner);
704
+
705
+ fetchUpdateSetName(function(usName) {
706
+ if (usName) {
707
+ var textEl = document.getElementById('nowaikit-us-text');
708
+ if (textEl) {
709
+ textEl.innerHTML = '<strong>Update Set:</strong> ' + escapeHtml(usName);
710
+ }
711
+ } else {
712
+ banner.style.display = 'none';
713
+ }
714
+ });
715
+ }
716
+
717
+ /**
718
+ * Get the current update set name — works on both classic UI and workspace.
719
+ * Strategy: DOM picker first, then REST API fallback.
720
+ */
721
+ function fetchUpdateSetName(callback) {
722
+ // Strategy 1: Classic UI picker in DOM
723
+ var usPicker = document.querySelector('#update_set_picker_select');
724
+ if (usPicker && usPicker.options && usPicker.options[usPicker.selectedIndex]) {
725
+ callback(usPicker.options[usPicker.selectedIndex].text);
726
+ return;
727
+ }
728
+
729
+ // Strategy 2: Classic system info text
730
+ var sysInfo = document.querySelector('.sn-system-info-text');
731
+ if (sysInfo && sysInfo.textContent && sysInfo.textContent.trim()) {
732
+ callback(sysInfo.textContent.trim());
733
+ return;
734
+ }
735
+
736
+ // Strategy 3: Workspace / Polaris — concourse picker element
737
+ var concourseText = document.querySelector(
738
+ '[data-testid="updateset-picker"] span, ' +
739
+ 'sn-polaris-header [aria-label*="update set"] span, ' +
740
+ '.sn-polaris-updateset-picker span, ' +
741
+ '[id*="updateset"] .now-dropdown-selected'
742
+ );
743
+ if (concourseText && concourseText.textContent && concourseText.textContent.trim()) {
744
+ callback(concourseText.textContent.trim());
745
+ return;
746
+ }
747
+
748
+ // Strategy 4: REST API (works everywhere, including workspace)
749
+ var token = getSecurityToken();
750
+ if (!token) {
751
+ // Wait briefly for g_ck bridge, then retry
752
+ setTimeout(function() {
753
+ var tok = getSecurityToken();
754
+ if (tok) fetchUpdateSetViaApi(tok, callback);
755
+ else callback('');
756
+ }, 2000);
757
+ return;
758
+ }
759
+ fetchUpdateSetViaApi(token, callback);
760
+ }
761
+
762
+ function fetchUpdateSetViaApi(token, callback) {
763
+ var xhr = new XMLHttpRequest();
764
+ xhr.open('GET', instanceUrl + '/api/now/ui/concoursepicker/updateset', true);
765
+ xhr.setRequestHeader('Accept', 'application/json');
766
+ xhr.setRequestHeader('X-UserToken', token);
767
+ xhr.onreadystatechange = function() {
768
+ if (xhr.readyState !== 4) return;
769
+ if (xhr.status === 200) {
770
+ try {
771
+ var data = JSON.parse(xhr.responseText);
772
+ var name = data.result && data.result.name ? data.result.name : '';
773
+ if (!name && data.result && data.result.displayValue) name = data.result.displayValue;
774
+ callback(name || 'Default');
775
+ } catch(e) { callback(''); }
776
+ } else {
777
+ callback('');
778
+ }
779
+ };
780
+ xhr.send();
781
+ }
782
+
783
+ // ─── Technical Names ────────────────────────────────────────────────────────
784
+
785
+ function injectTechnicalNames() {
786
+ if (!isForm && !isWorkspace) return;
787
+
788
+ // Combined selector: classic UI + workspace / Polaris / Next Experience
789
+ var selector = [
790
+ // Classic UI
791
+ 'label.label-text',
792
+ '.sn-form-field label',
793
+ 'td.label_left label',
794
+ // Workspace / Polaris / Next Experience
795
+ 'sn-form-field label',
796
+ 'now-record-form-field label',
797
+ '[data-field-name] label',
798
+ '.form-field label',
799
+ 'now-label',
800
+ 'label[for^="sp_formfield_"]',
801
+ ].join(', ');
802
+
803
+ waitForElements(selector, function(labels) {
804
+ labels.forEach(function(label) {
805
+ if (label.querySelector('.nowaikit-techname')) return; // Already injected
806
+
807
+ var fieldName = '';
808
+
809
+ // Strategy 1: Parent with data-field-name (workspace)
810
+ var fieldParent = label.closest('[data-field-name]');
811
+ if (fieldParent) {
812
+ fieldName = fieldParent.getAttribute('data-field-name');
813
+ }
814
+
815
+ // Strategy 2: Classic — extract from input IDs
816
+ if (!fieldName) {
817
+ var parent = label.closest('tr, .form-group, .sn-form-field, sn-form-field, now-record-form-field');
818
+ if (parent) {
819
+ var input = parent.querySelector('input, select, textarea, now-input, now-select, now-textarea');
820
+ if (input) {
821
+ fieldName = input.getAttribute('data-field-name') ||
822
+ input.getAttribute('name') ||
823
+ (input.getAttribute('id') || '').replace(/^sys_display\./, '').replace(/^ni\./, '').replace(/^sp_formfield_/, '');
824
+ }
825
+ }
826
+ }
827
+
828
+ // Strategy 3: label "for" attribute
829
+ if (!fieldName) {
830
+ var forAttr = label.getAttribute('for') || '';
831
+ if (forAttr) {
832
+ fieldName = forAttr.replace(/^sys_display\./, '').replace(/^ni\./, '').replace(/^sp_formfield_/, '');
833
+ }
834
+ }
835
+
836
+ // Skip internal/system names
837
+ if (!fieldName || fieldName.length > 80 || fieldName.includes(' ')) return;
838
+
839
+ var badge = document.createElement('span');
840
+ badge.className = 'nowaikit-techname';
841
+ badge.textContent = fieldName;
842
+ badge.title = 'Click to copy field name';
843
+ badge.addEventListener('click', function(e) {
844
+ e.preventDefault();
845
+ e.stopPropagation();
846
+ navigator.clipboard.writeText(fieldName).then(function() {
847
+ badge.textContent = 'Copied!';
848
+ setTimeout(function() { badge.textContent = fieldName; }, 1000);
849
+ }).catch(function() { /* clipboard denied */ });
850
+ });
851
+ label.appendChild(badge);
852
+ });
853
+ }, 10000);
854
+ }
855
+
856
+ // ─── Field Copy ─────────────────────────────────────────────────────────────
857
+
858
+ function injectFieldCopy() {
859
+ if (!isForm && !isWorkspace) return;
860
+
861
+ // Combined selectors: classic + workspace / Polaris
862
+ var selector = [
863
+ '.form-control',
864
+ '.sn-field-value',
865
+ 'td.vt',
866
+ // Workspace / Polaris
867
+ 'now-input',
868
+ 'now-textarea',
869
+ '[data-field-name] .now-line-height-crop',
870
+ 'sn-form-field .value-display',
871
+ '.sn-form-field-value',
872
+ ].join(', ');
873
+
874
+ waitForElements(selector, function(displayValues) {
875
+ displayValues.forEach(function(el) {
876
+ if (el.querySelector('.nowaikit-copy-btn')) return;
877
+
878
+ var btn = document.createElement('button');
879
+ btn.className = 'nowaikit-copy-btn';
880
+ btn.innerHTML = '&#x2398;';
881
+ btn.title = 'Copy value';
882
+ btn.addEventListener('click', function(e) {
883
+ e.preventDefault();
884
+ e.stopPropagation();
885
+ // Try multiple value sources
886
+ var value = el.value ||
887
+ (el.getAttribute && el.getAttribute('value')) ||
888
+ (el.textContent ? el.textContent.trim() : '');
889
+ navigator.clipboard.writeText(value).then(function() {
890
+ btn.innerHTML = '&#x2713;';
891
+ setTimeout(function() { btn.innerHTML = '&#x2398;'; }, 1000);
892
+ }).catch(function() { /* clipboard denied */ });
893
+ });
894
+
895
+ el.style.position = 'relative';
896
+ el.appendChild(btn);
897
+ });
898
+ }, 10000);
899
+ }
900
+
901
+ // ─── 3D. Field Type Tooltips ──────────────────────────────────────────────
902
+
903
+ function injectFieldTypeTooltips() {
904
+ if ((!isForm && !isWorkspace) || !currentTable) return;
905
+
906
+ fetchFieldMetadata(currentTable, function(fields) {
907
+ if (!fields || fields.length === 0) return;
908
+
909
+ // Build a lookup by element (column) name
910
+ const lookup = {};
911
+ fields.forEach(function(f) {
912
+ lookup[f.element] = f;
913
+ });
914
+
915
+ // Find all technical name badges and attach tooltip behavior
916
+ const badges = document.querySelectorAll('.nowaikit-techname');
917
+ badges.forEach(function(badge) {
918
+ const fieldName = badge.textContent;
919
+ if (fieldName === 'Copied!' || !lookup[fieldName]) return;
920
+
921
+ const meta = lookup[fieldName];
922
+
923
+ badge.classList.add('nowaikit-has-tooltip');
924
+
925
+ // Build tooltip content
926
+ const tooltipData = [];
927
+ tooltipData.push('Type: ' + (meta.internal_type_display || meta.internal_type || 'unknown'));
928
+ if (meta.max_length) tooltipData.push('Max Length: ' + meta.max_length);
929
+ if (meta.reference) tooltipData.push('Reference: ' + meta.reference);
930
+ if (meta.mandatory === 'true') tooltipData.push('Mandatory: Yes');
931
+ if (meta.read_only === 'true') tooltipData.push('Read Only: Yes');
932
+ if (meta.default_value) tooltipData.push('Default: ' + meta.default_value);
933
+
934
+ badge.addEventListener('mouseenter', function() {
935
+ showFieldTooltip(badge, tooltipData);
936
+ });
937
+ badge.addEventListener('mouseleave', function() {
938
+ hideFieldTooltip();
939
+ });
940
+ });
941
+ });
942
+ }
943
+
944
+ /**
945
+ * Fetch field metadata from sys_dictionary for a given table.
946
+ * Caches results per table so only one request is made per table per page load.
947
+ */
948
+ function fetchFieldMetadata(tableName, callback) {
949
+ if (fieldMetadataCache[tableName]) {
950
+ callback(fieldMetadataCache[tableName]);
951
+ return;
952
+ }
953
+
954
+ const url = instanceUrl + '/api/now/table/sys_dictionary'
955
+ + '?sysparm_query=name=' + encodeURIComponent(tableName)
956
+ + '&sysparm_fields=element,internal_type,max_length,reference,mandatory,read_only,default_value,column_label'
957
+ + '&sysparm_limit=500'
958
+ + '&sysparm_display_value=all';
959
+
960
+ const xhr = new XMLHttpRequest();
961
+ xhr.open('GET', url, true);
962
+ xhr.setRequestHeader('Accept', 'application/json');
963
+ xhr.setRequestHeader('X-UserToken', getSecurityToken());
964
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
965
+ xhr.onreadystatechange = function() {
966
+ if (xhr.readyState !== 4) return;
967
+ if (xhr.status === 200) {
968
+ try {
969
+ const data = JSON.parse(xhr.responseText);
970
+ const results = (data.result || []).map(function(r) {
971
+ return {
972
+ element: r.element && r.element.display_value ? r.element.display_value : (r.element || ''),
973
+ internal_type: r.internal_type && r.internal_type.value ? r.internal_type.value : (r.internal_type || ''),
974
+ internal_type_display: r.internal_type && r.internal_type.display_value ? r.internal_type.display_value : '',
975
+ max_length: r.max_length && r.max_length.display_value ? r.max_length.display_value : (r.max_length || ''),
976
+ reference: r.reference && r.reference.display_value ? r.reference.display_value : (r.reference || ''),
977
+ mandatory: r.mandatory && r.mandatory.value ? r.mandatory.value : (r.mandatory || ''),
978
+ read_only: r.read_only && r.read_only.value ? r.read_only.value : (r.read_only || ''),
979
+ default_value: r.default_value && r.default_value.display_value ? r.default_value.display_value : (r.default_value || ''),
980
+ column_label: r.column_label && r.column_label.display_value ? r.column_label.display_value : (r.column_label || ''),
981
+ };
982
+ }).filter(function(r) { return r.element; });
983
+
984
+ fieldMetadataCache[tableName] = results;
985
+ callback(results);
986
+ } catch(e) {
987
+ callback([]);
988
+ }
989
+ } else {
990
+ callback([]);
991
+ }
992
+ };
993
+ xhr.send();
994
+ }
995
+
996
+ /**
997
+ * Get the ServiceNow g_ck security token for API requests.
998
+ *
999
+ * Content scripts run in an isolated JavaScript world — they CANNOT access
1000
+ * the page's window.g_ck directly. We use multiple strategies:
1001
+ * 1. Read from our injected bridge element (most reliable)
1002
+ * 2. Read from SN-Utils bridge element (if installed)
1003
+ * 3. Parse from inline <script> tags in the DOM
1004
+ */
1005
+ function getSecurityToken() {
1006
+ // Method 1: Our injected bridge element (set by injectGckBridge)
1007
+ var bridgeEl = document.getElementById('nowaikit-gck');
1008
+ if (bridgeEl && bridgeEl.value) return bridgeEl.value;
1009
+
1010
+ // Method 2: SN-Utils bridge element (if that extension is installed)
1011
+ var snuEl = document.getElementById('sn_gck');
1012
+ if (snuEl && snuEl.value) return snuEl.value;
1013
+
1014
+ // Method 3: Parse from inline <script> tags (works for classic UI)
1015
+ var scripts = document.querySelectorAll('script');
1016
+ for (var i = 0; i < scripts.length; i++) {
1017
+ var text = scripts[i].textContent || '';
1018
+ var match = text.match(/var\s+g_ck\s*=\s*['"]([^'"]+)['"]/);
1019
+ if (match && match[1]) return match[1];
1020
+ }
1021
+
1022
+ // Method 4: Meta tag (some Next Experience versions)
1023
+ var metaEl = document.querySelector('meta[name="g_ck"], meta[name="_ck"]');
1024
+ if (metaEl && metaEl.getAttribute('content')) return metaEl.getAttribute('content');
1025
+
1026
+ console.warn('[NowAIKit] g_ck token not found — API calls may fail');
1027
+ return '';
1028
+ }
1029
+
1030
+ /**
1031
+ * Inject a micro-script into the PAGE context to extract g_ck.
1032
+ * Content scripts cannot access page JS variables directly, so we inject
1033
+ * a script that reads g_ck and stores it in a hidden DOM element.
1034
+ */
1035
+ function injectGckBridge() {
1036
+ // Don't inject if already present
1037
+ if (document.getElementById('nowaikit-gck')) return;
1038
+
1039
+ var script = document.createElement('script');
1040
+ script.textContent = '(function(){' +
1041
+ 'try{' +
1042
+ 'var el=document.createElement("input");' +
1043
+ 'el.type="hidden";' +
1044
+ 'el.id="nowaikit-gck";' +
1045
+ 'el.value=(typeof g_ck!=="undefined"&&g_ck)?g_ck:"";' +
1046
+ 'document.documentElement.appendChild(el);' +
1047
+ // Also listen for g_ck changes (e.g. after page framework init)
1048
+ 'if(!el.value){' +
1049
+ 'var _t=setInterval(function(){' +
1050
+ 'if(typeof g_ck!=="undefined"&&g_ck){' +
1051
+ 'el.value=g_ck;clearInterval(_t);' +
1052
+ '}' +
1053
+ '},2000);' +
1054
+ 'setTimeout(function(){clearInterval(_t);},8000);' +
1055
+ '}' +
1056
+ '}catch(e){}' +
1057
+ '})();';
1058
+ (document.head || document.documentElement).appendChild(script);
1059
+ script.remove(); // Clean up — the script already executed
1060
+ }
1061
+
1062
+ /** Active tooltip element */
1063
+ let activeTooltip = null;
1064
+
1065
+ function showFieldTooltip(anchor, lines) {
1066
+ hideFieldTooltip();
1067
+
1068
+ const tooltip = document.createElement('div');
1069
+ tooltip.className = 'nowaikit-field-tooltip';
1070
+ tooltip.innerHTML = lines.map(function(line) {
1071
+ const parts = line.split(': ');
1072
+ return '<div class="nowaikit-tooltip-row">'
1073
+ + '<span class="nowaikit-tooltip-key">' + escapeHtml(parts[0]) + '</span>'
1074
+ + '<span class="nowaikit-tooltip-val">' + escapeHtml(parts.slice(1).join(': ')) + '</span>'
1075
+ + '</div>';
1076
+ }).join('');
1077
+
1078
+ document.body.appendChild(tooltip);
1079
+ activeTooltip = tooltip;
1080
+
1081
+ // Position below the badge
1082
+ const rect = anchor.getBoundingClientRect();
1083
+ tooltip.style.top = (rect.bottom + window.scrollY + 4) + 'px';
1084
+ tooltip.style.left = (rect.left + window.scrollX) + 'px';
1085
+
1086
+ // Ensure tooltip doesn't overflow viewport right edge
1087
+ requestAnimationFrame(function() {
1088
+ if (!activeTooltip) return;
1089
+ const tRect = tooltip.getBoundingClientRect();
1090
+ if (tRect.right > window.innerWidth - 8) {
1091
+ tooltip.style.left = (window.innerWidth - tRect.width - 8) + 'px';
1092
+ }
1093
+ });
1094
+ }
1095
+
1096
+ function hideFieldTooltip() {
1097
+ if (activeTooltip) {
1098
+ activeTooltip.remove();
1099
+ activeTooltip = null;
1100
+ }
1101
+ }
1102
+
1103
+ // ─── Quick Navigation ──────────────────────────────────────────────────────
1104
+
1105
+ function injectQuickNav() {
1106
+ if (document.getElementById('nowaikit-quicknav')) return; // Already injected
1107
+ const nav = document.createElement('div');
1108
+ nav.id = 'nowaikit-quicknav';
1109
+ nav.innerHTML = '\
1110
+ <input type="text" id="nowaikit-quicknav-input"\
1111
+ placeholder="Table, sys_id, or /command..."\
1112
+ autocomplete="off" />\
1113
+ ';
1114
+ nav.style.display = 'none';
1115
+ document.body.appendChild(nav);
1116
+
1117
+ const input = document.getElementById('nowaikit-quicknav-input');
1118
+ input.addEventListener('keydown', function(e) {
1119
+ if (e.key === 'Escape') {
1120
+ nav.style.display = 'none';
1121
+ return;
1122
+ }
1123
+ if (e.key === 'Enter') {
1124
+ const value = input.value.trim();
1125
+ if (value) {
1126
+ navigateTo(value);
1127
+ }
1128
+ nav.style.display = 'none';
1129
+ input.value = '';
1130
+ }
1131
+ });
1132
+
1133
+ input.addEventListener('blur', function() {
1134
+ setTimeout(function() { nav.style.display = 'none'; }, 200);
1135
+ });
1136
+ }
1137
+
1138
+ function navigateTo(input) {
1139
+ let url = '';
1140
+
1141
+ // ─── Slash Commands ─────────────────────────────────────────
1142
+ if (input.startsWith('/')) {
1143
+ const parts = input.substring(1).split(/\s+/);
1144
+ const cmd = parts[0].toLowerCase();
1145
+ const arg = parts.slice(1).join(' ');
1146
+
1147
+ switch (cmd) {
1148
+ case 'docs':
1149
+ url = 'https://docs.servicenow.com/search?q=' + encodeURIComponent(arg || 'home');
1150
+ window.open(url, '_blank');
1151
+ return;
1152
+ case 'api':
1153
+ if (arg) {
1154
+ url = instanceUrl + '/api/now/table/' + encodeURIComponent(arg) + '?sysparm_limit=1';
1155
+ window.open(url, '_blank');
1156
+ } else {
1157
+ url = instanceUrl + '/api/now/doc/table/schema';
1158
+ window.open(url, '_blank');
1159
+ }
1160
+ return;
1161
+ case 'script':
1162
+ if (arg) {
1163
+ url = instanceUrl + '/nav_to.do?uri=sys_script_list.do?sysparm_query=nameLIKE' + encodeURIComponent(arg) + '^ORsys_script_client.nameLIKE' + encodeURIComponent(arg);
1164
+ } else {
1165
+ url = instanceUrl + '/nav_to.do?uri=sys_script_list.do';
1166
+ }
1167
+ window.open(url, '_blank');
1168
+ return;
1169
+ case 'us':
1170
+ case 'updateset':
1171
+ url = instanceUrl + '/nav_to.do?uri=sys_update_set_list.do?sysparm_query=state=in progress';
1172
+ window.open(url, '_blank');
1173
+ return;
1174
+ case 'node':
1175
+ var nodeInfo = detectNode();
1176
+ showToast(nodeInfo ? 'Node: ' + nodeInfo : 'Node info not available');
1177
+ return;
1178
+ case 'env':
1179
+ url = instanceUrl + '/stats.do';
1180
+ window.open(url, '_blank');
1181
+ return;
1182
+ case 'diff':
1183
+ if (arg && /^[a-f0-9]{32}$/.test(arg)) {
1184
+ url = instanceUrl + '/sys_update_xml_list.do?sysparm_query=name=' + arg;
1185
+ window.open(url, '_blank');
1186
+ } else {
1187
+ showToast('Usage: /diff <sys_id>');
1188
+ }
1189
+ return;
1190
+ case 'bg':
1191
+ case 'scripts-bg':
1192
+ url = instanceUrl + '/sys.scripts.do';
1193
+ window.open(url, '_blank');
1194
+ return;
1195
+ case 'log':
1196
+ case 'logs':
1197
+ url = instanceUrl + '/syslog_list.do?sysparm_query=level=2^ORlevel=3^ORDERBYDESCsys_created_on';
1198
+ window.open(url, '_blank');
1199
+ return;
1200
+ case 'tables':
1201
+ url = instanceUrl + '/sys_db_object_list.do';
1202
+ window.open(url, '_blank');
1203
+ return;
1204
+ default:
1205
+ showToast('Unknown command: /' + cmd);
1206
+ return;
1207
+ }
1208
+ }
1209
+
1210
+ // If it looks like a sys_id
1211
+ if (/^[a-f0-9]{32}$/.test(input)) {
1212
+ if (currentTable) {
1213
+ url = instanceUrl + '/' + currentTable + '.do?sys_id=' + input;
1214
+ } else {
1215
+ url = instanceUrl + '/sys_metadata.do?sys_id=' + input;
1216
+ }
1217
+ }
1218
+ // If it looks like table.do format
1219
+ else if (input.includes('.do')) {
1220
+ url = instanceUrl + '/' + input;
1221
+ }
1222
+ // If it looks like a table name
1223
+ else if (/^[a-z_]+$/.test(input)) {
1224
+ url = instanceUrl + '/' + input + '_list.do';
1225
+ }
1226
+ // Otherwise, navigate to it
1227
+ else {
1228
+ url = instanceUrl + '/nav_to.do?uri=' + encodeURIComponent(input);
1229
+ }
1230
+
1231
+ window.open(url, '_blank');
1232
+ }
1233
+
1234
+ // ─── Info Overlay ──────────────────────────────────────────────────────────
1235
+
1236
+ function injectInfoOverlay() {
1237
+ if (!currentTable) return;
1238
+
1239
+ const overlay = document.createElement('div');
1240
+ overlay.id = 'nowaikit-info-overlay';
1241
+
1242
+ let html = '\
1243
+ <div class="nowaikit-info-row">\
1244
+ <span class="nowaikit-info-label">Table</span>\
1245
+ <span class="nowaikit-info-value nowaikit-clickable" data-copy="' + escapeHtml(currentTable) + '">' + escapeHtml(currentTable) + '</span>\
1246
+ </div>';
1247
+
1248
+ if (currentSysId) {
1249
+ html += '\
1250
+ <div class="nowaikit-info-row">\
1251
+ <span class="nowaikit-info-label">sys_id</span>\
1252
+ <span class="nowaikit-info-value nowaikit-clickable" data-copy="' + currentSysId + '">' + currentSysId.substring(0, 8) + '...</span>\
1253
+ </div>';
1254
+ }
1255
+
1256
+ html += '\
1257
+ <div class="nowaikit-info-row">\
1258
+ <span class="nowaikit-info-label">Instance</span>\
1259
+ <span class="nowaikit-info-value">' + window.location.hostname.split('.')[0] + '</span>\
1260
+ </div>';
1261
+
1262
+ // 3C. Node detection — add node info row (clickable to open switcher)
1263
+ const nodeInfo = detectNode();
1264
+ html += '\
1265
+ <div class="nowaikit-info-row">\
1266
+ <span class="nowaikit-info-label">Node</span>\
1267
+ <span class="nowaikit-info-value nowaikit-node-value nowaikit-node-switch-trigger" title="Click to switch node">' + escapeHtml(nodeInfo || 'unknown') + '</span>\
1268
+ </div>';
1269
+
1270
+ overlay.innerHTML = html;
1271
+
1272
+ // Make values clickable to copy
1273
+ overlay.querySelectorAll('.nowaikit-clickable').forEach(function(el) {
1274
+ el.addEventListener('click', function() {
1275
+ navigator.clipboard.writeText(el.dataset.copy).then(function() {
1276
+ el.textContent = 'Copied!';
1277
+ setTimeout(function() {
1278
+ el.textContent = el.dataset.copy.length > 20
1279
+ ? el.dataset.copy.substring(0, 8) + '...'
1280
+ : el.dataset.copy;
1281
+ }, 1000);
1282
+ }).catch(function() { /* clipboard denied */ });
1283
+ });
1284
+ });
1285
+
1286
+ // Make node value clickable to open node switcher
1287
+ var nodeSwitch = overlay.querySelector('.nowaikit-node-switch-trigger');
1288
+ if (nodeSwitch) {
1289
+ nodeSwitch.style.cursor = 'pointer';
1290
+ nodeSwitch.addEventListener('click', function(e) {
1291
+ e.stopPropagation();
1292
+ openNodeSwitcher();
1293
+ });
1294
+ }
1295
+
1296
+ document.body.appendChild(overlay);
1297
+ }
1298
+
1299
+ // ─── 3C. Enhanced Node Switcher ───────────────────────────────────────────
1300
+
1301
+ /**
1302
+ * Detects the ServiceNow application node serving this page.
1303
+ * Uses multiple sources:
1304
+ * 1. glide_node cookie
1305
+ * 2. Performance API Server-Timing header
1306
+ * 3. X-ServiceNow-Node response header (via performance entries)
1307
+ */
1308
+ function detectNode() {
1309
+ let node = '';
1310
+
1311
+ // Method 1: glide_user_route cookie (correct ServiceNow cookie)
1312
+ // Value format: "glide.{nodeId}" — strip the "glide." prefix
1313
+ node = getCookieValue('glide_user_route');
1314
+ if (node) {
1315
+ return node.indexOf('glide.') === 0 ? node.substring(6) : node;
1316
+ }
1317
+
1318
+ // Method 2: glide_node cookie (legacy)
1319
+ node = getCookieValue('glide_node');
1320
+ if (node) return node;
1321
+
1322
+ // Method 3: glide.node cookie (alternate naming)
1323
+ node = getCookieValue('glide.node');
1324
+ if (node) return node;
1325
+
1326
+ // Method 4: Performance API — Server-Timing header
1327
+ if (window.performance && typeof window.performance.getEntriesByType === 'function') {
1328
+ try {
1329
+ const navEntries = window.performance.getEntriesByType('navigation');
1330
+ for (let i = 0; i < navEntries.length; i++) {
1331
+ const entry = navEntries[i];
1332
+ if (entry.serverTiming && entry.serverTiming.length > 0) {
1333
+ for (let j = 0; j < entry.serverTiming.length; j++) {
1334
+ const st = entry.serverTiming[j];
1335
+ if (st.name === 'glide_node' || st.name === 'node') {
1336
+ return st.description || st.name;
1337
+ }
1338
+ }
1339
+ }
1340
+ }
1341
+ } catch(e) { /* ignore */ }
1342
+ }
1343
+
1344
+ // Method 4: Performance API — resource entries with server timing
1345
+ if (window.performance && typeof window.performance.getEntriesByType === 'function') {
1346
+ try {
1347
+ const resources = window.performance.getEntriesByType('resource');
1348
+ for (let i = resources.length - 1; i >= 0 && i >= resources.length - 10; i--) {
1349
+ const entry = resources[i];
1350
+ if (entry.serverTiming && entry.serverTiming.length > 0) {
1351
+ for (let j = 0; j < entry.serverTiming.length; j++) {
1352
+ var st = entry.serverTiming[j];
1353
+ if (st.name === 'glide_node' || st.name === 'node') {
1354
+ return st.description || st.name;
1355
+ }
1356
+ }
1357
+ }
1358
+ }
1359
+ } catch(e) { /* ignore */ }
1360
+ }
1361
+
1362
+ // Method 5: Check for node info in page (sometimes exposed in system diagnostics)
1363
+ var nodeEl = document.querySelector('[data-node], .instance-node');
1364
+ if (nodeEl) {
1365
+ return nodeEl.getAttribute('data-node') || nodeEl.textContent.trim();
1366
+ }
1367
+
1368
+ return '';
1369
+ }
1370
+
1371
+ function getCookieValue(name) {
1372
+ const cookies = document.cookie.split(';');
1373
+ for (let i = 0; i < cookies.length; i++) {
1374
+ const c = cookies[i].trim();
1375
+ if (c.indexOf(name + '=') === 0) {
1376
+ return decodeURIComponent(c.substring(name.length + 1));
1377
+ }
1378
+ }
1379
+ return '';
1380
+ }
1381
+
1382
+ // ─── Node Switcher ──────────────────────────────────────────────────────
1383
+
1384
+ /**
1385
+ * Fetches available application nodes from sys_cluster_state.
1386
+ * Uses three strategies: sys_cluster_state API → stats.do JSON → xmlstats.do XML
1387
+ * @param {function} callback - Called with array of node objects
1388
+ */
1389
+ function fetchAvailableNodes(callback) {
1390
+ // Delay slightly to allow g_ck bridge to initialize
1391
+ var token = getSecurityToken();
1392
+ if (!token) {
1393
+ console.warn('[NowAIKit] No security token yet, retrying in 1s...');
1394
+ setTimeout(function() {
1395
+ var retryToken = getSecurityToken();
1396
+ if (!retryToken) {
1397
+ console.warn('[NowAIKit] No security token available for node discovery');
1398
+ callback([]);
1399
+ return;
1400
+ }
1401
+ doFetchNodes(retryToken, callback);
1402
+ }, 1000);
1403
+ return;
1404
+ }
1405
+ doFetchNodes(token, callback);
1406
+ }
1407
+
1408
+ function doFetchNodes(token, callback) {
1409
+ // Strategy 1: Query sys_cluster_state REST API (same as SN-Utils)
1410
+ var url = instanceUrl + '/api/now/table/sys_cluster_state'
1411
+ + '?sysparm_query=ORDERBYsystem_id'
1412
+ + '&sysparm_fields=system_id,node_id,status,node_type'
1413
+ + '&sysparm_display_value=true'
1414
+ + '&sysparm_exclude_reference_link=true'
1415
+ + '&sysparm_limit=50';
1416
+
1417
+ var xhr = new XMLHttpRequest();
1418
+ xhr.open('GET', url, true);
1419
+ xhr.setRequestHeader('Accept', 'application/json');
1420
+ xhr.setRequestHeader('X-UserToken', token);
1421
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1422
+ xhr.onreadystatechange = function() {
1423
+ if (xhr.readyState !== 4) return;
1424
+
1425
+ console.info('[NowAIKit] sys_cluster_state response: HTTP ' + xhr.status);
1426
+
1427
+ if (xhr.status === 200) {
1428
+ try {
1429
+ var data = JSON.parse(xhr.responseText);
1430
+ var results = data.result || [];
1431
+ if (results.length > 0) {
1432
+ var nodes = results.map(function(r) {
1433
+ return {
1434
+ systemId: r.system_id || '',
1435
+ nodeId: r.node_id || r.system_id || '',
1436
+ status: r.status || 'online',
1437
+ nodeType: r.node_type || '',
1438
+ lastTransaction: '',
1439
+ discoveredVia: 'sys_cluster_state API',
1440
+ };
1441
+ });
1442
+ console.info('[NowAIKit] Discovered ' + nodes.length + ' node(s) via sys_cluster_state API');
1443
+ callback(nodes);
1444
+ return;
1445
+ } else {
1446
+ console.warn('[NowAIKit] sys_cluster_state returned 0 results');
1447
+ }
1448
+ } catch(e) {
1449
+ console.warn('[NowAIKit] sys_cluster_state parse error:', e.message || e);
1450
+ }
1451
+ } else if (xhr.status === 401 || xhr.status === 403) {
1452
+ console.warn('[NowAIKit] sys_cluster_state: Access denied (HTTP ' + xhr.status + '). Are you an admin? g_ck token length: ' + (token ? token.length : 0));
1453
+ } else if (xhr.status !== 0) {
1454
+ console.warn('[NowAIKit] sys_cluster_state query failed: HTTP ' + xhr.status);
1455
+ }
1456
+
1457
+ // Strategy 2: Fallback — parse stats.do HTML
1458
+ fetchNodesFromStatsDo(callback);
1459
+ };
1460
+ xhr.send();
1461
+ }
1462
+
1463
+ /**
1464
+ * Strategy 2: Fetch node info from stats.do HTML page.
1465
+ * stats.do returns an HTML page with "Node ID: xxx" and "IP address: x.x.x.x".
1466
+ * This only shows the CURRENT node — not all cluster nodes.
1467
+ * Used as fallback info when sys_cluster_state fails.
1468
+ * @param {function} callback - Called with array of node objects
1469
+ */
1470
+ function fetchNodesFromStatsDo(callback) {
1471
+ var url = instanceUrl + '/stats.do';
1472
+ var xhr = new XMLHttpRequest();
1473
+ xhr.open('GET', url, true);
1474
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1475
+ xhr.onreadystatechange = function() {
1476
+ if (xhr.readyState !== 4) return;
1477
+ if (xhr.status === 200 && xhr.responseText) {
1478
+ var html = xhr.responseText;
1479
+ var nodes = [];
1480
+
1481
+ // Parse Node ID from stats.do HTML (format: "Node ID: xxx<br/>")
1482
+ var nodeIdMatch = html.match(/Node ID:\s*([\s\S]*?)\s*<br/i);
1483
+ var nodeNameMatch = html.match(/Connected to cluster node:\s*([\s\S]*?)\s*<br/i);
1484
+
1485
+ if (nodeIdMatch && nodeIdMatch[1]) {
1486
+ var nodeId = nodeIdMatch[1].replace(/<[^>]*>/g, '').trim();
1487
+ var nodeName = nodeNameMatch ? nodeNameMatch[1].replace(/<[^>]*>/g, '').trim() : nodeId;
1488
+ nodes.push({
1489
+ systemId: nodeName || nodeId,
1490
+ nodeId: nodeId,
1491
+ status: 'online',
1492
+ nodeType: '',
1493
+ lastTransaction: '',
1494
+ discoveredVia: 'stats.do (current node only)',
1495
+ });
1496
+ console.info('[NowAIKit] Found current node via stats.do: ' + nodeId);
1497
+ }
1498
+
1499
+ if (nodes.length > 0) {
1500
+ callback(nodes);
1501
+ return;
1502
+ }
1503
+ }
1504
+ if (xhr.status !== 0 && xhr.status !== 200) {
1505
+ console.warn('[NowAIKit] stats.do query failed: HTTP ' + xhr.status);
1506
+ }
1507
+ // Strategy 3: Fallback — try xmlstats.do
1508
+ fetchNodesFromXmlStats(callback);
1509
+ };
1510
+ xhr.send();
1511
+ }
1512
+
1513
+ /**
1514
+ * Strategy 3: Fallback node discovery using xmlstats.do.
1515
+ * Parses the XML response to extract node IDs.
1516
+ */
1517
+ function fetchNodesFromXmlStats(callback) {
1518
+ var url = instanceUrl + '/xmlstats.do';
1519
+ var xhr = new XMLHttpRequest();
1520
+ xhr.open('GET', url, true);
1521
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1522
+ xhr.onreadystatechange = function() {
1523
+ if (xhr.readyState !== 4) return;
1524
+ if (xhr.status === 200 && xhr.responseText) {
1525
+ try {
1526
+ var nodes = parseXmlStatsNodes(xhr.responseText);
1527
+ if (nodes.length > 0) {
1528
+ console.info('[NowAIKit] Discovered ' + nodes.length + ' node(s) via xmlstats.do');
1529
+ callback(nodes);
1530
+ return;
1531
+ }
1532
+ } catch(e) {
1533
+ console.warn('[NowAIKit] xmlstats.do parse error:', e.message || e);
1534
+ }
1535
+ } else if (xhr.status !== 0) {
1536
+ console.warn('[NowAIKit] xmlstats.do query failed: HTTP ' + xhr.status);
1537
+ }
1538
+ // Strategy 4: Final fallback — build list from current node only
1539
+ // User can still manually enter node IDs
1540
+ var currentNode = detectNode();
1541
+ if (currentNode) {
1542
+ console.info('[NowAIKit] Using current node as fallback: ' + currentNode);
1543
+ callback([{
1544
+ systemId: currentNode,
1545
+ nodeId: currentNode,
1546
+ status: 'online',
1547
+ nodeType: '',
1548
+ lastTransaction: '',
1549
+ discoveredVia: 'current node detection',
1550
+ }]);
1551
+ } else {
1552
+ console.warn('[NowAIKit] No nodes discovered via any strategy');
1553
+ callback([]);
1554
+ }
1555
+ };
1556
+ xhr.send();
1557
+ }
1558
+
1559
+ /**
1560
+ * Parse xmlstats.do response to extract node information.
1561
+ * xmlstats.do returns XML with various node/servlet elements depending on version.
1562
+ */
1563
+ function parseXmlStatsNodes(xmlText) {
1564
+ var nodes = [];
1565
+ var seen = {};
1566
+ try {
1567
+ var parser = new DOMParser();
1568
+ var doc = parser.parseFromString(xmlText, 'text/xml');
1569
+
1570
+ // Check for parse errors
1571
+ var parseError = doc.querySelector('parsererror');
1572
+ if (parseError) {
1573
+ console.warn('[NowAIKit] xmlstats.do returned invalid XML, falling back to regex');
1574
+ } else {
1575
+ // Look for elements with node info — expanded selectors for different SN versions
1576
+ var selectors = 'node, servlet_info, stats, cluster_node, node_stats, servlet, system_node, cluster_state';
1577
+ var nodeEls = doc.querySelectorAll(selectors);
1578
+ for (var i = 0; i < nodeEls.length; i++) {
1579
+ var el = nodeEls[i];
1580
+ var sysId = el.getAttribute('system_id')
1581
+ || el.getAttribute('node_id')
1582
+ || el.getAttribute('name')
1583
+ || el.getAttribute('id')
1584
+ || el.getAttribute('node_name')
1585
+ || '';
1586
+
1587
+ // Also check child elements for system_id
1588
+ if (!sysId) {
1589
+ var sysIdEl = el.querySelector('system_id, node_id');
1590
+ if (sysIdEl) sysId = sysIdEl.textContent.trim();
1591
+ }
1592
+
1593
+ if (sysId && !seen[sysId]) {
1594
+ seen[sysId] = true;
1595
+ nodes.push({
1596
+ systemId: sysId,
1597
+ nodeId: sysId,
1598
+ status: el.getAttribute('status') || 'online',
1599
+ nodeType: el.getAttribute('node_type') || el.getAttribute('type') || '',
1600
+ lastTransaction: '',
1601
+ discoveredVia: 'xmlstats.do',
1602
+ });
1603
+ }
1604
+ }
1605
+
1606
+ // Also scan all elements for system_id attributes (catch-all)
1607
+ if (nodes.length === 0) {
1608
+ var allEls = doc.querySelectorAll('[system_id], [node_id]');
1609
+ for (var k = 0; k < allEls.length; k++) {
1610
+ var attrId = allEls[k].getAttribute('system_id') || allEls[k].getAttribute('node_id') || '';
1611
+ if (attrId && !seen[attrId]) {
1612
+ seen[attrId] = true;
1613
+ nodes.push({
1614
+ systemId: attrId,
1615
+ nodeId: attrId,
1616
+ status: 'online',
1617
+ nodeType: '',
1618
+ lastTransaction: '',
1619
+ discoveredVia: 'xmlstats.do',
1620
+ });
1621
+ }
1622
+ }
1623
+ }
1624
+ }
1625
+
1626
+ // Fallback: regex extraction from raw text
1627
+ if (nodes.length === 0) {
1628
+ var patterns = [
1629
+ /system_id[>":\s]+([a-zA-Z0-9._:-]+)/g,
1630
+ /node_id[>":\s]+([a-zA-Z0-9._:-]+)/g,
1631
+ /node_name[>":\s]+([a-zA-Z0-9._:-]+)/g,
1632
+ ];
1633
+ for (var p = 0; p < patterns.length; p++) {
1634
+ var match = xmlText.match(patterns[p]);
1635
+ if (match) {
1636
+ for (var j = 0; j < match.length; j++) {
1637
+ var idMatch = match[j].match(/[>":\s]+([a-zA-Z0-9._:-]+)/);
1638
+ if (idMatch && idMatch[1] && !seen[idMatch[1]]) {
1639
+ seen[idMatch[1]] = true;
1640
+ nodes.push({
1641
+ systemId: idMatch[1],
1642
+ nodeId: idMatch[1],
1643
+ status: 'online',
1644
+ nodeType: '',
1645
+ lastTransaction: '',
1646
+ discoveredVia: 'xmlstats.do',
1647
+ });
1648
+ }
1649
+ }
1650
+ }
1651
+ if (nodes.length > 0) break;
1652
+ }
1653
+ }
1654
+ } catch(e) {
1655
+ console.warn('[NowAIKit] xmlstats.do parse exception:', e.message || e);
1656
+ }
1657
+ return nodes;
1658
+ }
1659
+
1660
+ /**
1661
+ * Switches to a specific application node by setting glide_user_route cookie
1662
+ * and optionally the F5 BIG-IP load balancer cookie.
1663
+ *
1664
+ * Handles three load-balancer scenarios:
1665
+ * 1. On-prem / no LB: set glide_user_route only
1666
+ * 2. Classic F5 BIG-IP: set BIGipServerpool (encoded IP.port) + glide_user_route
1667
+ * 3. ADCv2: set BIGipServerpool (md5 of ip:port) + glide_user_route
1668
+ *
1669
+ * All cookies set via chrome.cookies API (httpOnly, secure).
1670
+ *
1671
+ * @param {string} nodeId - The target node_id
1672
+ */
1673
+ function switchToNode(nodeId) {
1674
+ var origin = window.location.origin;
1675
+
1676
+ // Get current IP info from stats.do and check for F5 load balancer
1677
+ fetchStatsDoInfo(function(statsInfo) {
1678
+ chrome.runtime.sendMessage({ action: 'getAllCookies', url: origin }, function(resp) {
1679
+ if (chrome.runtime.lastError || !resp || !resp.cookies) {
1680
+ applyRouteAndReload(origin, nodeId);
1681
+ return;
1682
+ }
1683
+
1684
+ // Find BIGipServerpool cookie — indicates F5 LB
1685
+ var lbCookie = null;
1686
+ for (var i = 0; i < resp.cookies.length; i++) {
1687
+ if (/^BIGipServer[\w\d]+$/.test(resp.cookies[i].name)) {
1688
+ lbCookie = resp.cookies[i];
1689
+ break;
1690
+ }
1691
+ }
1692
+
1693
+ if (!lbCookie) {
1694
+ // No F5 — on-prem or direct. Route cookie is sufficient.
1695
+ console.info('[NowAIKit] No F5 LB cookie found, setting route only');
1696
+ applyRouteAndReload(origin, nodeId);
1697
+ return;
1698
+ }
1699
+
1700
+ console.info('[NowAIKit] F5 LB detected: ' + lbCookie.name);
1701
+
1702
+ if (!statsInfo || !statsInfo.ipParts || statsInfo.ipParts.length !== 4) {
1703
+ console.warn('[NowAIKit] Could not parse IP from stats.do, setting route only');
1704
+ applyRouteAndReload(origin, nodeId);
1705
+ return;
1706
+ }
1707
+
1708
+ // Fetch servlet port from sys_cluster_node_stats for the target node
1709
+ fetchServletPort(nodeId, function(port) {
1710
+ if (!port) {
1711
+ console.warn('[NowAIKit] Could not get servlet port, setting route only');
1712
+ applyRouteAndReload(origin, nodeId);
1713
+ return;
1714
+ }
1715
+
1716
+ var lbValue;
1717
+ var ipArr = statsInfo.ipParts;
1718
+
1719
+ if (lbCookie.value && !lbCookie.value.endsWith('.0000')) {
1720
+ // ADCv2 scheme — cookie value is md5(ip:port)
1721
+ var fullIp = ipArr.join('.');
1722
+ lbValue = nowaikitMd5(fullIp + ':' + port);
1723
+ console.info('[NowAIKit] ADCv2 encoding: md5(' + fullIp + ':' + port + ')');
1724
+ } else {
1725
+ // Classic F5 scheme — little-endian IP + swapped port
1726
+ var encodedIP = 0;
1727
+ for (var i = 0; i < 4; i++) {
1728
+ encodedIP += parseInt(ipArr[i], 10) * Math.pow(256, i);
1729
+ }
1730
+ var encodedPort = Math.floor(port / 256) + (port % 256) * 256;
1731
+ lbValue = encodedIP + '.' + encodedPort + '.0000';
1732
+ console.info('[NowAIKit] Classic F5 encoding: ' + lbValue);
1733
+ }
1734
+
1735
+ // Set LB cookie first, then route cookie, then reload
1736
+ chrome.runtime.sendMessage({
1737
+ action: 'setCookie', url: origin, name: lbCookie.name,
1738
+ value: lbValue, path: '/', httpOnly: true, secure: true,
1739
+ }, function() {
1740
+ applyRouteAndReload(origin, nodeId);
1741
+ });
1742
+ });
1743
+ });
1744
+ });
1745
+ }
1746
+
1747
+ /** Set glide_user_route and reload page */
1748
+ function applyRouteAndReload(origin, nodeId) {
1749
+ chrome.runtime.sendMessage({
1750
+ action: 'setCookie', url: origin, name: 'glide_user_route',
1751
+ value: 'glide.' + nodeId, path: '/', httpOnly: true, secure: true,
1752
+ }, function() {
1753
+ if (chrome.runtime.lastError) {
1754
+ console.warn('[NowAIKit] setCookie error:', chrome.runtime.lastError.message);
1755
+ }
1756
+ window.location.reload();
1757
+ });
1758
+ }
1759
+
1760
+ /** MD5 hash for ADCv2 F5 cookie encoding (Joseph Myers implementation) */
1761
+ function nowaikitMd5(str) {
1762
+ function md5cycle(x, k) {
1763
+ var a = x[0], b = x[1], c = x[2], d = x[3];
1764
+ a=ff(a,b,c,d,k[0],7,-680876936);d=ff(d,a,b,c,k[1],12,-389564586);c=ff(c,d,a,b,k[2],17,606105819);b=ff(b,c,d,a,k[3],22,-1044525330);
1765
+ a=ff(a,b,c,d,k[4],7,-176418897);d=ff(d,a,b,c,k[5],12,1200080426);c=ff(c,d,a,b,k[6],17,-1473231341);b=ff(b,c,d,a,k[7],22,-45705983);
1766
+ a=ff(a,b,c,d,k[8],7,1770035416);d=ff(d,a,b,c,k[9],12,-1958414417);c=ff(c,d,a,b,k[10],17,-42063);b=ff(b,c,d,a,k[11],22,-1990404162);
1767
+ a=ff(a,b,c,d,k[12],7,1804603682);d=ff(d,a,b,c,k[13],12,-40341101);c=ff(c,d,a,b,k[14],17,-1502002290);b=ff(b,c,d,a,k[15],22,1236535329);
1768
+ a=gg(a,b,c,d,k[1],5,-165796510);d=gg(d,a,b,c,k[6],9,-1069501632);c=gg(c,d,a,b,k[11],14,643717713);b=gg(b,c,d,a,k[0],20,-373897302);
1769
+ a=gg(a,b,c,d,k[5],5,-701558691);d=gg(d,a,b,c,k[10],9,38016083);c=gg(c,d,a,b,k[15],14,-660478335);b=gg(b,c,d,a,k[4],20,-405537848);
1770
+ a=gg(a,b,c,d,k[9],5,568446438);d=gg(d,a,b,c,k[14],9,-1019803690);c=gg(c,d,a,b,k[3],14,-187363961);b=gg(b,c,d,a,k[8],20,1163531501);
1771
+ a=gg(a,b,c,d,k[13],5,-1444681467);d=gg(d,a,b,c,k[2],9,-51403784);c=gg(c,d,a,b,k[7],14,1735328473);b=gg(b,c,d,a,k[12],20,-1926607734);
1772
+ a=hh(a,b,c,d,k[5],4,-378558);d=hh(d,a,b,c,k[8],11,-2022574463);c=hh(c,d,a,b,k[11],16,1839030562);b=hh(b,c,d,a,k[14],23,-35309556);
1773
+ a=hh(a,b,c,d,k[1],4,-1530992060);d=hh(d,a,b,c,k[4],11,1272893353);c=hh(c,d,a,b,k[7],16,-155497632);b=hh(b,c,d,a,k[10],23,-1094730640);
1774
+ a=hh(a,b,c,d,k[13],4,681279174);d=hh(d,a,b,c,k[0],11,-358537222);c=hh(c,d,a,b,k[3],16,-722521979);b=hh(b,c,d,a,k[6],23,76029189);
1775
+ a=hh(a,b,c,d,k[9],4,-640364487);d=hh(d,a,b,c,k[12],11,-421815835);c=hh(c,d,a,b,k[15],16,530742520);b=hh(b,c,d,a,k[2],23,-995338651);
1776
+ a=ii(a,b,c,d,k[0],6,-198630844);d=ii(d,a,b,c,k[7],10,1126891415);c=ii(c,d,a,b,k[14],15,-1416354905);b=ii(b,c,d,a,k[5],21,-57434055);
1777
+ a=ii(a,b,c,d,k[12],6,1700485571);d=ii(d,a,b,c,k[3],10,-1894986606);c=ii(c,d,a,b,k[10],15,-1051523);b=ii(b,c,d,a,k[1],21,-2054922799);
1778
+ a=ii(a,b,c,d,k[8],6,1873313359);d=ii(d,a,b,c,k[15],10,-30611744);c=ii(c,d,a,b,k[6],15,-1560198380);b=ii(b,c,d,a,k[13],21,1309151649);
1779
+ a=ii(a,b,c,d,k[4],6,-145523070);d=ii(d,a,b,c,k[11],10,-1120210379);c=ii(c,d,a,b,k[2],15,718787259);b=ii(b,c,d,a,k[9],21,-343485551);
1780
+ x[0]=ad(a,x[0]);x[1]=ad(b,x[1]);x[2]=ad(c,x[2]);x[3]=ad(d,x[3]);
1781
+ }
1782
+ function cmn(q,a,b,x,s,t){a=ad(ad(a,q),ad(x,t));return ad((a<<s)|(a>>>(32-s)),b);}
1783
+ function ff(a,b,c,d,x,s,t){return cmn((b&c)|((~b)&d),a,b,x,s,t);}
1784
+ function gg(a,b,c,d,x,s,t){return cmn((b&d)|(c&(~d)),a,b,x,s,t);}
1785
+ function hh(a,b,c,d,x,s,t){return cmn(b^c^d,a,b,x,s,t);}
1786
+ function ii(a,b,c,d,x,s,t){return cmn(c^(b|(~d)),a,b,x,s,t);}
1787
+ function ad(a,b){return(a+b)&0xFFFFFFFF;}
1788
+ var hex='0123456789abcdef'.split('');
1789
+ function rh(n){var s='';for(var j=0;j<4;j++)s+=hex[(n>>(j*8+4))&0xF]+hex[(n>>(j*8))&0xF];return s;}
1790
+ var n=((str.length+8)>>>6<<4)+16,bl=new Array(n);
1791
+ for(var i=0;i<n;i++)bl[i]=0;
1792
+ for(var i=0;i<str.length;i++)bl[i>>2]|=str.charCodeAt(i)<<((i%4)<<3);
1793
+ bl[str.length>>2]|=0x80<<((str.length%4)<<3);bl[n-2]=str.length*8;
1794
+ var st=[1732584193,-271733879,-1732584194,271733878];
1795
+ for(var i=0;i<bl.length;i+=16)md5cycle(st,bl.slice(i,i+16));
1796
+ return rh(st[0])+rh(st[1])+rh(st[2])+rh(st[3]);
1797
+ }
1798
+
1799
+ /**
1800
+ * Fetch the servlet port for a target node from sys_cluster_node_stats.
1801
+ * The stats field contains XML with servlet.port inside.
1802
+ *
1803
+ * @param {string} nodeId - The target node ID
1804
+ * @param {function} callback - Called with port number or null
1805
+ */
1806
+ function fetchServletPort(nodeId, callback) {
1807
+ var token = getSecurityToken();
1808
+ if (!token) { callback(null); return; }
1809
+
1810
+ var url = instanceUrl + '/api/now/table/sys_cluster_node_stats'
1811
+ + '?sysparm_query=node_id=' + encodeURIComponent(nodeId)
1812
+ + '&sysparm_fields=stats'
1813
+ + '&sysparm_limit=1';
1814
+
1815
+ var xhr = new XMLHttpRequest();
1816
+ xhr.open('GET', url, true);
1817
+ xhr.setRequestHeader('Accept', 'application/json');
1818
+ xhr.setRequestHeader('X-UserToken', token);
1819
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1820
+ xhr.onreadystatechange = function() {
1821
+ if (xhr.readyState !== 4) return;
1822
+ if (xhr.status === 200) {
1823
+ try {
1824
+ var data = JSON.parse(xhr.responseText);
1825
+ var statsXml = (data.result || [])[0]?.stats;
1826
+ if (statsXml) {
1827
+ // Parse XML to find servlet.port element
1828
+ var parser = new DOMParser();
1829
+ var doc = parser.parseFromString(statsXml, 'text/xml');
1830
+ var portEl = doc.querySelector('servlet\\.port');
1831
+ if (portEl) {
1832
+ var port = parseInt(portEl.textContent, 10);
1833
+ if (port > 0) {
1834
+ callback(port);
1835
+ return;
1836
+ }
1837
+ }
1838
+ }
1839
+ } catch(e) {
1840
+ console.warn('[NowAIKit] sys_cluster_node_stats parse error:', e.message);
1841
+ }
1842
+ }
1843
+ callback(null);
1844
+ };
1845
+ xhr.send();
1846
+ }
1847
+
1848
+ /**
1849
+ * Fetch IP address and node info from stats.do HTML page.
1850
+ * SN returns: "IP address: x.x.x.x<br/>" and "Node ID: xxx<br/>"
1851
+ *
1852
+ * @param {function} callback - Called with { ipParts: string[], nodeId: string } or null
1853
+ */
1854
+ function fetchStatsDoInfo(callback) {
1855
+ var xhr = new XMLHttpRequest();
1856
+ xhr.open('GET', instanceUrl + '/stats.do', true);
1857
+ xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
1858
+ xhr.onreadystatechange = function() {
1859
+ if (xhr.readyState !== 4) return;
1860
+ if (xhr.status === 200 && xhr.responseText) {
1861
+ var html = xhr.responseText.replace(/<br\s*\/?\s*>/gi, '<br/>');
1862
+ // Parse IP address: "IP address: 10.0.1.100<br/>"
1863
+ var ipMatch = html.match(/IP address:\s*([\d.]+)\s*<br\/>/i);
1864
+ var nodeIdMatch = html.match(/Node ID:\s*([\s\S]*?)\s*<br\/>/i);
1865
+
1866
+ if (ipMatch && ipMatch[1]) {
1867
+ callback({
1868
+ ipParts: ipMatch[1].split('.'),
1869
+ nodeId: nodeIdMatch ? nodeIdMatch[1].replace(/<[^>]*>/g, '').trim() : '',
1870
+ });
1871
+ return;
1872
+ }
1873
+ }
1874
+ callback(null);
1875
+ };
1876
+ xhr.send();
1877
+ }
1878
+
1879
+ /**
1880
+ * Opens the node switcher modal.
1881
+ */
1882
+ /** Extract the short display name from a full node/system ID.
1883
+ * e.g. "app1234abcd5678:us-virginia-linux-2" → "us-virginia-linux-2"
1884
+ */
1885
+ function nodeDisplayName(fullId) {
1886
+ if (!fullId) return 'unknown';
1887
+ var idx = fullId.lastIndexOf(':');
1888
+ if (idx !== -1 && idx < fullId.length - 1) return fullId.substring(idx + 1);
1889
+ // Sometimes the name is after the first dot (FQDN style)
1890
+ var dotIdx = fullId.indexOf('.');
1891
+ if (dotIdx > 0) return fullId.substring(0, dotIdx);
1892
+ return fullId;
1893
+ }
1894
+
1895
+ function isCurrentNodeMatch(nodeObj, currentNode) {
1896
+ if (!currentNode) return false;
1897
+ var sId = nodeObj.systemId || '';
1898
+ var nId = nodeObj.nodeId || '';
1899
+ return sId === currentNode || nId === currentNode
1900
+ || (sId && currentNode.indexOf(sId) !== -1)
1901
+ || (nId && currentNode.indexOf(nId) !== -1)
1902
+ || (sId && sId.indexOf(currentNode) !== -1);
1903
+ }
1904
+
1905
+ function openNodeSwitcher() {
1906
+ var existing = document.getElementById('nowaikit-node-switcher');
1907
+ if (existing) { existing.remove(); return; }
1908
+
1909
+ var currentNode = detectNode();
1910
+ var selectedNodeId = null;
1911
+
1912
+ var modal = document.createElement('div');
1913
+ modal.id = 'nowaikit-node-switcher';
1914
+ modal.className = 'nowaikit-node-switcher';
1915
+ modal.innerHTML = '\
1916
+ <div class="nowaikit-node-switcher-panel">\
1917
+ <div class="nowaikit-node-switcher-header">\
1918
+ <span>Node Switcher</span>\
1919
+ <button class="nowaikit-node-switcher-close" title="Close">&times;</button>\
1920
+ </div>\
1921
+ <div class="nowaikit-node-switcher-body">\
1922
+ <div class="nowaikit-node-switcher-loading">\
1923
+ <div class="nowaikit-node-spinner"></div>\
1924
+ Discovering nodes\u2026\
1925
+ </div>\
1926
+ </div>\
1927
+ <div class="nowaikit-node-switcher-footer">\
1928
+ <button class="nowaikit-node-switch-btn" disabled>Select a node</button>\
1929
+ <div class="nowaikit-node-footer-hint">Switching nodes may end your current session</div>\
1930
+ </div>\
1931
+ </div>';
1932
+
1933
+ document.body.appendChild(modal);
1934
+
1935
+ var switchBtn = modal.querySelector('.nowaikit-node-switch-btn');
1936
+
1937
+ // Close handlers
1938
+ modal.querySelector('.nowaikit-node-switcher-close').addEventListener('click', function() {
1939
+ modal.remove();
1940
+ });
1941
+ modal.addEventListener('click', function(e) {
1942
+ if (e.target === modal) modal.remove();
1943
+ });
1944
+ function onEsc(e) {
1945
+ if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', onEsc); }
1946
+ }
1947
+ document.addEventListener('keydown', onEsc);
1948
+
1949
+ // Switch button handler
1950
+ switchBtn.addEventListener('click', function() {
1951
+ if (!selectedNodeId) return;
1952
+ switchBtn.disabled = true;
1953
+ switchBtn.textContent = 'Switching\u2026';
1954
+ switchBtn.classList.add('nowaikit-node-switch-btn-loading');
1955
+ switchToNode(selectedNodeId);
1956
+ });
1957
+
1958
+ // Fetch and render nodes
1959
+ fetchAvailableNodes(function(nodes) {
1960
+ var body = modal.querySelector('.nowaikit-node-switcher-body');
1961
+ if (!body) return;
1962
+
1963
+ // Sort: current node first, then alphabetically by display name
1964
+ nodes.sort(function(a, b) {
1965
+ var aCur = isCurrentNodeMatch(a, currentNode);
1966
+ var bCur = isCurrentNodeMatch(b, currentNode);
1967
+ if (aCur && !bCur) return -1;
1968
+ if (!aCur && bCur) return 1;
1969
+ var aName = nodeDisplayName(a.systemId || a.nodeId);
1970
+ var bName = nodeDisplayName(b.systemId || b.nodeId);
1971
+ return aName.localeCompare(bName);
1972
+ });
1973
+
1974
+ if (nodes.length === 0) {
1975
+ body.innerHTML = '<div class="nowaikit-node-switcher-empty">'
1976
+ + 'No nodes discovered. This may be a single-node instance.'
1977
+ + '</div>';
1978
+ switchBtn.style.display = 'none';
1979
+ return;
1980
+ }
1981
+
1982
+ var html = '';
1983
+ for (var i = 0; i < nodes.length; i++) {
1984
+ var n = nodes[i];
1985
+ var fullId = n.systemId || n.nodeId;
1986
+ var displayName = nodeDisplayName(fullId);
1987
+ var isCurrent = isCurrentNodeMatch(n, currentNode);
1988
+ var nodeType = n.nodeType || '';
1989
+
1990
+ html += '<div class="nowaikit-node-item' + (isCurrent ? ' nowaikit-node-current' : '') + '"'
1991
+ + ' data-node-id="' + escapeHtml(fullId) + '">'
1992
+ + '<div class="nowaikit-node-radio"><div class="nowaikit-node-radio-dot"></div></div>'
1993
+ + '<div class="nowaikit-node-details">'
1994
+ + '<div class="nowaikit-node-name">' + escapeHtml(displayName) + '</div>'
1995
+ + '<div class="nowaikit-node-full-id">' + escapeHtml(fullId)
1996
+ + (nodeType ? ' \u00b7 ' + escapeHtml(nodeType) : '') + '</div>'
1997
+ + '</div>'
1998
+ + (isCurrent
1999
+ ? '<span class="nowaikit-node-badge-active">Active</span>'
2000
+ : '<span class="nowaikit-node-badge-available">Available</span>')
2001
+ + '</div>';
2002
+ }
2003
+
2004
+ body.innerHTML = html;
2005
+
2006
+ // Selection handler
2007
+ var allItems = body.querySelectorAll('.nowaikit-node-item');
2008
+ allItems.forEach(function(el) {
2009
+ el.addEventListener('click', function() {
2010
+ var nodeId = el.getAttribute('data-node-id');
2011
+ var isCur = el.classList.contains('nowaikit-node-current');
2012
+
2013
+ // Deselect all
2014
+ allItems.forEach(function(item) { item.classList.remove('nowaikit-node-selected'); });
2015
+
2016
+ if (isCur) {
2017
+ // Clicking the active node deselects
2018
+ selectedNodeId = null;
2019
+ switchBtn.disabled = true;
2020
+ switchBtn.textContent = 'Select a node';
2021
+ return;
2022
+ }
2023
+
2024
+ // Select this node
2025
+ el.classList.add('nowaikit-node-selected');
2026
+ selectedNodeId = nodeId;
2027
+ switchBtn.disabled = false;
2028
+ switchBtn.textContent = 'Switch to ' + nodeDisplayName(nodeId);
2029
+ });
2030
+ });
2031
+ });
2032
+ }
2033
+
2034
+ /**
2035
+ * Format a ServiceNow timestamp to a short relative/absolute string.
2036
+ */
2037
+ function formatTimestamp(ts) {
2038
+ try {
2039
+ var d = new Date(ts);
2040
+ var now = new Date();
2041
+ var diff = Math.floor((now - d) / 1000);
2042
+ if (diff < 60) return diff + 's ago';
2043
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
2044
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
2045
+ return d.toLocaleDateString();
2046
+ } catch(e) {
2047
+ return ts;
2048
+ }
2049
+ }
2050
+
2051
+ // ─── 3B. Script Syntax Highlighting ───────────────────────────────────────
2052
+
2053
+ /**
2054
+ * Lightweight syntax highlighter for ServiceNow script fields.
2055
+ * Uses an overlay technique — creates a positioned overlay with highlighted
2056
+ * tokens on top of the script textarea, without interfering with native editing.
2057
+ */
2058
+
2059
+ const SN_API_KEYWORDS = [
2060
+ 'GlideRecord', 'g_form', 'gs', 'current', 'previous',
2061
+ 'GlideAjax', 'GlideAggregate', 'GlideDateTime',
2062
+ 'GlideSystem', 'GlideElement', 'GlideSysAttachment',
2063
+ 'GlideFilter', 'GlideSession', 'GlideUser',
2064
+ 'g_list', 'g_user', 'g_navigation', 'g_scratchpad',
2065
+ ];
2066
+
2067
+ const JS_KEYWORDS = [
2068
+ 'var', 'let', 'const', 'function', 'return', 'if', 'else',
2069
+ 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
2070
+ 'try', 'catch', 'finally', 'throw', 'new', 'delete', 'typeof',
2071
+ 'instanceof', 'in', 'of', 'this', 'class', 'extends', 'super',
2072
+ 'import', 'export', 'default', 'true', 'false', 'null', 'undefined',
2073
+ 'void', 'with', 'yield', 'async', 'await',
2074
+ ];
2075
+
2076
+ function highlightSyntax(code) {
2077
+ // Tokenize with a simple regex-based approach
2078
+ // Order matters: comments and strings first, then keywords, then numbers
2079
+ const tokens = [];
2080
+ let remaining = code;
2081
+ let pos = 0;
2082
+
2083
+ while (remaining.length > 0) {
2084
+ let matched = false;
2085
+
2086
+ // Single-line comment
2087
+ var m = remaining.match(/^(\/\/[^\n]*)/);
2088
+ if (m) {
2089
+ tokens.push({ type: 'comment', text: m[1] });
2090
+ remaining = remaining.substring(m[1].length);
2091
+ matched = true;
2092
+ continue;
2093
+ }
2094
+
2095
+ // Multi-line comment
2096
+ m = remaining.match(/^(\/\*[\s\S]*?\*\/)/);
2097
+ if (m) {
2098
+ tokens.push({ type: 'comment', text: m[1] });
2099
+ remaining = remaining.substring(m[1].length);
2100
+ matched = true;
2101
+ continue;
2102
+ }
2103
+
2104
+ // Double-quoted string
2105
+ m = remaining.match(/^("(?:[^"\\]|\\.)*")/);
2106
+ if (m) {
2107
+ tokens.push({ type: 'string', text: m[1] });
2108
+ remaining = remaining.substring(m[1].length);
2109
+ matched = true;
2110
+ continue;
2111
+ }
2112
+
2113
+ // Single-quoted string
2114
+ m = remaining.match(/^('(?:[^'\\]|\\.)*')/);
2115
+ if (m) {
2116
+ tokens.push({ type: 'string', text: m[1] });
2117
+ remaining = remaining.substring(m[1].length);
2118
+ matched = true;
2119
+ continue;
2120
+ }
2121
+
2122
+ // Template literal (backtick)
2123
+ m = remaining.match(/^(`(?:[^`\\]|\\.)*`)/);
2124
+ if (m) {
2125
+ tokens.push({ type: 'string', text: m[1] });
2126
+ remaining = remaining.substring(m[1].length);
2127
+ matched = true;
2128
+ continue;
2129
+ }
2130
+
2131
+ // Number
2132
+ m = remaining.match(/^(\b\d+\.?\d*(?:[eE][+-]?\d+)?\b)/);
2133
+ if (m) {
2134
+ tokens.push({ type: 'number', text: m[1] });
2135
+ remaining = remaining.substring(m[1].length);
2136
+ matched = true;
2137
+ continue;
2138
+ }
2139
+
2140
+ // Word (identifier or keyword)
2141
+ m = remaining.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
2142
+ if (m) {
2143
+ var word = m[1];
2144
+ if (SN_API_KEYWORDS.indexOf(word) !== -1) {
2145
+ tokens.push({ type: 'sn-api', text: word });
2146
+ } else if (JS_KEYWORDS.indexOf(word) !== -1) {
2147
+ tokens.push({ type: 'keyword', text: word });
2148
+ } else {
2149
+ tokens.push({ type: 'plain', text: word });
2150
+ }
2151
+ remaining = remaining.substring(word.length);
2152
+ matched = true;
2153
+ continue;
2154
+ }
2155
+
2156
+ // Anything else — single character
2157
+ tokens.push({ type: 'plain', text: remaining[0] });
2158
+ remaining = remaining.substring(1);
2159
+ }
2160
+
2161
+ // Build highlighted HTML
2162
+ var html = '';
2163
+ for (var i = 0; i < tokens.length; i++) {
2164
+ var t = tokens[i];
2165
+ var escaped = escapeHtml(t.text);
2166
+ switch (t.type) {
2167
+ case 'comment':
2168
+ html += '<span class="nowaikit-comment">' + escaped + '</span>';
2169
+ break;
2170
+ case 'string':
2171
+ html += '<span class="nowaikit-string">' + escaped + '</span>';
2172
+ break;
2173
+ case 'number':
2174
+ html += '<span class="nowaikit-number">' + escaped + '</span>';
2175
+ break;
2176
+ case 'keyword':
2177
+ html += '<span class="nowaikit-keyword">' + escaped + '</span>';
2178
+ break;
2179
+ case 'sn-api':
2180
+ html += '<span class="nowaikit-sn-api">' + escaped + '</span>';
2181
+ break;
2182
+ default:
2183
+ html += escaped;
2184
+ }
2185
+ }
2186
+
2187
+ return html;
2188
+ }
2189
+
2190
+ function injectScriptHighlighting() {
2191
+ // Target ServiceNow script editor textareas and CodeMirror instances
2192
+ const scriptSelectors = [
2193
+ 'textarea.sn-script-editor',
2194
+ 'textarea[id^="element."][id$=".script"]',
2195
+ 'textarea[name="script"]',
2196
+ 'textarea[name="condition"]',
2197
+ '.CodeMirror',
2198
+ ].join(', ');
2199
+
2200
+ waitForElements(scriptSelectors, function(editors) {
2201
+ editors.forEach(function(editor) {
2202
+ applyScriptOverlay(editor);
2203
+ });
2204
+ }, 8000, 1000);
2205
+
2206
+ // Observe for dynamically loaded CodeMirror instances (auto-disconnect after 15s)
2207
+ if (document.body) {
2208
+ var cmObserverTimer;
2209
+ var cmObserver = new MutationObserver(function(mutations) {
2210
+ for (var i = 0; i < mutations.length; i++) {
2211
+ var added = mutations[i].addedNodes;
2212
+ for (var j = 0; j < added.length; j++) {
2213
+ var node = added[j];
2214
+ if (node.nodeType !== 1) continue;
2215
+ if (node.classList && node.classList.contains('CodeMirror')) {
2216
+ applyScriptOverlay(node);
2217
+ }
2218
+ var cms = node.querySelectorAll ? node.querySelectorAll('.CodeMirror') : [];
2219
+ for (var k = 0; k < cms.length; k++) {
2220
+ applyScriptOverlay(cms[k]);
2221
+ }
2222
+ }
2223
+ }
2224
+ });
2225
+ cmObserver.observe(document.body, { childList: true, subtree: true });
2226
+ // Auto-disconnect after 15 seconds to stop observing entire DOM
2227
+ cmObserverTimer = setTimeout(function() { cmObserver.disconnect(); }, 15000);
2228
+ }
2229
+ }
2230
+
2231
+ function applyScriptOverlay(editor) {
2232
+ // Skip if we already attached
2233
+ if (editor.dataset.nowaikitHighlight === 'true') return;
2234
+ editor.dataset.nowaikitHighlight = 'true';
2235
+
2236
+ // For CodeMirror instances
2237
+ if (editor.classList && editor.classList.contains('CodeMirror')) {
2238
+ const cmInstance = editor.CodeMirror;
2239
+ if (!cmInstance) return;
2240
+
2241
+ // Create overlay container
2242
+ const overlay = document.createElement('div');
2243
+ overlay.className = 'nowaikit-script-overlay';
2244
+ overlay.setAttribute('aria-hidden', 'true');
2245
+
2246
+ // Position overlay over the CodeMirror scroll area
2247
+ const scrollEl = editor.querySelector('.CodeMirror-scroll');
2248
+ if (!scrollEl) return;
2249
+
2250
+ scrollEl.style.position = 'relative';
2251
+ scrollEl.appendChild(overlay);
2252
+
2253
+ function updateOverlay() {
2254
+ const code = cmInstance.getValue();
2255
+ overlay.innerHTML = '<pre class="nowaikit-script-pre">' + highlightSyntax(code) + '</pre>';
2256
+ }
2257
+
2258
+ cmInstance.on('change', updateOverlay);
2259
+ updateOverlay();
2260
+ return;
2261
+ }
2262
+
2263
+ // For plain textareas
2264
+ if (editor.tagName === 'TEXTAREA') {
2265
+ const wrapper = editor.parentElement;
2266
+ if (!wrapper) return;
2267
+
2268
+ wrapper.style.position = 'relative';
2269
+
2270
+ const overlay = document.createElement('div');
2271
+ overlay.className = 'nowaikit-script-overlay nowaikit-textarea-overlay';
2272
+ overlay.setAttribute('aria-hidden', 'true');
2273
+ wrapper.appendChild(overlay);
2274
+
2275
+ function updateTextareaOverlay() {
2276
+ overlay.innerHTML = '<pre class="nowaikit-script-pre">' + highlightSyntax(editor.value) + '</pre>';
2277
+
2278
+ // Sync scroll
2279
+ overlay.scrollTop = editor.scrollTop;
2280
+ overlay.scrollLeft = editor.scrollLeft;
2281
+ }
2282
+
2283
+ editor.addEventListener('input', updateTextareaOverlay);
2284
+ editor.addEventListener('scroll', function() {
2285
+ overlay.scrollTop = editor.scrollTop;
2286
+ overlay.scrollLeft = editor.scrollLeft;
2287
+ });
2288
+ updateTextareaOverlay();
2289
+ }
2290
+ }
2291
+
2292
+ // ─── AI Sidebar On-Demand Init ──────────────────────────────────────────────
2293
+
2294
+ /** Initialize AI sidebar on-demand (first call inits, subsequent calls toggle) */
2295
+ function ensureAISidebar() {
2296
+ // Always init first (idempotent — won't re-create if already exists)
2297
+ if (typeof initAISidebar === 'function') {
2298
+ initAISidebar();
2299
+ }
2300
+ if (typeof toggleAISidebar === 'function') {
2301
+ toggleAISidebar();
2302
+ }
2303
+ }
2304
+
2305
+ // ─── Keyboard Shortcuts ─────────────────────────────────────────────────────
2306
+
2307
+ function injectKeyboardShortcuts() {
2308
+ // Helper: check for Ctrl (Windows/Linux) or Cmd (Mac)
2309
+ function modKey(e) { return e.ctrlKey || e.metaKey; }
2310
+
2311
+ document.addEventListener('keydown', function(e) {
2312
+ if (!e.shiftKey || !modKey(e)) return;
2313
+
2314
+ switch (e.key) {
2315
+ case 'K': // Quick Nav
2316
+ e.preventDefault();
2317
+ var nav = document.getElementById('nowaikit-quicknav');
2318
+ if (nav) {
2319
+ nav.style.display = nav.style.display === 'none' ? 'flex' : 'none';
2320
+ if (nav.style.display === 'flex') {
2321
+ var inp = document.getElementById('nowaikit-quicknav-input');
2322
+ if (inp) inp.focus();
2323
+ }
2324
+ }
2325
+ break;
2326
+ case 'C': // Copy sys_id
2327
+ e.preventDefault();
2328
+ if (currentSysId) {
2329
+ safeClipboardWrite(currentSysId, 'sys_id copied!', 'Failed to copy sys_id');
2330
+ }
2331
+ break;
2332
+ case 'U': // Copy URL
2333
+ e.preventDefault();
2334
+ safeClipboardWrite(window.location.href, 'URL copied!', 'Failed to copy URL');
2335
+ break;
2336
+ case 'L': // Open list view
2337
+ e.preventDefault();
2338
+ if (currentTable && isValidTableName(currentTable)) {
2339
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '_list.do', '_blank');
2340
+ }
2341
+ break;
2342
+ case 'X': // View as XML
2343
+ e.preventDefault();
2344
+ if (currentTable && isValidTableName(currentTable) && currentSysId && isValidSysId(currentSysId)) {
2345
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?XML&sys_id=' + encodeURIComponent(currentSysId), '_blank');
2346
+ } else if (currentTable && isValidTableName(currentTable)) {
2347
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?XML', '_blank');
2348
+ }
2349
+ break;
2350
+ case 'J': // View as JSON
2351
+ e.preventDefault();
2352
+ if (currentTable && isValidTableName(currentTable) && currentSysId && isValidSysId(currentSysId)) {
2353
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?JSONv2&sysparm_action=get&sysparm_sys_id=' + encodeURIComponent(currentSysId), '_blank');
2354
+ } else if (currentTable && isValidTableName(currentTable)) {
2355
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?JSONv2&sysparm_action=getRecords&sysparm_limit=20', '_blank');
2356
+ }
2357
+ break;
2358
+ case 'P': // Main Panel
2359
+ e.preventDefault();
2360
+ if (typeof window.toggleMainPanel === 'function') window.toggleMainPanel();
2361
+ break;
2362
+ case 'A': // Toggle AI Sidebar
2363
+ e.preventDefault();
2364
+ ensureAISidebar();
2365
+ break;
2366
+ case 'N': // Node Switcher
2367
+ e.preventDefault();
2368
+ openNodeSwitcher();
2369
+ break;
2370
+ }
2371
+ });
2372
+ }
2373
+
2374
+ // ─── Context Menu Actions ──────────────────────────────────────────────────
2375
+
2376
+ function listenForContextMenuActions() {
2377
+ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
2378
+ // Handle bridge status query from popup
2379
+ if (message.action === 'nowaikit-get-bridge-status') {
2380
+ if (typeof getBridgeStatus === 'function') {
2381
+ sendResponse(getBridgeStatus());
2382
+ } else {
2383
+ sendResponse({ connected: false });
2384
+ }
2385
+ return true; // async response
2386
+ }
2387
+
2388
+ switch (message.action) {
2389
+ case 'nowaikit-copy-sysid':
2390
+ if (currentSysId) {
2391
+ safeClipboardWrite(currentSysId, 'sys_id copied!', 'Failed to copy sys_id');
2392
+ } else {
2393
+ showToast('No sys_id found on this page', 'warn');
2394
+ }
2395
+ break;
2396
+
2397
+ case 'nowaikit-copy-record-url':
2398
+ safeClipboardWrite(window.location.href, 'URL copied!', 'Failed to copy URL');
2399
+ break;
2400
+
2401
+ case 'nowaikit-copy-table-name':
2402
+ if (currentTable) {
2403
+ safeClipboardWrite(currentTable, 'Table name copied!', 'Failed to copy table name');
2404
+ }
2405
+ break;
2406
+
2407
+ case 'nowaikit-open-list':
2408
+ if (currentTable && isValidTableName(currentTable)) {
2409
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '_list.do', '_blank');
2410
+ }
2411
+ break;
2412
+
2413
+ case 'nowaikit-open-schema':
2414
+ if (currentTable && isValidTableName(currentTable)) {
2415
+ window.open(instanceUrl + '/sys_dictionary_list.do?sysparm_query=name=' + encodeURIComponent(currentTable), '_blank');
2416
+ }
2417
+ break;
2418
+
2419
+ case 'nowaikit-xml-view':
2420
+ if (currentTable && isValidTableName(currentTable) && currentSysId && isValidSysId(currentSysId)) {
2421
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?XML&sys_id=' + encodeURIComponent(currentSysId), '_blank');
2422
+ } else if (currentTable && isValidTableName(currentTable)) {
2423
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?XML', '_blank');
2424
+ }
2425
+ break;
2426
+
2427
+ case 'nowaikit-json-view':
2428
+ if (currentTable && isValidTableName(currentTable) && currentSysId && isValidSysId(currentSysId)) {
2429
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?JSONv2&sysparm_action=get&sysparm_sys_id=' + encodeURIComponent(currentSysId), '_blank');
2430
+ } else if (currentTable && isValidTableName(currentTable)) {
2431
+ window.open(instanceUrl + '/' + encodeURIComponent(currentTable) + '.do?JSONv2&sysparm_action=getRecords&sysparm_limit=20', '_blank');
2432
+ }
2433
+ break;
2434
+
2435
+ case 'nowaikit-toggle-ai-sidebar':
2436
+ ensureAISidebar();
2437
+ break;
2438
+
2439
+ case 'nowaikit-switch-node':
2440
+ openNodeSwitcher();
2441
+ break;
2442
+
2443
+ case 'nowaikit-toggle-main-panel':
2444
+ if (typeof window.toggleMainPanel === 'function') window.toggleMainPanel();
2445
+ break;
2446
+
2447
+ case 'nowaikit-set-theme':
2448
+ if (message.theme && typeof window.nowaikitSetTheme === 'function') {
2449
+ window.nowaikitSetTheme(message.theme);
2450
+ }
2451
+ break;
2452
+ }
2453
+ });
2454
+ }
2455
+
2456
+ // ─── Toast Notification ────────────────────────────────────────────────────
2457
+
2458
+ // Expose globally for ai-sidebar.js etc.
2459
+ window.showToast = showToast;
2460
+
2461
+ function showToast(message, type) {
2462
+ if (!type) type = 'success';
2463
+ const existing = document.getElementById('nowaikit-toast');
2464
+ if (existing) existing.remove();
2465
+
2466
+ const toast = document.createElement('div');
2467
+ toast.id = 'nowaikit-toast';
2468
+ toast.className = 'nowaikit-toast nowaikit-toast-' + type;
2469
+ toast.textContent = message;
2470
+ document.body.appendChild(toast);
2471
+
2472
+ setTimeout(function() { toast.classList.add('nowaikit-toast-show'); }, 10);
2473
+ setTimeout(function() {
2474
+ toast.classList.remove('nowaikit-toast-show');
2475
+ setTimeout(function() { toast.remove(); }, 300);
2476
+ }, 2000);
2477
+ }
2478
+
2479
+ // ─── Utilities ─────────────────────────────────────────────────────────────
2480
+
2481
+ // Expose globally for ai-sidebar.js, main-panel.js, etc.
2482
+ window.escapeHtml = escapeHtml;
2483
+ window.openNodeSwitcher = openNodeSwitcher;
2484
+ window.ensureAISidebar = ensureAISidebar;
2485
+
2486
+ function escapeHtml(str) {
2487
+ const div = document.createElement('div');
2488
+ div.textContent = str;
2489
+ return div.innerHTML;
2490
+ }
2491
+
2492
+ /**
2493
+ * Safe clipboard write with error handling.
2494
+ * @param {string} text - Text to copy
2495
+ * @param {string} [successMsg] - Toast message on success
2496
+ * @param {string} [failMsg] - Toast message on failure
2497
+ */
2498
+ function safeClipboardWrite(text, successMsg, failMsg) {
2499
+ navigator.clipboard.writeText(text).then(function() {
2500
+ if (successMsg) showToast(successMsg);
2501
+ }).catch(function() {
2502
+ if (failMsg) showToast(failMsg, 'warn');
2503
+ });
2504
+ }
2505
+
2506
+ /**
2507
+ * Validate a ServiceNow table name is safe for URL construction.
2508
+ * Allows only alphanumeric, underscores, and dots.
2509
+ * @param {string} name
2510
+ * @returns {boolean}
2511
+ */
2512
+ function isValidTableName(name) {
2513
+ return /^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(name);
2514
+ }
2515
+
2516
+ /**
2517
+ * Validate a sys_id is a 32-char hex string.
2518
+ * @param {string} id
2519
+ * @returns {boolean}
2520
+ */
2521
+ function isValidSysId(id) {
2522
+ return /^[a-f0-9]{32}$/.test(id);
2523
+ }
2524
+
2525
+ window.safeClipboardWrite = safeClipboardWrite;
2526
+
2527
+ })();