handhold-sdk 1.0.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/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Utility to render Markdown to HTML safely
|
|
5
|
+
*/
|
|
6
|
+
export const MarkdownRenderer = {
|
|
7
|
+
/**
|
|
8
|
+
* Configure marked options
|
|
9
|
+
*/
|
|
10
|
+
init() {
|
|
11
|
+
marked.setOptions({
|
|
12
|
+
renderer: new marked.Renderer(),
|
|
13
|
+
gfm: true, // GitHub Flavored Markdown
|
|
14
|
+
breaks: true, // Convert \n to <br>
|
|
15
|
+
pedantic: false,
|
|
16
|
+
sanitize: false, // We'll assume the input is safe or sanitize it elsewhere if needed, but for now we trust the backend/AI
|
|
17
|
+
smartLists: true,
|
|
18
|
+
smartypants: false,
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse markdown string to HTML
|
|
24
|
+
* @param {string} text - Markdown text
|
|
25
|
+
* @returns {string} - HTML string
|
|
26
|
+
*/
|
|
27
|
+
parse(text) {
|
|
28
|
+
if (!text) return '';
|
|
29
|
+
try {
|
|
30
|
+
return marked.parse(text);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error('[HandHold] Markdown parsing error:', e);
|
|
33
|
+
return text; // Fallback to raw text
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Initialize on load
|
|
39
|
+
MarkdownRenderer.init();
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - DOM Utilities
|
|
3
|
+
* Helper functions for DOM manipulation and element scanning
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate a stable CSS selector for an element
|
|
8
|
+
* Priority: data-testid > id > unique class > nth-child path
|
|
9
|
+
* @param {Element} element - DOM element
|
|
10
|
+
* @returns {string} - CSS selector
|
|
11
|
+
*/
|
|
12
|
+
export function generateSelector(element) {
|
|
13
|
+
if (!element || element === document.body) {
|
|
14
|
+
return 'body';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Priority 1: data-testid
|
|
18
|
+
const testId = element.getAttribute('data-testid');
|
|
19
|
+
if (testId) {
|
|
20
|
+
return `[data-testid="${testId}"]`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Priority 2: ID (if unique)
|
|
24
|
+
if (element.id && document.querySelectorAll(`#${CSS.escape(element.id)}`).length === 1) {
|
|
25
|
+
return `#${CSS.escape(element.id)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Priority 3: data-id or data-key
|
|
29
|
+
const dataId = element.getAttribute('data-id') || element.getAttribute('data-key');
|
|
30
|
+
if (dataId) {
|
|
31
|
+
const attr = element.hasAttribute('data-id') ? 'data-id' : 'data-key';
|
|
32
|
+
return `[${attr}="${dataId}"]`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Priority 4: Unique class combination
|
|
36
|
+
if (element.classList.length > 0) {
|
|
37
|
+
const selector = `.${Array.from(element.classList).map(c => CSS.escape(c)).join('.')}`;
|
|
38
|
+
if (document.querySelectorAll(selector).length === 1) {
|
|
39
|
+
return selector;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Priority 5: Tag + text content for buttons/links
|
|
44
|
+
if (['BUTTON', 'A'].includes(element.tagName)) {
|
|
45
|
+
const text = element.textContent?.trim();
|
|
46
|
+
if (text && text.length < 50) {
|
|
47
|
+
// Use XPath-style or build path
|
|
48
|
+
const tag = element.tagName.toLowerCase();
|
|
49
|
+
const selector = buildPathSelector(element);
|
|
50
|
+
return selector;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fallback: Build path selector
|
|
55
|
+
return buildPathSelector(element);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build a path-based selector using nth-child
|
|
60
|
+
* @param {Element} element - DOM element
|
|
61
|
+
* @returns {string} - Path selector
|
|
62
|
+
*/
|
|
63
|
+
function buildPathSelector(element) {
|
|
64
|
+
const path = [];
|
|
65
|
+
let current = element;
|
|
66
|
+
|
|
67
|
+
while (current && current !== document.body && current !== document.documentElement) {
|
|
68
|
+
let selector = current.tagName.toLowerCase();
|
|
69
|
+
|
|
70
|
+
// Add ID if present
|
|
71
|
+
if (current.id) {
|
|
72
|
+
selector = `#${CSS.escape(current.id)}`;
|
|
73
|
+
path.unshift(selector);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add data-testid if present
|
|
78
|
+
const testId = current.getAttribute('data-testid');
|
|
79
|
+
if (testId) {
|
|
80
|
+
selector = `[data-testid="${testId}"]`;
|
|
81
|
+
path.unshift(selector);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Add class if unique among siblings
|
|
86
|
+
if (current.classList.length > 0) {
|
|
87
|
+
const classSelector = `.${Array.from(current.classList).slice(0, 2).map(c => CSS.escape(c)).join('.')}`;
|
|
88
|
+
const siblings = current.parentElement?.querySelectorAll(`:scope > ${current.tagName.toLowerCase()}${classSelector}`);
|
|
89
|
+
if (siblings?.length === 1) {
|
|
90
|
+
selector = `${current.tagName.toLowerCase()}${classSelector}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Add nth-child if needed
|
|
95
|
+
if (current.parentElement) {
|
|
96
|
+
const siblings = Array.from(current.parentElement.children).filter(
|
|
97
|
+
child => child.tagName === current.tagName
|
|
98
|
+
);
|
|
99
|
+
if (siblings.length > 1) {
|
|
100
|
+
const index = siblings.indexOf(current) + 1;
|
|
101
|
+
selector += `:nth-of-type(${index})`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
path.unshift(selector);
|
|
106
|
+
current = current.parentElement;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return path.join(' > ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get element's visible text content
|
|
114
|
+
* @param {Element} element - DOM element
|
|
115
|
+
* @returns {string} - Trimmed text content
|
|
116
|
+
*/
|
|
117
|
+
export function getElementText(element) {
|
|
118
|
+
// For inputs, get value or placeholder
|
|
119
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
120
|
+
return element.value || element.placeholder || '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// For select, get selected option text
|
|
124
|
+
if (element.tagName === 'SELECT') {
|
|
125
|
+
return element.options[element.selectedIndex]?.text || '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Prefer an explicit accessible name when present (Phase 3.3).
|
|
129
|
+
const ariaLabel = element.getAttribute && element.getAttribute('aria-label');
|
|
130
|
+
if (ariaLabel && ariaLabel.trim()) {
|
|
131
|
+
return ariaLabel.trim().substring(0, 100);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Collect only direct text nodes first — avoids picking up badge/icon/counter text
|
|
135
|
+
// e.g. <button>Approve <span class="badge">3</span> Timesheets</button> → "Approve Timesheets"
|
|
136
|
+
const directTexts = Array.from(element.childNodes)
|
|
137
|
+
.filter(node => node.nodeType === Node.TEXT_NODE)
|
|
138
|
+
.map(node => node.textContent.trim())
|
|
139
|
+
.filter(t => t.length > 0);
|
|
140
|
+
|
|
141
|
+
if (directTexts.length > 0) {
|
|
142
|
+
return directTexts.join(' ').replace(/\s+/g, ' ').substring(0, 100);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fallback: when the button text is wrapped in a child element (e.g. <button><span>Text</span></button>)
|
|
146
|
+
// Skip SVG icons and aria-hidden decorations (icon glyphs, counter badges).
|
|
147
|
+
const childTexts = Array.from(element.children)
|
|
148
|
+
.filter(child => {
|
|
149
|
+
if (child.tagName && child.tagName.toLowerCase() === 'svg') return false;
|
|
150
|
+
if (child.getAttribute && child.getAttribute('aria-hidden') === 'true') return false;
|
|
151
|
+
const style = window.getComputedStyle(child);
|
|
152
|
+
return style.display !== 'none' && style.visibility !== 'hidden';
|
|
153
|
+
})
|
|
154
|
+
.map(child => {
|
|
155
|
+
// Only get direct text nodes of this child, not further nested content
|
|
156
|
+
return Array.from(child.childNodes)
|
|
157
|
+
.filter(node => node.nodeType === Node.TEXT_NODE)
|
|
158
|
+
.map(node => node.textContent.trim())
|
|
159
|
+
.filter(t => t.length > 0)
|
|
160
|
+
.join(' ');
|
|
161
|
+
})
|
|
162
|
+
.filter(t => t.length > 0);
|
|
163
|
+
|
|
164
|
+
return childTexts.join(' ').replace(/\s+/g, ' ').substring(0, 100);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get element's role (button, link, input, etc.)
|
|
169
|
+
* @param {Element} element - DOM element
|
|
170
|
+
* @returns {string} - Element role
|
|
171
|
+
*/
|
|
172
|
+
export function getElementRole(element) {
|
|
173
|
+
// Check explicit ARIA role
|
|
174
|
+
const ariaRole = element.getAttribute('role');
|
|
175
|
+
if (ariaRole) return ariaRole;
|
|
176
|
+
|
|
177
|
+
// Infer from tag
|
|
178
|
+
const tagMap = {
|
|
179
|
+
'BUTTON': 'button',
|
|
180
|
+
'A': 'link',
|
|
181
|
+
'INPUT': 'input',
|
|
182
|
+
'SELECT': 'combobox',
|
|
183
|
+
'TEXTAREA': 'textbox',
|
|
184
|
+
'IMG': 'image',
|
|
185
|
+
'H1': 'heading',
|
|
186
|
+
'H2': 'heading',
|
|
187
|
+
'H3': 'heading',
|
|
188
|
+
'H4': 'heading',
|
|
189
|
+
'H5': 'heading',
|
|
190
|
+
'H6': 'heading',
|
|
191
|
+
'NAV': 'navigation',
|
|
192
|
+
'MAIN': 'main',
|
|
193
|
+
'SECTION': 'region',
|
|
194
|
+
'ARTICLE': 'article',
|
|
195
|
+
'ASIDE': 'complementary',
|
|
196
|
+
'FOOTER': 'contentinfo',
|
|
197
|
+
'HEADER': 'banner',
|
|
198
|
+
'FORM': 'form',
|
|
199
|
+
'TABLE': 'table',
|
|
200
|
+
'UL': 'list',
|
|
201
|
+
'OL': 'list',
|
|
202
|
+
'LI': 'listitem',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Check for button-like behavior
|
|
206
|
+
if (element.onclick || element.getAttribute('onclick')) {
|
|
207
|
+
return 'button';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check for clickable divs/spans with click handlers
|
|
211
|
+
if (['DIV', 'SPAN'].includes(element.tagName)) {
|
|
212
|
+
if (element.style.cursor === 'pointer' || element.classList.contains('btn') ||
|
|
213
|
+
element.classList.contains('button') || element.getAttribute('tabindex') === '0') {
|
|
214
|
+
return 'button';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return tagMap[element.tagName] || 'generic';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get element's bounding position
|
|
223
|
+
* @param {Element} element - DOM element
|
|
224
|
+
* @returns {Object} - { x, y, width, height }
|
|
225
|
+
*/
|
|
226
|
+
export function getElementPosition(element) {
|
|
227
|
+
const rect = element.getBoundingClientRect();
|
|
228
|
+
return {
|
|
229
|
+
x: Math.round(rect.left + window.scrollX),
|
|
230
|
+
y: Math.round(rect.top + window.scrollY),
|
|
231
|
+
width: Math.round(rect.width),
|
|
232
|
+
height: Math.round(rect.height),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check if element is visible
|
|
238
|
+
* @param {Element} element - DOM element
|
|
239
|
+
* @returns {boolean}
|
|
240
|
+
*/
|
|
241
|
+
export function isElementVisible(element) {
|
|
242
|
+
if (!element) return false;
|
|
243
|
+
|
|
244
|
+
const style = window.getComputedStyle(element);
|
|
245
|
+
|
|
246
|
+
if (style.display === 'none') return false;
|
|
247
|
+
if (style.visibility === 'hidden') return false;
|
|
248
|
+
if (style.opacity === '0') return false;
|
|
249
|
+
|
|
250
|
+
const rect = element.getBoundingClientRect();
|
|
251
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
252
|
+
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check if element is interactive
|
|
258
|
+
* @param {Element} element - DOM element
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
*/
|
|
261
|
+
export function isInteractiveElement(element) {
|
|
262
|
+
const interactiveTags = ['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA'];
|
|
263
|
+
const interactiveRoles = ['button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem', 'tab'];
|
|
264
|
+
|
|
265
|
+
if (interactiveTags.includes(element.tagName)) return true;
|
|
266
|
+
|
|
267
|
+
const role = element.getAttribute('role');
|
|
268
|
+
if (role && interactiveRoles.includes(role)) return true;
|
|
269
|
+
|
|
270
|
+
// Check for tabindex
|
|
271
|
+
const tabindex = element.getAttribute('tabindex');
|
|
272
|
+
if (tabindex && parseInt(tabindex, 10) >= 0) return true;
|
|
273
|
+
|
|
274
|
+
// Check for onclick
|
|
275
|
+
if (element.onclick || element.getAttribute('onclick')) return true;
|
|
276
|
+
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get all interactive elements on page
|
|
282
|
+
* @param {Element} [root=document] - Root element to search from
|
|
283
|
+
* @returns {Element[]} - Array of interactive elements
|
|
284
|
+
*/
|
|
285
|
+
export function getInteractiveElements(root = document) {
|
|
286
|
+
const selectors = [
|
|
287
|
+
'button',
|
|
288
|
+
'a[href]',
|
|
289
|
+
'input:not([type="hidden"])',
|
|
290
|
+
'select',
|
|
291
|
+
'textarea',
|
|
292
|
+
'[role="button"]',
|
|
293
|
+
'[role="link"]',
|
|
294
|
+
'[role="checkbox"]',
|
|
295
|
+
'[role="radio"]',
|
|
296
|
+
'[role="menuitem"]',
|
|
297
|
+
'[role="tab"]',
|
|
298
|
+
'[onclick]',
|
|
299
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
const elements = Array.from(root.querySelectorAll(selectors.join(', ')));
|
|
303
|
+
|
|
304
|
+
return elements.filter(el => isElementVisible(el));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Create element from HTML string
|
|
309
|
+
* @param {string} html - HTML string
|
|
310
|
+
* @returns {Element}
|
|
311
|
+
*/
|
|
312
|
+
export function createElement(html) {
|
|
313
|
+
const template = document.createElement('template');
|
|
314
|
+
template.innerHTML = html.trim();
|
|
315
|
+
return template.content.firstChild;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Wait for element to appear in DOM
|
|
320
|
+
* @param {string} selector - CSS selector
|
|
321
|
+
* @param {number} timeout - Timeout in ms
|
|
322
|
+
* @returns {Promise<Element>}
|
|
323
|
+
*/
|
|
324
|
+
export function waitForElement(selector, timeout = 5000) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const element = document.querySelector(selector);
|
|
327
|
+
if (element) {
|
|
328
|
+
resolve(element);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const observer = new MutationObserver((mutations, obs) => {
|
|
333
|
+
const element = document.querySelector(selector);
|
|
334
|
+
if (element) {
|
|
335
|
+
obs.disconnect();
|
|
336
|
+
resolve(element);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
observer.observe(document.body, {
|
|
341
|
+
childList: true,
|
|
342
|
+
subtree: true,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
setTimeout(() => {
|
|
346
|
+
observer.disconnect();
|
|
347
|
+
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
|
|
348
|
+
}, timeout);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - Event Bus
|
|
3
|
+
* Simple pub/sub event system for internal communication
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class EventBus {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.events = {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to an event
|
|
13
|
+
* @param {string} event - Event name
|
|
14
|
+
* @param {Function} callback - Callback function
|
|
15
|
+
* @returns {Function} - Unsubscribe function
|
|
16
|
+
*/
|
|
17
|
+
on(event, callback) {
|
|
18
|
+
if (!this.events[event]) {
|
|
19
|
+
this.events[event] = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.events[event].push(callback);
|
|
23
|
+
|
|
24
|
+
// Return unsubscribe function
|
|
25
|
+
return () => this.off(event, callback);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Subscribe to an event (one time only)
|
|
30
|
+
* @param {string} event - Event name
|
|
31
|
+
* @param {Function} callback - Callback function
|
|
32
|
+
*/
|
|
33
|
+
once(event, callback) {
|
|
34
|
+
const onceCallback = (...args) => {
|
|
35
|
+
callback(...args);
|
|
36
|
+
this.off(event, onceCallback);
|
|
37
|
+
};
|
|
38
|
+
this.on(event, onceCallback);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Unsubscribe from an event
|
|
43
|
+
* @param {string} event - Event name
|
|
44
|
+
* @param {Function} callback - Callback function to remove
|
|
45
|
+
*/
|
|
46
|
+
off(event, callback) {
|
|
47
|
+
if (!this.events[event]) return;
|
|
48
|
+
|
|
49
|
+
this.events[event] = this.events[event].filter(cb => cb !== callback);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Emit an event
|
|
54
|
+
* @param {string} event - Event name
|
|
55
|
+
* @param {...any} args - Arguments to pass to callbacks
|
|
56
|
+
*/
|
|
57
|
+
emit(event, ...args) {
|
|
58
|
+
if (!this.events[event]) return;
|
|
59
|
+
|
|
60
|
+
this.events[event].forEach(callback => {
|
|
61
|
+
try {
|
|
62
|
+
callback(...args);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(`[HandHold EventBus] Error in event handler for '${event}':`, error);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove all listeners for an event (or all events)
|
|
71
|
+
* @param {string} [event] - Event name (optional)
|
|
72
|
+
*/
|
|
73
|
+
clear(event) {
|
|
74
|
+
if (event) {
|
|
75
|
+
delete this.events[event];
|
|
76
|
+
} else {
|
|
77
|
+
this.events = {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get all registered events
|
|
83
|
+
* @returns {string[]} - Array of event names
|
|
84
|
+
*/
|
|
85
|
+
getEvents() {
|
|
86
|
+
return Object.keys(this.events);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get listener count for an event
|
|
91
|
+
* @param {string} event - Event name
|
|
92
|
+
* @returns {number} - Number of listeners
|
|
93
|
+
*/
|
|
94
|
+
listenerCount(event) {
|
|
95
|
+
return this.events[event]?.length || 0;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Export singleton instance
|
|
100
|
+
const eventBus = new EventBus();
|
|
101
|
+
export default eventBus;
|
|
102
|
+
export { EventBus };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - Utils Index
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { default as eventBus, EventBus } from './eventBus.js';
|
|
6
|
+
export { default as logger, Logger, LOG_LEVELS } from './Logger.js';
|
|
7
|
+
export * as domUtils from './domUtils.js';
|
|
8
|
+
export * as stringUtils from './stringUtils.js';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - String Utilities
|
|
3
|
+
* Helper functions for string manipulation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert string to snake_case
|
|
8
|
+
* @param {string} str - Input string
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function toSnakeCase(str) {
|
|
12
|
+
return str
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/\s+/g, '_')
|
|
15
|
+
.replace(/[^\w_]/g, '')
|
|
16
|
+
.replace(/_+/g, '_')
|
|
17
|
+
.replace(/^_|_$/g, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert snake_case to Title Case
|
|
22
|
+
* @param {string} str - Input string in snake_case
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function toTitleCase(str) {
|
|
26
|
+
return String(str)
|
|
27
|
+
// insert a boundary at camelCase transitions: "createOkr" -> "create Okr"
|
|
28
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
29
|
+
// split on underscores, hyphens, and whitespace
|
|
30
|
+
.split(/[_\-\s]+/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
33
|
+
.join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Convert to camelCase
|
|
38
|
+
* @param {string} str - Input string
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function toCamelCase(str) {
|
|
42
|
+
return str
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[_\s]+(.)/g, (_, char) => char.toUpperCase());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Truncate string with ellipsis
|
|
49
|
+
* @param {string} str - Input string
|
|
50
|
+
* @param {number} maxLength - Maximum length
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function truncate(str, maxLength = 50) {
|
|
54
|
+
if (!str || str.length <= maxLength) return str;
|
|
55
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Capitalize first letter
|
|
60
|
+
* @param {string} str - Input string
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function capitalize(str) {
|
|
64
|
+
if (!str) return '';
|
|
65
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate hash from string
|
|
70
|
+
* @param {string} str - Input string
|
|
71
|
+
* @returns {string} - Hash string
|
|
72
|
+
*/
|
|
73
|
+
export function hashString(str) {
|
|
74
|
+
let hash = 0;
|
|
75
|
+
if (!str || str.length === 0) return hash.toString(36);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < str.length; i++) {
|
|
78
|
+
const char = str.charCodeAt(i);
|
|
79
|
+
hash = ((hash << 5) - hash) + char;
|
|
80
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Math.abs(hash).toString(36);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract keywords from text
|
|
88
|
+
* @param {string} text - Input text
|
|
89
|
+
* @returns {string[]} - Array of keywords
|
|
90
|
+
*/
|
|
91
|
+
export function extractKeywords(text) {
|
|
92
|
+
const stopWords = new Set([
|
|
93
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
94
|
+
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
95
|
+
'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
|
|
96
|
+
'could', 'should', 'may', 'might', 'must', 'shall', 'can', 'this',
|
|
97
|
+
'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they',
|
|
98
|
+
'what', 'which', 'who', 'when', 'where', 'why', 'how', 'all', 'each',
|
|
99
|
+
'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no',
|
|
100
|
+
'not', 'only', 'same', 'so', 'than', 'too', 'very', 'just', 'also',
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
return text
|
|
104
|
+
.toLowerCase()
|
|
105
|
+
.replace(/[^\w\s]/g, '')
|
|
106
|
+
.split(/\s+/)
|
|
107
|
+
.filter(word => word.length > 2 && !stopWords.has(word));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Calculate similarity between two strings (Levenshtein distance based)
|
|
112
|
+
* @param {string} str1 - First string
|
|
113
|
+
* @param {string} str2 - Second string
|
|
114
|
+
* @returns {number} - Similarity score (0-1)
|
|
115
|
+
*/
|
|
116
|
+
export function similarity(str1, str2) {
|
|
117
|
+
if (!str1 || !str2) return 0;
|
|
118
|
+
|
|
119
|
+
str1 = str1.toLowerCase();
|
|
120
|
+
str2 = str2.toLowerCase();
|
|
121
|
+
|
|
122
|
+
if (str1 === str2) return 1;
|
|
123
|
+
|
|
124
|
+
const maxLength = Math.max(str1.length, str2.length);
|
|
125
|
+
if (maxLength === 0) return 1;
|
|
126
|
+
|
|
127
|
+
const distance = levenshteinDistance(str1, str2);
|
|
128
|
+
return 1 - (distance / maxLength);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Levenshtein distance between two strings
|
|
133
|
+
* @param {string} str1 - First string
|
|
134
|
+
* @param {string} str2 - Second string
|
|
135
|
+
* @returns {number} - Edit distance
|
|
136
|
+
*/
|
|
137
|
+
function levenshteinDistance(str1, str2) {
|
|
138
|
+
const m = str1.length;
|
|
139
|
+
const n = str2.length;
|
|
140
|
+
|
|
141
|
+
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
144
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
145
|
+
|
|
146
|
+
for (let i = 1; i <= m; i++) {
|
|
147
|
+
for (let j = 1; j <= n; j++) {
|
|
148
|
+
if (str1[i - 1] === str2[j - 1]) {
|
|
149
|
+
dp[i][j] = dp[i - 1][j - 1];
|
|
150
|
+
} else {
|
|
151
|
+
dp[i][j] = Math.min(
|
|
152
|
+
dp[i - 1][j] + 1, // deletion
|
|
153
|
+
dp[i][j - 1] + 1, // insertion
|
|
154
|
+
dp[i - 1][j - 1] + 1 // substitution
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return dp[m][n];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if string contains all keywords
|
|
165
|
+
* @param {string} str - String to search
|
|
166
|
+
* @param {string[]} keywords - Keywords to find
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function containsAllKeywords(str, keywords) {
|
|
170
|
+
const lowerStr = str.toLowerCase();
|
|
171
|
+
return keywords.every(keyword => lowerStr.includes(keyword.toLowerCase()));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Pluralize a word
|
|
176
|
+
* @param {string} word - Word to pluralize
|
|
177
|
+
* @param {number} count - Count
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
export function pluralize(word, count) {
|
|
181
|
+
if (count === 1) return word;
|
|
182
|
+
|
|
183
|
+
// Simple rules
|
|
184
|
+
if (word.endsWith('y')) {
|
|
185
|
+
return word.slice(0, -1) + 'ies';
|
|
186
|
+
}
|
|
187
|
+
if (word.endsWith('s') || word.endsWith('x') || word.endsWith('ch') || word.endsWith('sh')) {
|
|
188
|
+
return word + 'es';
|
|
189
|
+
}
|
|
190
|
+
return word + 's';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generate a unique ID
|
|
195
|
+
* @param {string} [prefix=''] - Prefix for the ID
|
|
196
|
+
* @returns {string}
|
|
197
|
+
*/
|
|
198
|
+
export function generateId(prefix = '') {
|
|
199
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
200
|
+
const timestamp = Date.now().toString(36);
|
|
201
|
+
return prefix ? `${prefix}_${random}${timestamp}` : `${random}${timestamp}`;
|
|
202
|
+
}
|