diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 2651c07..b493a98 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -257,6 +257,63 @@ "message": "Schließen", "description": "Schaltfläche zum Schließen des Gruppenverwaltungsdialogs." }, + "optionsSelectorsLabel": { + "message": "Erfasste Selektoren", + "description": "Beschriftung für den Bereich zur Verwaltung der Selektoren." + }, + "optionsSelectorsHint": { + "message": "Wählen Sie Elemente über das Popup oder fügen Sie Selektoren manuell hinzu. Maximal 10 pro Webhook.", + "description": "Hinweistext, der erklärt, wie Selektoren verwaltet werden." + }, + "optionsSelectorPlaceholder": { + "message": ".main-content h1", + "description": "Platzhaltertext für das Selektor-Eingabefeld." + }, + "optionsAddSelectorButton": { + "message": "Selektor hinzufügen", + "description": "Button-Text zum manuellen Hinzufügen eines Selektors." + }, + "optionsSelectorsEmptyMessage": { + "message": "Noch keine Selektoren gespeichert. Verwenden Sie den Auswahlmodus im Popup oder fügen Sie einen Selektor manuell hinzu.", + "description": "Hinweistext, wenn noch keine Selektoren vorhanden sind." + }, + "optionsSelectorMoveUpButton": { + "message": "↑", + "description": "Button-Beschriftung, um einen Selektor nach oben zu verschieben." + }, + "optionsSelectorMoveDownButton": { + "message": "↓", + "description": "Button-Beschriftung, um einen Selektor nach unten zu verschieben." + }, + "optionsSelectorEditButton": { + "message": "Bearbeiten", + "description": "Button-Beschriftung zum Bearbeiten eines Selektors." + }, + "optionsSelectorDeleteButton": { + "message": "Entfernen", + "description": "Button-Beschriftung zum Löschen eines Selektors." + }, + "optionsSelectorEmptyError": { + "message": "Selektor darf nicht leer sein.", + "description": "Fehlermeldung, wenn das Selektor-Eingabefeld leer ist." + }, + "optionsSelectorLimitError": { + "message": "Pro Webhook können maximal $1 Selektoren gespeichert werden.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Fehlermeldung, wenn das Selektoren-Limit überschritten wird." + }, + "optionsSelectorDuplicateError": { + "message": "Dieser Selektor ist bereits vorhanden.", + "description": "Fehlermeldung, wenn ein Selektor doppelt vorkommt." + }, + "optionsSelectorEditPrompt": { + "message": "Selektor bearbeiten", + "description": "Prompt-Text beim Bearbeiten eines Selektors." + }, "optionsImportInfo": { "message": "Beim Import werden vorhandene Webhooks ersetzt.", "description": "Hinweistext neben dem Import-Button." @@ -300,5 +357,117 @@ "optionsCloseAppearanceButton": { "message": "Schließen", "description": "Schaltfläche zum Schließen des Erscheinungsbild-Dialogs." + }, + "popupResponseHeader": { + "message": "Letzte Antwort", + "description": "Überschrift für den Antwortbereich im Popup." + }, + "popupCopyResponseButton": { + "message": "Kopieren", + "description": "Buttonbeschriftung zum Kopieren der Antwort." + }, + "popupCopySuccess": { + "message": "Antwort wurde in die Zwischenablage kopiert.", + "description": "Statusmeldung, wenn das Kopieren erfolgreich war." + }, + "popupCopyError": { + "message": "Antwort konnte nicht kopiert werden.", + "description": "Statusmeldung, wenn das Kopieren fehlgeschlagen ist." + }, + "popupCaptureButtonLabel": { + "message": "Auswahl ($1/$2)", + "placeholders": { + "current": { + "content": "$1" + }, + "max": { + "content": "$2" + } + }, + "description": "Beschriftung für den Button zum Erfassen von Selektoren. Platzhalter für aktuelle Anzahl und Maximum." + }, + "popupCaptureLimitReachedTooltip": { + "message": "Maximal $1 Selektoren erreicht.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Hinweis, wenn das Selektoren-Limit erreicht wurde." + }, + "popupCaptureStarted": { + "message": "Auswahlmodus aktiv. $1 Selektoren verbleiben. Klicken Sie auf Elemente, um Text zu speichern, drücken Sie Esc zum Beenden.", + "placeholders": { + "remaining": { + "content": "$1" + } + }, + "description": "Statusmeldung nach dem Start des Auswahlmodus." + }, + "popupCaptureStartError": { + "message": "Auswahlmodus konnte nicht gestartet werden. Bitte laden Sie die Seite neu.", + "description": "Statusmeldung, wenn der Auswahlmodus nicht gestartet werden konnte." + }, + "popupCaptureContentNotAvailable": { + "message": "Für diese Seite steht kein Auswahlmodus zur Verfügung.", + "description": "Hinweis, wenn das Inhalts-Skript nicht geladen werden kann oder die Seite blockiert ist." + }, + "popupNoActiveTabError": { + "message": "Kein aktiver Tab gefunden.", + "description": "Fehlermeldung, wenn kein aktiver Tab vorhanden ist." + }, + "popupCaptureDuplicate": { + "message": "Dieses Element wurde bereits erfasst.", + "description": "Statusmeldung, wenn derselbe Selektor erneut erfasst wird." + }, + "popupCaptureSaved": { + "message": "„$1“ gespeichert. $2 Selektoren verbleiben.", + "placeholders": { + "preview": { + "content": "$1" + }, + "remaining": { + "content": "$2" + } + }, + "description": "Statusmeldung, wenn ein Selektor erfolgreich erfasst wurde." + }, + "popupCaptureGenericError": { + "message": "Dieses Element kann nicht erfasst werden.", + "description": "Allgemeine Fehlermeldung, wenn die Auswahl fehlschlägt." + }, + "popupCaptureEmptyText": { + "message": "Das ausgewählte Element enthält keinen Text.", + "description": "Fehlermeldung, wenn das Element keinen Text besitzt." + }, + "popupCaptureNoSelector": { + "message": "Für dieses Element konnte kein Selektor bestimmt werden.", + "description": "Fehlermeldung, wenn kein Selektor generiert werden kann." + }, + "popupCaptureEnded": { + "message": "Auswahlmodus beendet. Es können bis zu $1 Selektoren gespeichert werden.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Statusmeldung, wenn der Auswahlmodus endet." + }, + "popupCaptureCancelled": { + "message": "Auswahl abgebrochen. Bis zu $1 Selektoren können gespeichert werden.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Statusmeldung, wenn der Auswahlmodus abgebrochen wird." + }, + "popupCaptureUnsupported": { + "message": "Der Auswahlmodus ist in diesem Kontext nicht verfügbar.", + "description": "Wird angezeigt, wenn der Browser den Auswahlmodus nicht unterstützt." + }, + "selectorCaptureTooltip": { + "message": "Klicken Sie auf Elemente, um deren Text zu erfassen. Drücken Sie Esc zum Beenden.", + "description": "Tooltip, der im Auswahlmodus angezeigt wird." } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3d28615..5ce8a95 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -257,6 +257,63 @@ "message": "Close", "description": "Button to close the group management modal." }, + "optionsSelectorsLabel": { + "message": "Captured selectors", + "description": "Label for the selectors management section." + }, + "optionsSelectorsHint": { + "message": "Select elements via the popup or add selectors manually. Maximum of 10 per webhook.", + "description": "Helper text explaining how selectors are managed." + }, + "optionsSelectorPlaceholder": { + "message": ".main-content h1", + "description": "Placeholder text for the selector input." + }, + "optionsAddSelectorButton": { + "message": "Add selector", + "description": "Button text to add a selector manually." + }, + "optionsSelectorsEmptyMessage": { + "message": "No selectors saved yet. Use the popup capture mode or add one manually.", + "description": "Message shown when no selectors are stored." + }, + "optionsSelectorMoveUpButton": { + "message": "↑", + "description": "Button label to move a selector up." + }, + "optionsSelectorMoveDownButton": { + "message": "↓", + "description": "Button label to move a selector down." + }, + "optionsSelectorEditButton": { + "message": "Edit", + "description": "Button label to edit a selector." + }, + "optionsSelectorDeleteButton": { + "message": "Remove", + "description": "Button label to delete a selector." + }, + "optionsSelectorEmptyError": { + "message": "Selector cannot be empty.", + "description": "Error shown when the selector input is empty." + }, + "optionsSelectorLimitError": { + "message": "You can store up to $1 selectors per webhook.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Error shown when the selector limit is exceeded." + }, + "optionsSelectorDuplicateError": { + "message": "This selector is already on the list.", + "description": "Error shown when the selector already exists." + }, + "optionsSelectorEditPrompt": { + "message": "Edit selector", + "description": "Prompt message shown when editing a selector." + }, "optionsImportInfo": { "message": "Importing replaces all existing webhooks.", "description": "Information text shown next to the import button." @@ -301,5 +358,117 @@ "optionsCloseAppearanceButton": { "message": "Close", "description": "Button to close the appearance modal." + }, + "popupResponseHeader": { + "message": "Latest Response", + "description": "Header for the response panel in the popup." + }, + "popupCopyResponseButton": { + "message": "Copy", + "description": "Button label for copying the webhook response." + }, + "popupCopySuccess": { + "message": "Response copied to clipboard.", + "description": "Status message shown when copying the response succeeds." + }, + "popupCopyError": { + "message": "Unable to copy the response.", + "description": "Status message shown when copying the response fails." + }, + "popupCaptureButtonLabel": { + "message": "Capture ($1/$2)", + "placeholders": { + "current": { + "content": "$1" + }, + "max": { + "content": "$2" + } + }, + "description": "Label for the selector capture button. Placeholders are current count and max selectors." + }, + "popupCaptureLimitReachedTooltip": { + "message": "Maximum of $1 selectors reached.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Tooltip or status message shown when the selector limit is reached." + }, + "popupCaptureStarted": { + "message": "Capture mode active. $1 selectors remaining. Click elements to save text, press Esc to stop.", + "placeholders": { + "remaining": { + "content": "$1" + } + }, + "description": "Status message shown after capture mode starts." + }, + "popupCaptureStartError": { + "message": "Failed to start selector capture. Try reloading the page.", + "description": "Status message shown when capture mode cannot be started." + }, + "popupCaptureContentNotAvailable": { + "message": "No eligible content for selector capture on this page.", + "description": "Warning shown when the content script is unavailable or the page blocks injection." + }, + "popupNoActiveTabError": { + "message": "Unable to find an active tab.", + "description": "Error shown when there is no active tab." + }, + "popupCaptureDuplicate": { + "message": "You already captured this element.", + "description": "Status message shown when the same selector is captured twice." + }, + "popupCaptureSaved": { + "message": "Saved “$1”. $2 selectors remaining.", + "placeholders": { + "preview": { + "content": "$1" + }, + "remaining": { + "content": "$2" + } + }, + "description": "Status message shown when a selector has been captured successfully. First placeholder is a preview of the text, second is remaining count." + }, + "popupCaptureGenericError": { + "message": "Unable to capture this element.", + "description": "Generic error shown when capture fails." + }, + "popupCaptureEmptyText": { + "message": "The selected element does not contain any text.", + "description": "Error shown when the element has no text content." + }, + "popupCaptureNoSelector": { + "message": "Could not determine a selector for this element.", + "description": "Error shown when a selector cannot be generated." + }, + "popupCaptureEnded": { + "message": "Capture mode ended. You can store up to $1 selectors.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Status message shown when capture mode stops." + }, + "popupCaptureCancelled": { + "message": "Capture cancelled. Up to $1 selectors can be saved.", + "placeholders": { + "limit": { + "content": "$1" + } + }, + "description": "Status message shown when the user cancels capture mode." + }, + "popupCaptureUnsupported": { + "message": "Selector capture is not available in this context.", + "description": "Displayed when the browser does not support selector capture in the current environment." + }, + "selectorCaptureTooltip": { + "message": "Click elements to capture their text. Press Esc to stop.", + "description": "Tooltip shown during selector capture mode." } } diff --git a/content-scripts/selector-capture.js b/content-scripts/selector-capture.js new file mode 100644 index 0000000..dbd438f --- /dev/null +++ b/content-scripts/selector-capture.js @@ -0,0 +1,380 @@ +(() => { + const browserAPI = typeof browser !== "undefined" ? browser : chrome; + const MAX_SELECTORS_DEFAULT = 10; + + let isCapturing = false; + let currentWebhookId = null; + let selectorSet = new Set(); + let selectorLimit = MAX_SELECTORS_DEFAULT; + let highlightBox = null; + let tooltip = null; + let lastHoveredElement = null; + + const TOOLTIP_ID = "webhook-trigger-selector-tooltip"; + const HIGHLIGHT_ID = "webhook-trigger-selector-highlight"; + + const normalizeSelectors = (selectors) => { + if (!Array.isArray(selectors)) return []; + return selectors + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0); + }; + + const persistSelectorLocally = async (selector) => { + if (!currentWebhookId || !selector) return; + try { + const data = await browserAPI.storage.sync.get("webhooks"); + const webhooks = Array.isArray(data?.webhooks) ? data.webhooks : []; + const index = webhooks.findIndex((wh) => wh.id === currentWebhookId); + if (index === -1) { + return; + } + const selectors = normalizeSelectors(webhooks[index].selectors); + if (selectors.includes(selector)) { + return; + } + if (selectors.length >= selectorLimit) { + return; + } + selectors.push(selector); + webhooks[index] = { ...webhooks[index], selectors }; + await browserAPI.storage.sync.set({ webhooks }); + } catch (error) { + console.debug("Failed to persist selector locally", error); + } + }; + + const sendRuntimeMessage = (payload) => { + const message = { origin: "content-script", ...payload }; + try { + const result = browserAPI.runtime.sendMessage(message); + if (result && typeof result.then === "function" && typeof result.catch === "function") { + result.catch((error) => { + console.debug("Runtime message rejected", error); + }); + } + } catch (error) { + // Ignore messaging errors when popup/background is unavailable + console.debug("Failed to send runtime message", error); + } + }; + + const createHighlightElements = () => { + if (!highlightBox) { + highlightBox = document.createElement("div"); + highlightBox.id = HIGHLIGHT_ID; + Object.assign(highlightBox.style, { + position: "fixed", + pointerEvents: "none", + border: "2px solid #2563eb", + backgroundColor: "rgba(37, 99, 235, 0.15)", + zIndex: "2147483646", + transition: "all 0.1s ease", + }); + document.documentElement.appendChild(highlightBox); + } + + if (!tooltip) { + tooltip = document.createElement("div"); + tooltip.id = TOOLTIP_ID; + Object.assign(tooltip.style, { + position: "fixed", + pointerEvents: "none", + padding: "6px 10px", + backgroundColor: "#2563eb", + color: "#fff", + borderRadius: "4px", + fontSize: "12px", + zIndex: "2147483647", + boxShadow: "0 2px 10px rgba(0,0,0,0.2)", + maxWidth: "280px", + lineHeight: "1.4", + }); + tooltip.innerText = browserAPI?.i18n?.getMessage + ? browserAPI.i18n.getMessage("selectorCaptureTooltip") || "Click elements to capture their text. Press Esc to stop." + : "Click elements to capture their text. Press Esc to stop."; + document.documentElement.appendChild(tooltip); + } + }; + + const removeHighlightElements = () => { + if (highlightBox && highlightBox.parentNode) { + highlightBox.parentNode.removeChild(highlightBox); + } + if (tooltip && tooltip.parentNode) { + tooltip.parentNode.removeChild(tooltip); + } + highlightBox = null; + tooltip = null; + }; + + const updateHighlightPosition = (element, pointerEvent) => { + if (!highlightBox || !element) return; + const rect = element.getBoundingClientRect(); + Object.assign(highlightBox.style, { + top: `${rect.top}px`, + left: `${rect.left}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + display: "block", + }); + + if (tooltip) { + const padding = 8; + let top = pointerEvent.clientY + padding; + let left = pointerEvent.clientX + padding; + + if (left + tooltip.offsetWidth > window.innerWidth) { + left = pointerEvent.clientX - tooltip.offsetWidth - padding; + } + if (top + tooltip.offsetHeight > window.innerHeight) { + top = pointerEvent.clientY - tooltip.offsetHeight - padding; + } + + tooltip.style.top = `${Math.max(top, 0)}px`; + tooltip.style.left = `${Math.max(left, 0)}px`; + } + }; + + const clearHighlight = () => { + if (highlightBox) { + highlightBox.style.display = "none"; + } + if (tooltip) { + tooltip.style.display = "none"; + } + }; + + const normalizeElement = (node) => { + if (!node) return null; + return node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + }; + + const shouldIgnoreElement = (element) => { + if (!element) return true; + if (element === document.documentElement || element === document.body) return false; + if (element.id === HIGHLIGHT_ID || element.id === TOOLTIP_ID) return true; + if (element.closest(`#${HIGHLIGHT_ID}`) || element.closest(`#${TOOLTIP_ID}`)) return true; + return false; + }; + + const buildSelector = (element) => { + if (!element || element.nodeType !== Node.ELEMENT_NODE) return null; + + if (element.id) { + return `#${CSS.escape(element.id)}`; + } + + const parts = []; + let el = element; + while (el && el.nodeType === Node.ELEMENT_NODE && el !== document.documentElement) { + let selector = el.tagName.toLowerCase(); + if (el.classList.length > 0) { + selector += [...el.classList].slice(0, 3).map(cls => `.${CSS.escape(cls)}`).join(""); + } + + const siblings = el.parentElement + ? [...el.parentElement.children].filter(child => child.tagName === el.tagName) + : []; + if (siblings.length > 1) { + const index = siblings.indexOf(el) + 1; + selector += `:nth-of-type(${index})`; + } + + parts.unshift(selector); + el = el.parentElement; + } + + parts.unshift("html"); + const selectorString = parts.join(" > "); + + try { + const matches = document.querySelectorAll(selectorString); + if (matches.length === 1) { + return selectorString; + } + } catch (_) { + // Invalid selector built, fallback to shorter strategy + } + + // Fallback: rely on full path without uniqueness check + return selectorString; + }; + + const extractPlainText = (element) => { + if (!element) return ""; + const text = element.innerText || element.textContent || ""; + return text.replace(/\s+/g, " ").trim(); + }; + + const stopCapture = (reason = "completed") => { + if (!isCapturing) return; + isCapturing = false; + currentWebhookId = null; + selectorSet = new Set(); + selectorLimit = MAX_SELECTORS_DEFAULT; + lastHoveredElement = null; + + document.removeEventListener("mousemove", handleMouseMove, true); + document.removeEventListener("mouseover", handleMouseMove, true); + document.removeEventListener("click", handleClick, true); + document.removeEventListener("keydown", handleKeydown, true); + + clearHighlight(); + removeHighlightElements(); + + sendRuntimeMessage({ type: "SELECTOR_CAPTURE_ENDED", reason }); + }; + + const handleMouseMove = (event) => { + if (!isCapturing) return; + let element = normalizeElement(event.target); + if (shouldIgnoreElement(element)) { + element = null; + } + + lastHoveredElement = element; + if (element) { + createHighlightElements(); + tooltip.style.display = "block"; + updateHighlightPosition(element, event); + } else { + clearHighlight(); + } + }; + + const handleClick = (event) => { + if (!isCapturing) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const element = normalizeElement(event.target); + if (!element || shouldIgnoreElement(element)) { + return; + } + + if (selectorSet.size >= selectorLimit) { + sendRuntimeMessage({ type: "SELECTOR_CAPTURE_ERROR", reason: "limit-reached" }); + stopCapture("limit-reached"); + return; + } + + const selector = buildSelector(element); + if (!selector) { + sendRuntimeMessage({ type: "SELECTOR_CAPTURE_ERROR", reason: "no-selector" }); + return; + } + + if (selectorSet.has(selector)) { + sendRuntimeMessage({ type: "SELECTOR_CAPTURE_ERROR", reason: "duplicate", selector }); + return; + } + + const textContent = extractPlainText(element); + if (!textContent) { + sendRuntimeMessage({ type: "SELECTOR_CAPTURE_ERROR", reason: "empty-text", selector }); + return; + } + + selectorSet.add(selector); + + persistSelectorLocally(selector); + + removeHighlightElements(); + lastHoveredElement = null; + sendRuntimeMessage({ + type: "SELECTOR_CAPTURED", + selector, + textContent, + webhookId: currentWebhookId, + remaining: Math.max(selectorLimit - selectorSet.size, 0), + }); + + if (selectorSet.size >= selectorLimit) { + stopCapture("limit-reached"); + } + }; + + const handleKeydown = (event) => { + if (!isCapturing) return; + if (event.key === "Escape") { + event.preventDefault(); + stopCapture("cancelled"); + } + }; + + const startCapture = async ({ webhookId, existingSelectors = [], maxSelectors = MAX_SELECTORS_DEFAULT }) => { + if (!webhookId) { + return { ok: false, error: "missing-webhook" }; + } + + if (isCapturing) { + stopCapture("replaced"); + } + + isCapturing = true; + currentWebhookId = webhookId; + selectorSet = new Set(Array.isArray(existingSelectors) ? existingSelectors : []); + selectorLimit = typeof maxSelectors === "number" && maxSelectors > 0 ? maxSelectors : MAX_SELECTORS_DEFAULT; + + createHighlightElements(); + document.addEventListener("mousemove", handleMouseMove, true); + document.addEventListener("mouseover", handleMouseMove, true); + document.addEventListener("click", handleClick, true); + document.addEventListener("keydown", handleKeydown, true); + + return { ok: true, remaining: Math.max(selectorLimit - selectorSet.size, 0) }; + }; + + const getSelectorContent = (selectors = []) => { + if (!Array.isArray(selectors) || selectors.length === 0) { + return []; + } + return selectors.map((selector) => { + try { + const element = document.querySelector(selector); + return extractPlainText(element); + } catch (error) { + console.warn("Failed to query selector", selector, error); + return ""; + } + }); + }; + + browserAPI.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || typeof message !== "object" || !message.type) { + return undefined; + } + + if (message.type === "START_SELECTOR_CAPTURE") { + startCapture(message).then(sendResponse); + return true; + } + + if (message.type === "STOP_SELECTOR_CAPTURE") { + stopCapture("stopped"); + sendResponse({ ok: true }); + return false; + } + + if (message.type === "GET_SELECTOR_CONTENT") { + try { + const selectorContent = getSelectorContent(message.selectors); + sendResponse({ ok: true, selectorContent }); + } catch (error) { + sendResponse({ ok: false, error: error?.message || "failed-to-get-selector-content" }); + } + return false; + } + + return undefined; + }); + + // Clean up when the page is unloaded + window.addEventListener("pagehide", () => { + if (isCapturing) { + stopCapture("page-hidden"); + } + }); +})(); diff --git a/manifest.json b/manifest.json index ee48c6c..aa319df 100644 --- a/manifest.json +++ b/manifest.json @@ -4,8 +4,15 @@ "version": "1.10.1", "description": "__MSG_extensionDescription__", "default_locale": "en", - "permissions": ["storage", "activeTab"], + "permissions": ["storage", "activeTab", "scripting", "tabs"], "host_permissions": [""], + "content_scripts": [ + { + "matches": [""], + "js": ["content-scripts/selector-capture.js"], + "run_at": "document_idle" + } + ], "action": { "default_popup": "popup/popup.html", "default_icon": { diff --git a/options/options.css b/options/options.css index 64538fc..9debf6f 100644 --- a/options/options.css +++ b/options/options.css @@ -50,6 +50,100 @@ label { color: #4b5563; } +.form-hint { + margin: -4px 0 10px 0; + font-size: 0.9em; + color: #6b7280; +} + +.selectors-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.selectors-count { + font-size: 0.85em; + color: #6b7280; + background: #eef2ff; + border-radius: 9999px; + padding: 2px 10px; +} + +.selectors-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.selector-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #f9fafb; +} + +.selector-item code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.9em; + color: #1f2937; + word-break: break-word; + white-space: pre-wrap; +} + +.selector-actions { + display: flex; + gap: 6px; +} + +.selector-action-btn { + background: #e5e7eb; + color: #1f2937; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.75em; + box-shadow: none; + border: none; +} + +.selector-action-btn:hover { + background: #d1d5db; + transform: none; +} + +.selector-input-row { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.selector-input-row input { + flex: 1; +} + +.error-message { + color: #b91c1c; + font-size: 0.85em; + margin-top: 6px; +} + +.selector-empty { + padding: 10px 12px; + border: 1px dashed #d1d5db; + border-radius: 8px; + background: #f9fafb; + color: #6b7280; + font-size: 0.9em; +} + input[type="text"], input[type="url"], select, diff --git a/options/options.html b/options/options.html index 8a55e52..79ce0e4 100644 --- a/options/options.html +++ b/options/options.html @@ -83,6 +83,19 @@

__MSG_optionsAddWebhookHeader__

+
+
+ + 0/10 +
+

__MSG_optionsSelectorsHint__

+
    +
    + + +
    + +
    diff --git a/options/options.js b/options/options.js index 395c925..68c5bf6 100644 --- a/options/options.js +++ b/options/options.js @@ -1,8 +1,27 @@ const browser = window.getBrowserAPI(); +const MAX_SELECTORS_PER_WEBHOOK = 10; + +const normalizeWebhookRecord = (webhook) => { + const normalizedHeaders = Array.isArray(webhook.headers) ? webhook.headers : []; + const normalizedSelectors = Array.isArray(webhook.selectors) + ? webhook.selectors + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter((value) => value.length > 0) + .slice(0, MAX_SELECTORS_PER_WEBHOOK) + : []; + + return { + ...webhook, + headers: normalizedHeaders, + emoji: webhook.emoji || "", + selectors: normalizedSelectors, + }; +}; // Function to load and display webhooks const loadWebhooks = async () => { const { webhooks = [], groups = [] } = await browser.storage.sync.get(["webhooks", "groups"]); + const normalizedWebhooks = webhooks.map(normalizeWebhookRecord); const list = document.getElementById("webhook-list"); const message = document.getElementById("no-webhooks-message"); list.innerHTML = ""; @@ -30,7 +49,7 @@ const loadWebhooks = async () => { groupSelect.appendChild(option); }); - if (webhooks.length === 0) { + if (normalizedWebhooks.length === 0) { message.classList.remove("hidden"); message.textContent = browser.i18n.getMessage("optionsNoWebhooksMessage"); } else { @@ -38,7 +57,7 @@ const loadWebhooks = async () => { // Group webhooks by group ID const groupedWebhooks = {}; - webhooks.forEach(webhook => { + normalizedWebhooks.forEach(webhook => { const groupId = webhook.groupId || "ungrouped"; if (!groupedWebhooks[groupId]) { groupedWebhooks[groupId] = []; @@ -166,7 +185,8 @@ const loadWebhooks = async () => { // Function to save webhooks const saveWebhooks = (webhooks) => { - return browser.storage.sync.set({ webhooks }); + const normalized = Array.isArray(webhooks) ? webhooks.map(normalizeWebhookRecord) : []; + return browser.storage.sync.set({ webhooks: normalized }); }; // Function to save groups @@ -412,10 +432,17 @@ const clearEmojiBtn = document.getElementById('clear-emoji-btn'); const emojiPicker = document.getElementById('emoji-picker'); const emojiGrid = document.getElementById('emoji-grid'); let headers = []; +const selectorsList = document.getElementById("selectors-list"); +const selectorInput = document.getElementById("selector-input"); +const addSelectorBtn = document.getElementById("add-selector-btn"); +const selectorError = document.getElementById("selector-error"); +const selectorsCountLabel = document.getElementById("selectors-count"); +let selectors = []; async function exportWebhooks() { const { webhooks = [] } = await browser.storage.sync.get("webhooks"); - const blob = new Blob([JSON.stringify({ webhooks }, null, 2)], { + const normalized = webhooks.map(normalizeWebhookRecord); + const blob = new Blob([JSON.stringify({ webhooks: normalized }, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); @@ -434,8 +461,7 @@ async function handleImport(event) { const data = JSON.parse(text); const hooks = Array.isArray(data) ? data : data.webhooks; if (Array.isArray(hooks)) { - // Backward compatibility: ensure emoji field exists - const normalized = hooks.map(h => ({ ...h, emoji: h.emoji || "" })); + const normalized = hooks.map(normalizeWebhookRecord); await saveWebhooks(normalized); loadWebhooks(); } @@ -449,7 +475,7 @@ async function handleImport(event) { // Export webhooks and groups const exportData = async () => { const { webhooks = [], groups = [] } = await browser.storage.sync.get(["webhooks", "groups"]); - const data = { webhooks, groups }; + const data = { webhooks: webhooks.map(normalizeWebhookRecord), groups }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -471,8 +497,7 @@ const importData = async (file) => { const data = JSON.parse(event.target.result); let webhooks = Array.isArray(data.webhooks) ? data.webhooks : []; const groups = Array.isArray(data.groups) ? data.groups : []; - // Ensure emoji field exists - webhooks = webhooks.map(h => ({ ...h, emoji: h.emoji || "" })); + webhooks = webhooks.map(normalizeWebhookRecord); await browser.storage.sync.set({ webhooks, groups }); await loadWebhooks(); if (typeof renderGroups === 'function') await renderGroups(); @@ -487,7 +512,7 @@ const importData = async (file) => { if (exportWebhooksBtn) { exportWebhooksBtn.addEventListener("click", async () => { const { webhooks = [], groups = [] } = await browser.storage.sync.get(["webhooks", "groups"]); - const data = { webhooks, groups }; + const data = { webhooks: webhooks.map(normalizeWebhookRecord), groups }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -768,6 +793,196 @@ function renderHeaders() { }); } +function setSelectorError(message) { + if (!selectorError) return; + if (!message) { + selectorError.textContent = ''; + selectorError.classList.add('hidden'); + } else { + selectorError.textContent = message; + selectorError.classList.remove('hidden'); + } +} + +function clearSelectorError() { + setSelectorError(''); +} + +function updateSelectorsCount() { + if (selectorsCountLabel) { + selectorsCountLabel.textContent = `${selectors.length}/${MAX_SELECTORS_PER_WEBHOOK}`; + } +} + +function createSelectorListItem(selectorValue, index) { + const item = document.createElement('li'); + item.className = 'selector-item'; + item.dataset.index = String(index); + + const code = document.createElement('code'); + code.textContent = selectorValue; + item.appendChild(code); + + const actions = document.createElement('div'); + actions.className = 'selector-actions'; + + const moveUpBtn = document.createElement('button'); + moveUpBtn.type = 'button'; + moveUpBtn.className = 'selector-action-btn selector-up-btn'; + moveUpBtn.textContent = browser.i18n.getMessage('optionsSelectorMoveUpButton') || '↑'; + moveUpBtn.disabled = index === 0; + actions.appendChild(moveUpBtn); + + const moveDownBtn = document.createElement('button'); + moveDownBtn.type = 'button'; + moveDownBtn.className = 'selector-action-btn selector-down-btn'; + moveDownBtn.textContent = browser.i18n.getMessage('optionsSelectorMoveDownButton') || '↓'; + moveDownBtn.disabled = index === selectors.length - 1; + actions.appendChild(moveDownBtn); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'selector-action-btn selector-edit-btn'; + editBtn.textContent = browser.i18n.getMessage('optionsSelectorEditButton') || 'Edit'; + actions.appendChild(editBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'selector-action-btn selector-delete-btn'; + deleteBtn.textContent = browser.i18n.getMessage('optionsSelectorDeleteButton') || 'Delete'; + actions.appendChild(deleteBtn); + + item.appendChild(actions); + return item; +} + +function renderSelectors() { + if (!selectorsList) return; + selectors = selectors + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0) + .slice(0, MAX_SELECTORS_PER_WEBHOOK); + + selectorsList.textContent = ''; + updateSelectorsCount(); + clearSelectorError(); + + const limitReached = selectors.length >= MAX_SELECTORS_PER_WEBHOOK; + if (addSelectorBtn) addSelectorBtn.disabled = limitReached; + if (selectorInput) selectorInput.disabled = limitReached; + + if (selectors.length === 0) { + const empty = document.createElement('li'); + empty.className = 'selector-empty'; + empty.textContent = browser.i18n.getMessage('optionsSelectorsEmptyMessage') || 'No selectors captured yet.'; + selectorsList.appendChild(empty); + return; + } + + selectors.forEach((selectorValue, index) => { + selectorsList.appendChild(createSelectorListItem(selectorValue, index)); + }); +} + +function addSelectorValue(value) { + const trimmed = (value || '').trim(); + if (!trimmed) { + setSelectorError(browser.i18n.getMessage('optionsSelectorEmptyError') || 'Selector cannot be empty.'); + return false; + } + if (selectors.length >= MAX_SELECTORS_PER_WEBHOOK) { + setSelectorError( + browser.i18n.getMessage('optionsSelectorLimitError', [String(MAX_SELECTORS_PER_WEBHOOK)]) || + `Maximum of ${MAX_SELECTORS_PER_WEBHOOK} selectors reached.` + ); + return false; + } + if (selectors.some((existing) => existing === trimmed)) { + setSelectorError(browser.i18n.getMessage('optionsSelectorDuplicateError') || 'Selector already exists.'); + return false; + } + selectors.push(trimmed); + renderSelectors(); + return true; +} + +function editSelectorAt(index) { + if (index < 0 || index >= selectors.length) return; + const currentValue = selectors[index]; + const promptLabel = browser.i18n.getMessage('optionsSelectorEditPrompt') || 'Edit selector:'; + const updated = window.prompt(promptLabel, currentValue); + if (updated === null) { + return; + } + const trimmed = updated.trim(); + if (!trimmed) { + setSelectorError(browser.i18n.getMessage('optionsSelectorEmptyError') || 'Selector cannot be empty.'); + return; + } + if (selectors.some((value, idx) => idx !== index && value === trimmed)) { + setSelectorError(browser.i18n.getMessage('optionsSelectorDuplicateError') || 'Selector already exists.'); + return; + } + selectors[index] = trimmed; + renderSelectors(); +} + +function removeSelectorAt(index) { + if (index < 0 || index >= selectors.length) return; + selectors.splice(index, 1); + renderSelectors(); +} + +function moveSelector(index, offset) { + const newIndex = index + offset; + if (newIndex < 0 || newIndex >= selectors.length) return; + const [item] = selectors.splice(index, 1); + selectors.splice(newIndex, 0, item); + renderSelectors(); +} + +if (addSelectorBtn) { + addSelectorBtn.addEventListener('click', () => { + if (addSelectorValue(selectorInput.value)) { + selectorInput.value = ''; + selectorInput.focus(); + } + }); +} + +if (selectorInput) { + selectorInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (addSelectorValue(selectorInput.value)) { + selectorInput.value = ''; + } + } + }); + selectorInput.addEventListener('input', () => clearSelectorError()); +} + +if (selectorsList) { + selectorsList.addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button) return; + const item = button.closest('li.selector-item'); + if (!item) return; + const index = Number(item.dataset.index); + if (Number.isNaN(index)) return; + + if (button.classList.contains('selector-delete-btn')) { + removeSelectorAt(index); + } else if (button.classList.contains('selector-edit-btn')) { + editSelectorAt(index); + } else if (button.classList.contains('selector-up-btn')) { + moveSelector(index, -1); + } else if (button.classList.contains('selector-down-btn')) { + moveSelector(index, 1); + } + }); +} + addHeaderBtn.addEventListener('click', () => { const key = headerKeyInput.value.trim(); const value = headerValueInput.value.trim(); @@ -813,7 +1028,8 @@ form.addEventListener("submit", async (e) => { customPayload: customPayload || null, urlFilter: urlFilter || "", groupId, - emoji: emoji || "" + emoji: emoji || "", + selectors: [...selectors] } : wh ); editWebhookId = null; @@ -830,7 +1046,8 @@ form.addEventListener("submit", async (e) => { customPayload: customPayload || null, urlFilter: urlFilter || "", groupId, - emoji: emoji || "" + emoji: emoji || "", + selectors: [...selectors] }; webhooks.push(newWebhook); } @@ -847,7 +1064,12 @@ form.addEventListener("submit", async (e) => { headerValueInput.value = ""; groupSelect.value = ""; headers = []; + selectors = []; renderHeaders(); + renderSelectors(); + if (selectorInput) { + selectorInput.value = ""; + } // Always reset to save button after submit form.querySelector('button[type="submit"]').textContent = browser.i18n.getMessage("optionsSaveButton") || "Save Webhook"; // Collapse custom payload section @@ -924,6 +1146,8 @@ webhookList.addEventListener("click", async (e) => { headerValueInput.value = ""; headers = []; renderHeaders(); + selectors = []; + renderSelectors(); cancelEditBtn.classList.add("hidden"); form.querySelector('button[type="submit"]').textContent = browser.i18n.getMessage("optionsSaveButton") || "Save Webhook"; } @@ -942,6 +1166,8 @@ webhookList.addEventListener("click", async (e) => { if (emojiInput) emojiInput.value = webhook.emoji || ""; headers = Array.isArray(webhook.headers) ? [...webhook.headers] : []; renderHeaders(); + selectors = Array.isArray(webhook.selectors) ? [...webhook.selectors] : []; + renderSelectors(); cancelEditBtn.classList.remove("hidden"); testWebhookBtn.classList.remove("hidden"); form.classList.remove('hidden'); @@ -969,6 +1195,8 @@ webhookList.addEventListener("click", async (e) => { if (emojiInput) emojiInput.value = webhook.emoji || ""; headers = Array.isArray(webhook.headers) ? [...webhook.headers] : []; renderHeaders(); + selectors = Array.isArray(webhook.selectors) ? [...webhook.selectors] : []; + renderSelectors(); cancelEditBtn.classList.remove("hidden"); testWebhookBtn.classList.remove("hidden"); form.classList.remove('hidden'); @@ -995,7 +1223,9 @@ cancelEditBtn.addEventListener("click", () => { groupSelect.value = ""; if (emojiInput) emojiInput.value = ""; headers = []; + selectors = []; renderHeaders(); + renderSelectors(); cancelEditBtn.classList.add("hidden"); testWebhookBtn.classList.add("hidden"); formStatusMessage.textContent = ""; @@ -1111,6 +1341,15 @@ document.addEventListener("DOMContentLoaded", () => { identifierInput.placeholder = browser.i18n.getMessage('optionsIdentifierPlaceholder'); urlFilterInput.placeholder = browser.i18n.getMessage('optionsURLFilterPlaceholder'); customPayloadInput.placeholder = browser.i18n.getMessage('optionsCustomPayloadPlaceholder'); + if (selectorInput) { + selectorInput.placeholder = browser.i18n.getMessage('optionsSelectorPlaceholder') || 'Enter CSS selector'; + } + if (addSelectorBtn) { + addSelectorBtn.textContent = browser.i18n.getMessage('optionsAddSelectorButton') || 'Add Selector'; + } + if (selectorsCountLabel) { + selectorsCountLabel.textContent = `0/${MAX_SELECTORS_PER_WEBHOOK}`; + } // Set localized label for cancel edit button cancelEditBtn.textContent = browser.i18n.getMessage("optionsCancelEditButton") || "Cancel"; @@ -1119,6 +1358,7 @@ document.addEventListener("DOMContentLoaded", () => { // Initialize custom payload section (collapsed by default) updateCustomPayloadVisibility(); updateUrlFilterVisibility(); + renderSelectors(); // Load webhooks loadWebhooks(); @@ -1204,7 +1444,8 @@ testWebhookBtn.addEventListener('click', async () => { identifier: identifierInput.value.trim() || undefined, customPayload: customPayloadInput.value.trim() || undefined, urlFilter: urlFilterInput.value.trim() || undefined, - groupId: groupSelect.value || undefined + groupId: groupSelect.value || undefined, + selectors: [...selectors] }; // Add test header to identify this as a test webhook diff --git a/package-lock.json b/package-lock.json index dd860a7..0d61da5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -543,9 +543,9 @@ "license": "MIT" }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", "dev": true, "funding": [ { @@ -587,9 +587,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", "dev": true, "funding": [ { @@ -603,7 +603,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", + "@csstools/color-helpers": "^5.0.2", "@csstools/css-calc": "^2.1.4" }, "engines": { @@ -820,9 +820,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -969,9 +969,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3940,9 +3940,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6142,9 +6142,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz", + "integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==", "dev": true, "license": "ISC", "dependencies": { @@ -6215,9 +6215,9 @@ } }, "node_modules/multimatch/node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -6251,9 +6251,9 @@ "license": "MIT" }, "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", "dev": true, "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { @@ -6339,9 +6339,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true, "license": "MIT" }, @@ -7558,9 +7558,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", - "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -8099,7 +8099,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -8338,9 +8337,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", "engines": { diff --git a/popup/popup.css b/popup/popup.css index a3bf0d5..d86e78a 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -98,6 +98,37 @@ body { gap: 8px; } +.hidden { + display: none !important; +} + +.webhook-row { + display: flex; + gap: 6px; +} + +.webhook-row .webhook-btn { + flex: 1; +} + +.capture-btn { + flex-shrink: 0; + padding: 0 10px; + min-width: 48px; + border: 1px solid var(--primary); + border-radius: 10px; + background: transparent; + color: var(--primary); + font-size: 12px; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.capture-btn:hover { + background: var(--primary); + color: var(--primary-contrast); +} + .group-header { display: block; font-weight: 700; @@ -158,12 +189,69 @@ body { border-color: transparent; } +#status-message.info { + background-color: rgba(37, 99, 235, 0.12); + color: var(--primary); + border-color: transparent; +} + #status-message.error { background-color: var(--error-bg); color: var(--error-text); border-color: transparent; } +#status-message.hidden { + display: none !important; +} + +#response-container { + margin-top: 12px; + padding: 10px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--card-bg); + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 2px 6px rgba(0,0,0,0.06); +} + +.response-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 600; + color: var(--text); +} + +.response-header button { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--primary); + background: transparent; + color: var(--primary); + cursor: pointer; + font-size: 11px; + transition: background 0.2s ease, color 0.2s ease; +} + +.response-header button:hover { + background: var(--primary); + color: var(--primary-contrast); +} + +#response-content { + max-height: 160px; + overflow-y: auto; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} + .footer { margin-top: 12px; padding-top: 2px; diff --git a/popup/popup.html b/popup/popup.html index e030844..79b1901 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -12,6 +12,13 @@
    + diff --git a/popup/popup.js b/popup/popup.js index c2bd7de..b329b7a 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,4 +1,5 @@ - +const MAX_SELECTORS_PER_WEBHOOK = 10; +const STATUS_VARIANTS = ["success", "error", "info", "hidden"]; /** * Helper function to create a webhook button element. @@ -18,152 +19,588 @@ function createWebhookButton(webhook) { document.addEventListener("DOMContentLoaded", async () => { const browserAPI = window.getBrowserAPI(); - // Replace i18n placeholders replaceI18nPlaceholders(); const buttonsContainer = document.getElementById("buttons-container"); + const statusMessageEl = document.getElementById("status-message"); + const responseContainer = document.getElementById("response-container"); + const responseContent = document.getElementById("response-content"); + const copyResponseBtn = document.getElementById("copy-response-btn"); + const captureButtonMap = new Map(); - // Apply theme preference (system | light | dark) - try { - const themeResult = await browserAPI.storage.sync.get("theme"); - const theme = themeResult && themeResult.theme ? themeResult.theme : "system"; - const root = document.documentElement; - if (theme === "light" || theme === "dark") { - root.setAttribute("data-theme", theme); - } else { - root.removeAttribute("data-theme"); + let activeCaptureWebhookId = null; + let currentResponseText = ""; + + const setStatus = (variant, message) => { + if (!statusMessageEl) return; + STATUS_VARIANTS.forEach(v => statusMessageEl.classList.remove(v)); + const text = message || ""; + statusMessageEl.textContent = text; + if (!text) { + statusMessageEl.classList.add("hidden"); + return; } - } catch (_) { - // ignore theme errors, fallback to system - } + const effectiveVariant = STATUS_VARIANTS.includes(variant) ? variant : "info"; + statusMessageEl.classList.add(effectiveVariant); + }; - // Load webhooks and groups from storage - const { webhooks = [], groups = [] } = await browserAPI.storage.sync.get(["webhooks", "groups"]); - const tabs = await browserAPI.tabs.query({ active: true, currentWindow: true }); - const currentUrl = tabs[0]?.url || ""; - const visibleWebhooks = webhooks.filter( - (wh) => !wh.urlFilter || currentUrl.includes(wh.urlFilter) - ); - - if (visibleWebhooks.length === 0) { - // Use textContent instead of innerHTML for security - const p = document.createElement("p"); - p.className = "no-hooks-msg"; - p.textContent = browserAPI.i18n.getMessage("popupNoWebhooksConfigured"); - buttonsContainer.textContent = ""; // Clear any existing content - buttonsContainer.appendChild(p); - } else { - // Group webhooks by group ID - const groupedWebhooks = {}; - visibleWebhooks.forEach(webhook => { - const groupId = webhook.groupId || "ungrouped"; - if (!groupedWebhooks[groupId]) { - groupedWebhooks[groupId] = []; - } - groupedWebhooks[groupId].push(webhook); - }); + const hideResponse = () => { + currentResponseText = ""; + if (responseContent) { + responseContent.textContent = ""; + } + if (responseContainer) { + responseContainer.classList.add("hidden"); + } + }; + + const showResponse = (text) => { + currentResponseText = text || ""; + if (!responseContainer || !responseContent) return; + if (!currentResponseText) { + hideResponse(); + return; + } + responseContent.textContent = currentResponseText; + responseContainer.classList.remove("hidden"); + }; + + const getCaptureLabel = (count) => { + const localized = browserAPI.i18n.getMessage("popupCaptureButtonLabel", [ + String(count), + String(MAX_SELECTORS_PER_WEBHOOK), + ]); + return localized || `Capture (${count}/${MAX_SELECTORS_PER_WEBHOOK})`; + }; - // Create a map of group ID to group name for easy lookup - const groupMap = Object.fromEntries(groups.map(group => [group.id, group.name])); - - // Display webhooks grouped by group in the order defined by the groups array - // First display groups in their defined order - groups.forEach(group => { - const groupId = group.id; - const groupWebhooks = groupedWebhooks[groupId]; - if (groupWebhooks && groupWebhooks.length > 0) { - // Create group header - const groupHeader = document.createElement("h3"); - groupHeader.className = "group-header"; - groupHeader.textContent = group.name; - buttonsContainer.appendChild(groupHeader); - - // Create a button for each webhook in this group - groupWebhooks.forEach((webhook) => { - const button = createWebhookButton(webhook); - buttonsContainer.appendChild(button); + const getLimitTooltip = () => { + return ( + browserAPI.i18n.getMessage("popupCaptureLimitReachedTooltip", [ + String(MAX_SELECTORS_PER_WEBHOOK), + ]) || `Maximum of ${MAX_SELECTORS_PER_WEBHOOK} selectors reached` + ); + }; + + const ensureSelectorContentScript = async (tabId) => { + try { + if (browserAPI.scripting && typeof browserAPI.scripting.executeScript === "function") { + await browserAPI.scripting.executeScript({ + target: { tabId }, + files: ["content-scripts/selector-capture.js"], }); + return true; } - }); + if (browserAPI.tabs && typeof browserAPI.tabs.executeScript === "function") { + await browserAPI.tabs.executeScript(tabId, { + file: "content-scripts/selector-capture.js", + }); + return true; + } + } catch (error) { + console.debug("Failed to inject selector capture script", error); + } + return false; + }; - // Then display ungrouped webhooks if they exist - const ungroupedWebhooks = groupedWebhooks["ungrouped"]; - if (ungroupedWebhooks && ungroupedWebhooks.length > 0) { - // Create ungrouped header - const ungroupedHeader = document.createElement("h3"); - ungroupedHeader.className = "group-header"; - ungroupedHeader.textContent = browserAPI.i18n.getMessage("popupNoGroup") || "No Group"; - buttonsContainer.appendChild(ungroupedHeader); - - // Create a button for each ungrouped webhook - ungroupedWebhooks.forEach((webhook) => { - const button = createWebhookButton(webhook); - buttonsContainer.appendChild(button); + const sendMessageToTab = (tabId, message) => { + if (browserAPI.tabs && typeof browserAPI.tabs.sendMessage === "function") { + return browserAPI.tabs.sendMessage(tabId, message); + } + if (typeof browser !== "undefined" && browser.tabs?.sendMessage) { + return browser.tabs.sendMessage(tabId, message); + } + if (typeof chrome !== "undefined" && chrome.tabs?.sendMessage) { + return new Promise((resolve, reject) => { + try { + chrome.tabs.sendMessage(tabId, message, (response) => { + const error = chrome.runtime?.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(response); + } + }); + } catch (error) { + reject(error); + } }); } + throw new Error("tabs.sendMessage API is unavailable"); + }; - // Store webhooks in a map for quick lookup by id - window._webhookMap = Object.fromEntries(visibleWebhooks.map(w => [w.id, w])); + const updateCaptureButtonState = (webhookId) => { + const button = captureButtonMap.get(webhookId); + const webhook = window._webhookMap ? window._webhookMap[webhookId] : null; + if (!button || !webhook) return; + const count = Array.isArray(webhook.selectors) ? webhook.selectors.length : 0; + button.textContent = getCaptureLabel(count); + const limitReached = count >= MAX_SELECTORS_PER_WEBHOOK; + button.disabled = limitReached; + if (limitReached) { + button.title = getLimitTooltip(); + } else { + button.removeAttribute("title"); + } + if (activeCaptureWebhookId === webhookId) { + button.dataset.capturing = "true"; + button.disabled = false; + } else { + button.dataset.capturing = "false"; + } + }; + + const copyToClipboard = async (text) => { + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + // Fallback: use a hidden textarea + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + const result = document.execCommand("copy"); + document.body.removeChild(textarea); + return result; + } catch (error) { + console.error("Failed to copy response", error); + return false; + } + }; + + if (copyResponseBtn) { + copyResponseBtn.addEventListener("click", async () => { + if (!currentResponseText) return; + const ok = await copyToClipboard(currentResponseText); + const successMsg = + browserAPI.i18n.getMessage("popupCopySuccess") || "Copied to clipboard."; + const errorMsg = + browserAPI.i18n.getMessage("popupCopyError") || "Failed to copy response."; + setStatus(ok ? "success" : "error", ok ? successMsg : errorMsg); + }); } -}); -// Event listener for webhook button clicks -document - .getElementById("buttons-container") - .addEventListener("click", async (e) => { - const browserAPI = window.getBrowserAPI(); - if (!e.target.classList.contains("webhook-btn")) { - return; + const ensureSelectors = (value) => { + if (!Array.isArray(value)) return []; + return value + .filter((entry) => typeof entry === "string" && entry.trim().length > 0) + .slice(0, MAX_SELECTORS_PER_WEBHOOK); + }; + + const applyThemePreference = async () => { + try { + const themeResult = await browserAPI.storage.sync.get("theme"); + const theme = themeResult && themeResult.theme ? themeResult.theme : "system"; + const root = document.documentElement; + if (theme === "light" || theme === "dark") { + root.setAttribute("data-theme", theme); + } else { + root.removeAttribute("data-theme"); + } + } catch (error) { + console.warn("Failed to load theme preference", error); } + }; - const button = e.target; - const url = button.dataset.url; - const originalLabel = button.dataset.label; - const statusMessage = document.getElementById("status-message"); - const webhookId = button.dataset.webhookId; - const webhook = window._webhookMap ? window._webhookMap[webhookId] : null; + const renderWebhooks = async () => { + captureButtonMap.clear(); + buttonsContainer.textContent = ""; + hideResponse(); + setStatus("hidden", ""); + + const [{ webhooks = [], groups = [] }, tabs] = await Promise.all([ + browserAPI.storage.sync.get(["webhooks", "groups"]), + browserAPI.tabs.query({ active: true, currentWindow: true }), + ]); + + const currentUrl = tabs[0]?.url || ""; + const normalizedWebhooks = webhooks.map((wh) => ({ + ...wh, + selectors: ensureSelectors(wh.selectors), + })); + + const visibleWebhooks = normalizedWebhooks.filter( + (wh) => !wh.urlFilter || currentUrl.includes(wh.urlFilter) + ); - // Prevent multiple clicks - if (button.disabled) { + window._webhookMap = Object.fromEntries( + visibleWebhooks.map((wh) => [wh.id, { ...wh, selectors: [...wh.selectors] }]) + ); + + if (visibleWebhooks.length === 0) { + const p = document.createElement("p"); + p.className = "no-hooks-msg"; + p.textContent = browserAPI.i18n.getMessage("popupNoWebhooksConfigured"); + buttonsContainer.appendChild(p); return; } + const groupedWebhooks = visibleWebhooks.reduce((acc, webhook) => { + const groupKey = webhook.groupId || "ungrouped"; + if (!acc[groupKey]) acc[groupKey] = []; + acc[groupKey].push(webhook); + return acc; + }, {}); + + const groupMap = Object.fromEntries(groups.map((group) => [group.id, group.name])); + + const appendWebhookRow = (webhook) => { + const row = document.createElement("div"); + row.className = "webhook-row"; + + const displayLabel = `${webhook.emoji ? `${webhook.emoji} ` : ""}${webhook.label}`; + + const triggerBtn = document.createElement("button"); + triggerBtn.dataset.action = "trigger"; + triggerBtn.dataset.webhookId = webhook.id; + triggerBtn.dataset.label = displayLabel; + triggerBtn.classList.add("webhook-btn"); + triggerBtn.textContent = displayLabel; + + const captureBtn = document.createElement("button"); + captureBtn.dataset.action = "capture"; + captureBtn.dataset.webhookId = webhook.id; + captureBtn.classList.add("capture-btn"); + captureButtonMap.set(webhook.id, captureBtn); + + row.appendChild(triggerBtn); + row.appendChild(captureBtn); + buttonsContainer.appendChild(row); + + updateCaptureButtonState(webhook.id); + }; + + groups.forEach((group) => { + const groupWebhooks = groupedWebhooks[group.id]; + if (!groupWebhooks || groupWebhooks.length === 0) { + return; + } + const header = document.createElement("h3"); + header.className = "group-header"; + header.textContent = group.name; + buttonsContainer.appendChild(header); + groupWebhooks.forEach(appendWebhookRow); + }); + + const ungrouped = groupedWebhooks["ungrouped"] || []; + if (ungrouped.length > 0) { + const header = document.createElement("h3"); + header.className = "group-header"; + header.textContent = + browserAPI.i18n.getMessage("popupNoGroup") || "No Group"; + buttonsContainer.appendChild(header); + ungrouped.forEach(appendWebhookRow); + } + }; + + const handleTrigger = async (webhook, button) => { + if (!button || !webhook) return; + const originalLabel = button.dataset.label || button.textContent; + if (button.disabled) return; + + hideResponse(); + setStatus("info", browserAPI.i18n.getMessage("popupSending") || "Sending…"); + button.disabled = true; - button.textContent = browserAPI.i18n.getMessage("popupSending"); - statusMessage.textContent = ""; - statusMessage.className = ""; + button.textContent = browserAPI.i18n.getMessage("popupSending") || "Sending…"; try { - await window.sendWebhook(webhook, false); - - // Success feedback - statusMessage.textContent = browserAPI.i18n.getMessage("popupStatusSuccess"); - statusMessage.classList.add("success"); - button.textContent = browserAPI.i18n.getMessage("popupBtnTextSent"); + const response = await window.sendWebhook(webhook, false); + const message = await extractResponseMessage(response); + if (message) { + showResponse(message); + } else { + hideResponse(); + } + setStatus( + "success", + browserAPI.i18n.getMessage("popupStatusSuccess") || "Webhook sent!" + ); + button.textContent = + browserAPI.i18n.getMessage("popupBtnTextSent") || "Sent!"; } catch (error) { console.error("Error sending webhook:", error); - statusMessage.textContent = `${browserAPI.i18n.getMessage("popupStatusErrorPrefix")} ${error.message}`; - statusMessage.classList.add("error"); - button.textContent = browserAPI.i18n.getMessage("popupBtnTextFailed"); + hideResponse(); + const prefix = + browserAPI.i18n.getMessage("popupStatusErrorPrefix") || "Error:"; + setStatus("error", `${prefix} ${error.message}`); + button.textContent = + browserAPI.i18n.getMessage("popupBtnTextFailed") || "Failed"; } finally { - // Re-enable button after a delay and restore state setTimeout(() => { button.disabled = false; button.textContent = originalLabel; - statusMessage.textContent = ""; - statusMessage.className = ""; + setStatus("hidden", ""); }, 2500); } + }; + + const startCapture = async (webhook, button) => { + const selectors = Array.isArray(webhook.selectors) ? webhook.selectors : []; + if (selectors.length >= MAX_SELECTORS_PER_WEBHOOK) { + setStatus("error", getLimitTooltip()); + updateCaptureButtonState(webhook.id); + return; + } + + let tabs = []; + if (browserAPI.tabs?.query) { + tabs = await browserAPI.tabs.query({ active: true, currentWindow: true }); + } else if (browserAPI.tabs?.getCurrent) { + const tab = await browserAPI.tabs.getCurrent(); + if (tab) tabs = [tab]; + } + if (!tabs.length) { + setStatus( + "error", + browserAPI.i18n.getMessage("popupNoActiveTabError") || + "Unable to find an active tab." + ); + return; + } + + const tabId = tabs[0].id; + const attemptStart = async () => { + const response = await sendMessageToTab(tabId, { + type: "START_SELECTOR_CAPTURE", + webhookId: webhook.id, + existingSelectors: selectors, + maxSelectors: MAX_SELECTORS_PER_WEBHOOK, + }); + activeCaptureWebhookId = webhook.id; + if (button) { + button.disabled = false; + button.dataset.capturing = "true"; + } + const remaining = response?.remaining ?? MAX_SELECTORS_PER_WEBHOOK - selectors.length; + const captureMsg = + browserAPI.i18n.getMessage("popupCaptureStarted", [ + String(remaining), + ]) || + `Capture mode active. ${remaining} remaining. Click elements to save text, press Esc to stop.`; + setStatus("info", captureMsg); + }; + + let lastError = null; + try { + await attemptStart(); + return; + } catch (firstError) { + lastError = firstError; + } + + const shouldRetry = + lastError && + typeof lastError.message === "string" && + (lastError.message.includes("Receiving end does not exist") || + lastError.message.includes("Could not establish connection")); + + if (shouldRetry) { + const injected = await ensureSelectorContentScript(tabId); + if (injected) { + try { + await attemptStart(); + return; + } catch (retryError) { + console.debug("Selector capture retry failed", retryError); + lastError = retryError; + } + } + } + + const errorMsg = + lastError && typeof lastError.message === "string" && lastError.message.includes("Receiving end does not exist") + ? browserAPI.i18n.getMessage("popupCaptureContentNotAvailable") || + "No eligible content on this page. Try reloading or switch to a supported tab." + : browserAPI.i18n.getMessage("popupCaptureStartError") || + "Failed to start selector capture. Try reloading the page."; + setStatus("error", errorMsg); + updateCaptureButtonState(webhook.id); + }; + + const handleCapturedSelector = (message) => { + if (message.origin && message.origin !== "background") { + return; + } + const { selector, textContent, webhookId, selectors = [], remaining } = message; + if (!selector || !webhookId) return; + + if (window._webhookMap && window._webhookMap[webhookId]) { + window._webhookMap[webhookId].selectors = Array.isArray(selectors) + ? [...selectors] + : []; + } + updateCaptureButtonState(webhookId); + + const preview = + typeof textContent === "string" && textContent.length > 0 + ? textContent.slice(0, 80) + : selector; + const selectorsLength = Array.isArray(selectors) ? selectors.length : 0; + const successMsg = + browserAPI.i18n.getMessage("popupCaptureSaved", [ + preview, + String(remaining ?? Math.max(MAX_SELECTORS_PER_WEBHOOK - selectorsLength, 0)), + ]) || + `Captured: "${preview}"`; + setStatus("success", successMsg); + }; + + const handleCaptureError = (message) => { + if (message.origin && message.origin !== "background") { + return; + } + const { reason } = message; + let key = "popupCaptureGenericError"; + switch (reason) { + case "duplicate": + key = "popupCaptureDuplicate"; + break; + case "limit": + case "limit-reached": + key = "popupCaptureLimitReachedTooltip"; + break; + case "empty-text": + key = "popupCaptureEmptyText"; + break; + case "no-selector": + key = "popupCaptureNoSelector"; + break; + case "not-found": + key = "popupCaptureGenericError"; + break; + default: + key = "popupCaptureGenericError"; + break; + } + const text = + browserAPI.i18n.getMessage(key, [String(MAX_SELECTORS_PER_WEBHOOK)]) || + "Unable to capture this element."; + setStatus("error", text); + }; + + const handleCaptureEnded = (message) => { + if (message.origin && message.origin !== "background") { + return; + } + if (!activeCaptureWebhookId) { + return; + } + const webhookId = activeCaptureWebhookId; + activeCaptureWebhookId = null; + updateCaptureButtonState(webhookId); + + let key = "popupCaptureEnded"; + switch (message?.reason) { + case "limit-reached": + key = "popupCaptureLimitReachedTooltip"; + break; + case "cancelled": + key = "popupCaptureCancelled"; + break; + default: + key = "popupCaptureEnded"; + break; + } + const text = + browserAPI.i18n.getMessage(key, [String(MAX_SELECTORS_PER_WEBHOOK)]) || + "Capture mode ended."; + setStatus("info", text); + }; + + buttonsContainer.addEventListener("click", async (event) => { + const button = event.target.closest("button"); + if (!button) return; + const action = button.dataset.action; + const webhookId = button.dataset.webhookId; + const webhook = + window._webhookMap && webhookId ? window._webhookMap[webhookId] : null; + if (!webhook) return; + + if (action === "trigger") { + await handleTrigger(webhook, button); + } else if (action === "capture") { + await startCapture(webhook, button); + } }); -// Link to open the options page -document.getElementById("open-options").addEventListener("click", (e) => { - e.preventDefault(); - const browserAPI = window.getBrowserAPI(); - browserAPI.runtime.openOptionsPage(); + const addRuntimeListener = (runtime) => { + if (!runtime || !runtime.onMessage || typeof runtime.onMessage.addListener !== "function") { + return false; + } + runtime.onMessage.addListener((message) => { + if (!message || typeof message !== "object") { + return false; + } + switch (message.type) { + case "SELECTOR_CAPTURED": + if ( + activeCaptureWebhookId && + message.webhookId === activeCaptureWebhookId + ) { + handleCapturedSelector(message); + } + break; + case "SELECTOR_CAPTURE_ERROR": + handleCaptureError(message); + break; + case "SELECTOR_CAPTURE_ENDED": + handleCaptureEnded(message); + break; + default: + break; + } + return false; + }); + return true; + }; + + const runtimeCandidates = [ + browserAPI.runtime, + typeof browser !== "undefined" ? browser.runtime : undefined, + typeof chrome !== "undefined" ? chrome.runtime : undefined, + ]; + runtimeCandidates.some(addRuntimeListener); + + document.getElementById("open-options").addEventListener("click", (event) => { + event.preventDefault(); + browserAPI.runtime.openOptionsPage(); + }); + + await applyThemePreference(); + await renderWebhooks(); }); +const extractResponseMessage = async (response) => { + if (!response) return ""; + try { + const text = await response.clone().text(); + if (!text) return ""; + try { + const parsed = JSON.parse(text); + if (typeof parsed === "string") { + return parsed; + } + if (parsed && typeof parsed.message === "string") { + return parsed.message; + } + return JSON.stringify(parsed, null, 2); + } catch (_) { + return text; + } + } catch (error) { + console.warn("Failed to extract response message", error); + return ""; + } +}; + // Export for testing in Node environment if (typeof module !== "undefined" && module.exports) { - module.exports = {}; + module.exports = { + extractResponseMessage, + }; } diff --git a/tests/exportImport.test.js b/tests/exportImport.test.js index 9f551c9..027e844 100644 --- a/tests/exportImport.test.js +++ b/tests/exportImport.test.js @@ -20,6 +20,19 @@ describe('export and import logic', () => { +
    +
    + + 0/10 +
    +

    +
      +
      + + +
      + +
      @@ -143,7 +156,13 @@ describe('export and import logic', () => { expect(global.URL.createObjectURL).toHaveBeenCalled(); const blob = global.URL.createObjectURL.mock.calls[0][0]; const text = await blob.text(); - expect(JSON.parse(text)).toEqual({ webhooks: hooks }); + const normalizedHooks = hooks.map(h => ({ + ...h, + headers: [], + emoji: '', + selectors: [] + })); + expect(JSON.parse(text)).toEqual({ webhooks: normalizedHooks }); expect(clickSpy).toHaveBeenCalled(); }); @@ -155,7 +174,13 @@ describe('export and import logic', () => { await handleImport(event); - expect(global.browser.storage.sync.set).toHaveBeenCalledWith({ webhooks: hooks.map(h => ({...h, emoji: ''})) }); + const normalizedHooks = hooks.map(h => ({ + ...h, + headers: [], + emoji: '', + selectors: [] + })); + expect(global.browser.storage.sync.set).toHaveBeenCalledWith({ webhooks: normalizedHooks }); expect(event.target.value).toBe(''); }); }); diff --git a/tests/options.test.js b/tests/options.test.js index 2a0c08f..5fe0abf 100644 --- a/tests/options.test.js +++ b/tests/options.test.js @@ -21,6 +21,19 @@ describe('options page', () => { +
      +
      + + 0/10 +
      +

      +
        +
        + + +
        + +
        @@ -118,6 +131,7 @@ describe('options page', () => { delete global.Node; delete global.browser; delete global.replaceI18nPlaceholders; + delete global.crypto; }); test('shows message when no webhooks are stored', async () => { @@ -143,7 +157,14 @@ describe('options page', () => { test('saveWebhooks writes to storage', async () => { const hooks = [{ id: 'a' }]; await saveWebhooks(hooks); - expect(global.browser.storage.sync.set).toHaveBeenCalledWith({ webhooks: hooks }); + expect(global.browser.storage.sync.set).toHaveBeenCalledWith({ + webhooks: [{ + id: 'a', + headers: [], + emoji: '', + selectors: [] + }] + }); }); test('webhook with custom payload is properly stored', async () => { @@ -209,11 +230,42 @@ describe('options page', () => { customPayload, urlFilter: 'example.com', groupId: null, - emoji: '' + emoji: '', + selectors: [] }] }); }); + test('manually added selectors are persisted', async () => { + document.getElementById('webhook-label').value = 'Selector Test'; + document.getElementById('webhook-url').value = 'https://example.com/hook'; + const selectorInput = document.getElementById('selector-input'); + selectorInput.value = '.profile-name'; + document.getElementById('add-selector-btn').click(); + + global.browser.storage.sync.get.mockResolvedValue({ webhooks: [] }); + global.crypto = { randomUUID: () => 'sel-123' }; + + const setPromise = new Promise(resolve => { + global.browser.storage.sync.set.mockImplementation(data => { + resolve(data); + return Promise.resolve(); + }); + }); + + const form = document.getElementById('add-webhook-form'); + const submitEvent = new dom.window.Event('submit'); + form.dispatchEvent(submitEvent); + + await setPromise; + + expect(global.browser.storage.sync.set).toHaveBeenCalledWith({ + webhooks: [expect.objectContaining({ + selectors: ['.profile-name'] + })] + }); + }); + test('persistWebhookOrder stores list order', async () => { const hooks = [ { id: '1', label: 'A', url: 'a', groupId: null }, @@ -227,9 +279,10 @@ describe('options page', () => { await persistWebhookOrder(); - expect(global.browser.storage.sync.set).toHaveBeenLastCalledWith({ - webhooks: [hooks[1], hooks[0]] - }); + const lastCall = global.browser.storage.sync.set.mock.calls.pop(); + expect(lastCall).toBeDefined(); + const saved = lastCall[0].webhooks; + expect(saved.map((w) => w.id)).toEqual(['2', '1']); }); test('duplicate button prefills the form without edit mode', async () => { @@ -370,9 +423,9 @@ describe('options page', () => { await testBtn.click(); // Verify sendWebhook was called with correct parameters - expect(mockSendWebhook).toHaveBeenCalledWith({ + expect(mockSendWebhook).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://test-webhook.com/endpoint', - method: 'POST', // This should now match + method: 'POST', headers: [ { key: 'Authorization', value: 'Bearer token123' }, { key: 'x-webhook-test', value: 'true' } @@ -380,8 +433,9 @@ describe('options page', () => { identifier: 'test-identifier', customPayload: '{"custom": "data"}', urlFilter: undefined, - groupId: undefined - }, false); // false = send real data, not test payload + groupId: undefined, + selectors: [] + }), false); // false = send real data, not test payload }); test('test button shows success message on successful webhook', async () => { diff --git a/tests/popup.test.js b/tests/popup.test.js index eeb8fff..e178b4a 100644 --- a/tests/popup.test.js +++ b/tests/popup.test.js @@ -11,6 +11,13 @@ describe('popup script', () => { dom = new JSDOM(`
        + `, { url: 'https://example.com' }); global.document = dom.window.document; @@ -22,15 +29,24 @@ describe('popup script', () => { global.browser = { storage: { sync: { get: jest.fn() } }, i18n: { getMessage: jest.fn((key) => key) }, - tabs: { query: jest.fn().mockResolvedValue([{ title: 't', url: 'https://example.com', id: 1, windowId: 1, index: 0, pinned: false, audible: false, mutedInfo: null, incognito: false, status: 'complete' }]) }, + tabs: { + query: jest.fn().mockResolvedValue([{ title: 't', url: 'https://example.com', id: 1, windowId: 1, index: 0, pinned: false, audible: false, mutedInfo: null, incognito: false, status: 'complete' }]), + sendMessage: jest.fn().mockResolvedValue({ remaining: 10 }) + }, runtime: { openOptionsPage: jest.fn(), getBrowserInfo: jest.fn().mockResolvedValue({}), getPlatformInfo: jest.fn().mockResolvedValue({}), + onMessage: { addListener: jest.fn() } }, }; + global.browser.storage.sync.get.mockResolvedValue({ webhooks: [] }); global.window.getBrowserAPI = jest.fn().mockReturnValue(global.browser); - global.window.sendWebhook = jest.fn().mockResolvedValue({ ok: true }); + global.window.sendWebhook = jest.fn().mockResolvedValue({ + clone: () => ({ + text: () => Promise.resolve('') + }) + }); }); afterEach(() => { @@ -67,7 +83,7 @@ describe('popup script', () => { btn.dispatchEvent(new dom.window.Event('click', { bubbles: true })); await new Promise(setImmediate); expect(window.sendWebhook).toHaveBeenCalled(); - expect(window.sendWebhook.mock.calls[0][0]).toEqual(hook); + expect(window.sendWebhook.mock.calls[0][0]).toMatchObject(hook); }); test('uses custom payload when available', async () => { @@ -102,7 +118,7 @@ describe('popup script', () => { await new Promise(setImmediate); expect(window.sendWebhook).toHaveBeenCalled(); - expect(window.sendWebhook.mock.calls[0][0]).toEqual(hook); + expect(window.sendWebhook.mock.calls[0][0]).toMatchObject(hook); }); test('filters webhooks based on urlFilter', async () => { diff --git a/tests/utils.test.js b/tests/utils.test.js index 3374fa6..f678838 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -2,7 +2,8 @@ const { TextEncoder, TextDecoder } = require('util'); global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; const { JSDOM } = require('jsdom'); -const { replaceI18nPlaceholders } = require('../utils/utils'); +const utils = require('../utils/utils'); +const { replaceI18nPlaceholders } = utils; describe('replaceI18nPlaceholders', () => { let dom; @@ -29,3 +30,81 @@ describe('replaceI18nPlaceholders', () => { expect(dom.window.document.title).toBe('Title'); }); }); + +describe('sendWebhook', () => { + let mockBrowser; + const originalFetch = global.fetch; + let originalBrowser; + + beforeEach(() => { + mockBrowser = { + tabs: { + query: jest.fn().mockResolvedValue([{ + id: 1, + title: 'Active Tab', + url: 'https://example.com', + windowId: 1, + index: 0, + pinned: false, + audible: false, + mutedInfo: null, + incognito: false, + status: 'complete' + }]), + sendMessage: jest.fn().mockResolvedValue({ selectorContent: ['John Doe'] }) + }, + runtime: { + getBrowserInfo: jest.fn().mockResolvedValue({}), + getPlatformInfo: jest.fn().mockResolvedValue({}), + }, + i18n: { + getMessage: jest.fn((key, value) => key) + } + }; + + originalBrowser = global.browser; + global.browser = mockBrowser; + global.fetch = jest.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + if (typeof originalBrowser === 'undefined') { + delete global.browser; + } else { + global.browser = originalBrowser; + } + if (typeof originalFetch === 'function') { + global.fetch = originalFetch; + } else { + delete global.fetch; + } + }); + + test('attaches selector content to payload when selectors exist', async () => { + const webhook = { url: 'https://webhook.example', selectors: ['.name'] }; + await utils.sendWebhook(webhook, false); + + expect(mockBrowser.tabs.sendMessage).toHaveBeenCalledWith(1, expect.objectContaining({ + type: 'GET_SELECTOR_CONTENT', + selectors: ['.name'] + })); + + const fetchArgs = global.fetch.mock.calls[0]; + expect(fetchArgs[0]).toBe('https://webhook.example'); + const payload = JSON.parse(fetchArgs[1].body); + expect(payload.selectorContent).toEqual(['John Doe']); + }); + + test('replaces selectorContent placeholder inside custom payload', async () => { + const webhook = { + url: 'https://webhook.example', + selectors: ['.name'], + customPayload: '{"message": {{selectorContent}} }' + }; + + await utils.sendWebhook(webhook, false); + + const payload = JSON.parse(global.fetch.mock.calls[0][1].body); + expect(payload).toEqual({ message: ['John Doe'] }); + }); +}); diff --git a/utils/utils.js b/utils/utils.js index f432f3f..61c24d6 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -85,6 +85,8 @@ function replaceI18nPlaceholders() { async function sendWebhook(webhook, isTest = false) { const browserAPI = getBrowserAPI(); + let selectors = Array.isArray(webhook?.selectors) ? [...webhook.selectors] : []; + let selectorContent = []; try { let payload; @@ -107,6 +109,62 @@ async function sendWebhook(webhook, isTest = false) { const activeTab = tabs[0]; const currentUrl = activeTab.url; + if ((!selectors || selectors.length === 0) && webhook?.id) { + try { + const stored = await browserAPI.storage.sync.get("webhooks"); + const storedHooks = Array.isArray(stored?.webhooks) ? stored.webhooks : []; + const storedMatch = storedHooks.find((w) => w.id === webhook.id); + if (storedMatch && Array.isArray(storedMatch.selectors)) { + selectors = storedMatch.selectors.filter((value) => typeof value === "string" && value.trim().length > 0); + } + } catch (error) { + console.debug("Failed to load stored selectors", error); + } + } + + const canSendMessage = + typeof browserAPI.tabs?.sendMessage === "function" || + (typeof browser !== "undefined" && typeof browser.tabs?.sendMessage === "function") || + (typeof chrome !== "undefined" && typeof chrome.tabs?.sendMessage === "function"); + + if (selectors.length > 0 && canSendMessage) { + try { + let response; + if (browserAPI.tabs && typeof browserAPI.tabs.sendMessage === "function") { + response = await browserAPI.tabs.sendMessage(activeTab.id, { + type: "GET_SELECTOR_CONTENT", + selectors, + }); + } else if (typeof browser !== "undefined" && typeof browser.tabs?.sendMessage === "function") { + response = await browser.tabs.sendMessage(activeTab.id, { + type: "GET_SELECTOR_CONTENT", + selectors, + }); + } else if (typeof chrome !== "undefined" && typeof chrome.tabs?.sendMessage === "function") { + response = await new Promise((resolve, reject) => { + chrome.tabs.sendMessage(activeTab.id, { + type: "GET_SELECTOR_CONTENT", + selectors, + }, (res) => { + const err = chrome.runtime?.lastError; + if (err) { + reject(new Error(err.message)); + } else { + resolve(res); + } + }); + }); + } + if (response && Array.isArray(response.selectorContent)) { + selectorContent = response.selectorContent.map((value) => + typeof value === "string" ? value.trim() : "" + ); + } + } catch (error) { + console.warn("Failed to retrieve selector content", error); + } + } + // Get browser and platform info const browserInfo = await browserAPI.runtime.getBrowserInfo?.() || {}; const platformInfo = await browserAPI.runtime.getPlatformInfo?.() || {}; @@ -134,6 +192,10 @@ async function sendWebhook(webhook, isTest = false) { payload.identifier = webhook.identifier; } + if (selectors.length > 0) { + payload.selectorContent = selectorContent; + } + if (webhook && webhook.customPayload) { try { const replacements = { @@ -151,7 +213,8 @@ async function sendWebhook(webhook, isTest = false) { "{{platform.os}}": platformInfo.os || "unknown", "{{platform.version}}": platformInfo.version, "{{triggeredAt}}": new Date().toISOString(), - "{{identifier}}": webhook.identifier || "" + "{{identifier}}": webhook.identifier || "", + "{{selectorContent}}": selectorContent }; let customPayloadStr = webhook.customPayload;