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.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/ai-window.html +598 -0
- package/background/service-worker.js +398 -0
- package/cli.mjs +65 -0
- package/content/ai-sidebar.js +1198 -0
- package/content/code-templates.js +843 -0
- package/content/content.js +2527 -0
- package/content/integration-bridge.js +627 -0
- package/content/main-panel.js +592 -0
- package/content/styles.css +1609 -0
- package/icons/README.txt +1 -0
- package/icons/icon-128.png +0 -0
- package/icons/icon-16.png +0 -0
- package/icons/icon-48.png +0 -0
- package/icons/icon.svg +16 -0
- package/manifest.json +63 -0
- package/options/options.html +434 -0
- package/package.json +49 -0
- package/popup/popup.html +663 -0
- package/popup/popup.js +414 -0
|
@@ -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 = '⎘';
|
|
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 = '✓';
|
|
891
|
+
setTimeout(function() { btn.innerHTML = '⎘'; }, 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">×</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
|
+
})();
|