diff --git a/BrowserExtensions/HireflowInteractionTracker/Archive.zip b/BrowserExtensions/HireflowInteractionTracker/Archive.zip new file mode 100644 index 0000000..7e13107 Binary files /dev/null and b/BrowserExtensions/HireflowInteractionTracker/Archive.zip differ diff --git a/BrowserExtensions/HireflowInteractionTracker/background.js b/BrowserExtensions/HireflowInteractionTracker/background.js new file mode 100644 index 0000000..69577ef --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/background.js @@ -0,0 +1,9 @@ +const STORAGE_KEYS = ["recording", "recordingTabId"]; + +chrome.tabs.onRemoved.addListener((tabId) => { + chrome.storage.local.get(["recording", "recordingTabId"], (data) => { + if (data.recording && data.recordingTabId === tabId) { + chrome.storage.local.remove(STORAGE_KEYS); + } + }); +}); diff --git a/BrowserExtensions/HireflowInteractionTracker/content/tracker.js b/BrowserExtensions/HireflowInteractionTracker/content/tracker.js new file mode 100644 index 0000000..77b1573 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/content/tracker.js @@ -0,0 +1,317 @@ +(function () { + const GUARD = "__HireflowInteractionTracker"; + if (window[GUARD]) { + return; + } + + const TYPE_DEBOUNCE_MS = 250; + + /** @type {{ recording: boolean, interactions: Array<{action: string, xpath_selector: string, text?: string}>, listenersAttached: boolean, typeTimer: ReturnType | null, pendingType: { xpath: string, text: string } | null, userEdited: WeakSet }} */ + const state = { + recording: false, + interactions: [], + listenersAttached: false, + typeTimer: null, + pendingType: null, + userEdited: new WeakSet(), + }; + window[GUARD] = state; + + /** Input types that represent free-text (or numeric) typing — not clicks-only controls. */ + const TEXT_LIKE_INPUT_TYPES = new Set([ + "text", + "search", + "url", + "tel", + "email", + "password", + "number", + "", + ]); + + function normalizedInputType(el) { + if (el.tagName !== "INPUT") { + return ""; + } + const raw = el.getAttribute("type"); + if (raw == null || raw === "") { + return "text"; + } + return String(raw).toLowerCase(); + } + + function isTextLikeField(el) { + if (!el || el.nodeType !== Node.ELEMENT_NODE) { + return false; + } + if (el.tagName === "TEXTAREA") { + return true; + } + if (el.tagName === "INPUT") { + return TEXT_LIKE_INPUT_TYPES.has(normalizedInputType(el)); + } + if (el.isContentEditable === true) { + return true; + } + return false; + } + + function markFieldUserEdited(el) { + if (isTextLikeField(el)) { + state.userEdited.add(el); + } + } + + function escapeXPathLiteral(str) { + if (str.indexOf("'") === -1) { + return "'" + str + "'"; + } + if (str.indexOf('"') === -1) { + return '"' + str.replace(/"/g, '\\"') + '"'; + } + const parts = str.split("'"); + const concatArgs = []; + for (let i = 0; i < parts.length; i++) { + concatArgs.push("'" + parts[i] + "'"); + if (i < parts.length - 1) { + concatArgs.push('"\'"'); + } + } + return "concat(" + concatArgs.join(", ") + ")"; + } + + function getXPath(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + if (element.id) { + return "//*" + "[@id=" + escapeXPathLiteral(element.id) + "]"; + } + + const segments = []; + let node = element; + while (node && node.nodeType === Node.ELEMENT_NODE) { + if (node === document.documentElement) { + segments.unshift("html"); + break; + } + const parent = node.parentElement; + if (!parent) { + break; + } + const tag = node.nodeName.toLowerCase(); + const sameTagSiblings = Array.from(parent.children).filter( + (c) => c.nodeName === node.nodeName + ); + const index = sameTagSiblings.indexOf(node) + 1; + if (sameTagSiblings.length === 1) { + segments.unshift(tag); + } else { + segments.unshift(tag + "[" + index + "]"); + } + node = parent; + } + return "//" + segments.join("/"); + } + + function flushPendingType() { + state.typeTimer = null; + if (!state.pendingType) { + return; + } + const { xpath, text } = state.pendingType; + state.pendingType = null; + const last = state.interactions[state.interactions.length - 1]; + if (last && last.action === "type" && last.xpath_selector === xpath) { + last.text = text; + } else { + state.interactions.push({ + action: "type", + text, + xpath_selector: xpath, + }); + } + } + + function scheduleTypeCommit(xpath, text) { + state.pendingType = { xpath, text }; + if (state.typeTimer) { + clearTimeout(state.typeTimer); + } + state.typeTimer = setTimeout(flushPendingType, TYPE_DEBOUNCE_MS); + } + + function onClickCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + flushPendingType(); + state.interactions.push({ + action: "click", + xpath_selector: getXPath(target), + }); + } + + function onBeforeInputCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + if (typeof InputEvent !== "undefined" && ev instanceof InputEvent) { + const t = ev.target; + if (t && t.nodeType === Node.ELEMENT_NODE && isTextLikeField(t)) { + state.userEdited.add(t); + } + } + } + + function onKeyDownCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const el = document.activeElement; + if (!el || !isTextLikeField(el)) { + return; + } + if (ev.ctrlKey || ev.metaKey || ev.altKey) { + if (ev.key === "v" || ev.key === "V" || ev.key === "x" || ev.key === "X") { + markFieldUserEdited(el); + } + return; + } + if ( + ev.key.length === 1 || + ev.key === "Backspace" || + ev.key === "Delete" || + ev.key === "Enter" + ) { + markFieldUserEdited(el); + } + } + + function onPasteCutCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const t = ev.target; + if (t && t.nodeType === Node.ELEMENT_NODE) { + markFieldUserEdited(t); + } + } + + function onInputCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + if (!isTextLikeField(target)) { + return; + } + if (!state.userEdited.has(target)) { + return; + } + let text = ""; + if (target.isContentEditable) { + text = target.innerText || target.textContent || ""; + } else { + text = target.value != null ? String(target.value) : ""; + } + const xpath = getXPath(target); + scheduleTypeCommit(xpath, text); + } + + function onChangeCapture(ev) { + if (!state.recording || !ev.isTrusted) { + return; + } + const target = ev.target; + if (!target || target.nodeType !== Node.ELEMENT_NODE) { + return; + } + if (target.tagName !== "SELECT") { + return; + } + flushPendingType(); + const text = target.value != null ? String(target.value) : ""; + state.interactions.push({ + action: "select", + text, + xpath_selector: getXPath(target), + }); + } + + function attachListeners() { + if (state.listenersAttached) { + return; + } + document.addEventListener("click", onClickCapture, true); + document.addEventListener("beforeinput", onBeforeInputCapture, true); + document.addEventListener("keydown", onKeyDownCapture, true); + document.addEventListener("paste", onPasteCutCapture, true); + document.addEventListener("cut", onPasteCutCapture, true); + document.addEventListener("input", onInputCapture, true); + document.addEventListener("change", onChangeCapture, true); + state.listenersAttached = true; + } + + function detachListeners() { + if (!state.listenersAttached) { + return; + } + document.removeEventListener("click", onClickCapture, true); + document.removeEventListener("beforeinput", onBeforeInputCapture, true); + document.removeEventListener("keydown", onKeyDownCapture, true); + document.removeEventListener("paste", onPasteCutCapture, true); + document.removeEventListener("cut", onPasteCutCapture, true); + document.removeEventListener("input", onInputCapture, true); + document.removeEventListener("change", onChangeCapture, true); + state.listenersAttached = false; + } + + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === "START_RECORDING") { + state.interactions = []; + state.pendingType = null; + state.userEdited = new WeakSet(); + if (state.typeTimer) { + clearTimeout(state.typeTimer); + state.typeTimer = null; + } + state.recording = true; + attachListeners(); + sendResponse({ ok: true }); + return false; + } + + if (message.type === "STOP_AND_GET_INTERACTIONS") { + state.recording = false; + if (state.typeTimer) { + clearTimeout(state.typeTimer); + state.typeTimer = null; + } + flushPendingType(); + detachListeners(); + const interactions = state.interactions.slice(); + state.interactions = []; + sendResponse({ ok: true, interactions }); + return false; + } + + if (message.type === "GET_STATUS") { + sendResponse({ + ok: true, + recording: state.recording, + count: state.interactions.length, + }); + return false; + } + + return false; + }); +})(); diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon16.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon16.png new file mode 100644 index 0000000..a2d86ab Binary files /dev/null and b/BrowserExtensions/HireflowInteractionTracker/icons/icon16.png differ diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon48.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon48.png new file mode 100644 index 0000000..91d82f3 Binary files /dev/null and b/BrowserExtensions/HireflowInteractionTracker/icons/icon48.png differ diff --git a/BrowserExtensions/HireflowInteractionTracker/icons/icon96.png b/BrowserExtensions/HireflowInteractionTracker/icons/icon96.png new file mode 100644 index 0000000..4a70ad0 Binary files /dev/null and b/BrowserExtensions/HireflowInteractionTracker/icons/icon96.png differ diff --git a/BrowserExtensions/HireflowInteractionTracker/manifest.json b/BrowserExtensions/HireflowInteractionTracker/manifest.json new file mode 100644 index 0000000..8cd4f25 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 3, + "name": "Hireflow Interaction Tracker", + "version": "1.0.0", + "description": "Record page interactions (clicks, typing) and export as JSON with XPath selectors.", + "permissions": ["activeTab", "scripting", "storage"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Hireflow Interaction Tracker", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "96": "icons/icon96.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "96": "icons/icon96.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "hireflow-interaction-tracker@hireflow.local", + "strict_min_version": "109.0" + } + } +} diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.css b/BrowserExtensions/HireflowInteractionTracker/popup.css new file mode 100644 index 0000000..37d4ff9 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.css @@ -0,0 +1,119 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + color: #1a1a1a; + background: #f4f4f5; +} + +.panel { + padding: 14px 16px 16px; +} + +.title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; +} + +.hint { + margin: 0 0 8px; + line-height: 1.45; + color: #52525b; +} + +.nav-hint { + margin: 0 0 12px; + font-size: 12px; + color: #71717a; +} + +.nav-hint.hidden, +.hidden { + display: none !important; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.btn { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #2563eb; + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: #1d4ed8; +} + +.btn-danger { + background: #dc2626; + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #b91c1c; +} + +.btn-secondary { + flex: 0 0 auto; + margin-top: 8px; + background: #e4e4e7; + color: #18181b; +} + +.btn-secondary:hover:not(:disabled) { + background: #d4d4d8; +} + +.export { + margin-top: 4px; +} + +.export-label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 13px; +} + +#export-json { + width: 100%; + padding: 10px; + border: 1px solid #d4d4d8; + border-radius: 6px; + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 12px; + line-height: 1.4; + resize: vertical; + background: #fff; +} + +.copy-status { + min-height: 1.2em; + margin: 6px 0 0; + font-size: 12px; + color: #16a34a; +} diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.html b/BrowserExtensions/HireflowInteractionTracker/popup.html new file mode 100644 index 0000000..20b8db7 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.html @@ -0,0 +1,33 @@ + + + + + + Hireflow Interaction Tracker + + + +
+

Interacties

+

+ Open een webpagina en druk op Opnemen. Na Stop kun je de JSON kopiëren. +

+ +
+ + +
+ +
+ + + diff --git a/BrowserExtensions/HireflowInteractionTracker/popup.js b/BrowserExtensions/HireflowInteractionTracker/popup.js new file mode 100644 index 0000000..95a3290 --- /dev/null +++ b/BrowserExtensions/HireflowInteractionTracker/popup.js @@ -0,0 +1,216 @@ +const TRACKER_FILE = "content/tracker.js"; + +function storageGet(keys) { + return new Promise((resolve) => { + chrome.storage.local.get(keys, resolve); + }); +} + +function storageSet(obj) { + return new Promise((resolve) => { + chrome.storage.local.set(obj, resolve); + }); +} + +function storageRemove(keys) { + return new Promise((resolve) => { + chrome.storage.local.remove(keys, resolve); + }); +} + +function tabsQuery(queryInfo) { + return new Promise((resolve) => { + chrome.tabs.query(queryInfo, resolve); + }); +} + +function executeScript(details) { + return new Promise((resolve, reject) => { + chrome.scripting.executeScript(details, () => { + const err = chrome.runtime.lastError; + if (err) { + reject(new Error(err.message)); + return; + } + resolve(); + }); + }); +} + +const els = { + record: document.getElementById("record"), + stop: document.getElementById("stop"), + exportSection: document.getElementById("export-section"), + exportJson: document.getElementById("export-json"), + copy: document.getElementById("copy"), + copyStatus: document.getElementById("copy-status"), + hint: document.getElementById("hint"), + navHint: document.getElementById("nav-hint"), +}; + +function isRestrictedUrl(url) { + if (!url) { + return true; + } + const u = url.toLowerCase(); + return ( + u.startsWith("chrome://") || + u.startsWith("chrome-extension://") || + u.startsWith("edge://") || + u.startsWith("about:") || + u.startsWith("moz-extension://") || + u.startsWith("devtools:") || + u.startsWith("view-source:") + ); +} + +async function getActiveTab() { + const tabs = await tabsQuery({ active: true, currentWindow: true }); + return tabs[0]; +} + +async function injectTracker(tabId) { + await executeScript({ + target: { tabId }, + files: [TRACKER_FILE], + }); +} + +function sendToTab(tabId, message) { + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, message, (response) => { + const err = chrome.runtime.lastError; + if (err) { + reject(new Error(err.message)); + return; + } + resolve(response); + }); + }); +} + +async function clearRecordingStorage() { + await storageRemove(["recording", "recordingTabId"]); +} + +function setRecordingUi(isRecording, showExport) { + els.record.classList.toggle("hidden", isRecording); + els.stop.classList.toggle("hidden", !isRecording); + els.stop.disabled = !isRecording; + els.navHint.classList.toggle("hidden", !isRecording); + els.exportSection.classList.toggle("hidden", !showExport); +} + +async function syncStateFromTab() { + const tab = await getActiveTab(); + const data = await storageGet(["recording", "recordingTabId"]); + + if (!tab?.id) { + setRecordingUi(false, !!els.exportJson.value); + return; + } + + if (!data.recording || data.recordingTabId !== tab.id) { + setRecordingUi(false, !!els.exportJson.value); + return; + } + + if (isRestrictedUrl(tab.url)) { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + return; + } + + try { + await injectTracker(tab.id); + const status = await sendToTab(tab.id, { type: "GET_STATUS" }); + if (status && status.recording) { + setRecordingUi(true, !!els.exportJson.value); + } else { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + } + } catch { + await clearRecordingStorage(); + setRecordingUi(false, !!els.exportJson.value); + } +} + +els.record.addEventListener("click", async () => { + els.copyStatus.textContent = ""; + const tab = await getActiveTab(); + if (!tab?.id) { + return; + } + if (isRestrictedUrl(tab.url)) { + els.hint.textContent = + "Opnemen werkt niet op deze pagina (browser-interne URL). Open een normale website."; + return; + } + + try { + await injectTracker(tab.id); + await sendToTab(tab.id, { type: "START_RECORDING" }); + await storageSet({ + recording: true, + recordingTabId: tab.id, + }); + els.exportJson.value = ""; + setRecordingUi(true, false); + els.hint.textContent = + "Interacties worden vastgelegd. Druk op Stop om de JSON te tonen."; + } catch (e) { + els.hint.textContent = + "Kon niet starten: ververs de pagina of controleer of de site scripts toestaat."; + console.error(e); + } +}); + +els.stop.addEventListener("click", async () => { + els.copyStatus.textContent = ""; + const tab = await getActiveTab(); + const data = await storageGet(["recordingTabId"]); + + if (!tab?.id || tab.id !== data.recordingTabId) { + await clearRecordingStorage(); + setRecordingUi(false, false); + els.hint.textContent = "Geen actieve opname op dit tabblad."; + return; + } + + try { + await injectTracker(tab.id); + const response = await sendToTab(tab.id, { + type: "STOP_AND_GET_INTERACTIONS", + }); + await clearRecordingStorage(); + + const interactions = response?.interactions ?? []; + els.exportJson.value = JSON.stringify(interactions, null, 2); + setRecordingUi(false, true); + els.hint.textContent = + "JSON hieronder. Gebruik Kopiëren of selecteer de tekst handmatig."; + } catch (e) { + await clearRecordingStorage(); + setRecordingUi(false, false); + els.hint.textContent = + "Stoppen mislukt (pagina mogelijk vernieuwd). Start opnieuw met Opnemen."; + console.error(e); + } +}); + +els.copy.addEventListener("click", async () => { + const text = els.exportJson.value; + if (!text) { + return; + } + try { + await navigator.clipboard.writeText(text); + els.copyStatus.textContent = "Gekopieerd naar klembord."; + } catch { + els.exportJson.select(); + els.copyStatus.textContent = "Selecteer Cmd/Ctrl+C om te kopiëren."; + } +}); + +syncStateFromTab();