// ==UserScript== // @name Homebox Connector - Core // @namespace https://github.com/Cougar/userscript-homebox // @version 1.0.7 // @description Core background engine and API connector for Homebox e-shop userscripts // @author Cougar // @homepageURL https://github.com/Cougar/userscript-homebox // @supportURL https://github.com/Cougar/userscript-homebox/issues // @downloadURL https://raw.githubusercontent.com/Cougar/userscript-homebox/main/homebox-core.user.js // @updateURL https://raw.githubusercontent.com/Cougar/userscript-homebox/main/homebox-core.user.js // @match *://*.handymann.ee/* // @match *://*.osta.ee/* // @match *://*.oomipood.ee/* // @match *://*.bauhof.ee/* // @match *://*.bauhaus.ee/* // @match *://*.depo.ee/* // @match *://*.depo.lv/* // @match *://*.arvutitark.ee/* // @match *://*.euronics.ee/* // @match *://*.euronics.lv/* // @match *://*.euronics.lt/* // @match *://*.ikea.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect * // ==/UserScript== (function () { "use strict"; // --- Constants --- const PROTOCOL_VERSION = "1.0.0"; const ADAPTER_NAMES = { "platform-woocommerce": "WooCommerce", "platform-opencart": "OpenCart", "platform-storefrontui": "StorefrontUI", "platform-magento": "Magento", "custom-osta": "Osta.ee", "custom-depo": "DEPO Online", "custom-arvutitark": "Arvutitark", "custom-euronics": "Euronics", "custom-ikea": "IKEA", woocommerce: "WooCommerce", opencart: "OpenCart", storefrontui: "StorefrontUI", magento: "Magento", osta: "Osta.ee", depo: "DEPO Online", arvutitark: "Arvutitark", euronics: "Euronics", ikea: "IKEA", }; // --- CSS Styles --- const GLOBAL_CSS = ` /* Homebox Toast Notifications */ .homebox-toast { position: fixed; bottom: 20px; right: 20px; background: #1f2937; color: #ffffff; padding: 12px 24px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 99999; font-size: 14px; font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; gap: 10px; animation: homeboxSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); } @keyframes homeboxSlideIn { from { transform: translateX(120%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } /* Settings Modal overlay */ .homebox-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px); display: flex; justify-content: center; align-items: center; z-index: 999999; font-family: system-ui, -apple-system, sans-serif; } /* Modal dialog structure */ .homebox-modal { background: #ffffff; color: #1f2937; width: 90%; max-width: 480px; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); overflow: hidden; animation: homeboxFadeIn 0.2s ease-out; } @keyframes homeboxFadeIn { from { opacity: 0; transform: scale(0.95) translateY(-10px); } to { opacity: 1; transform: scale(1) translateY(0); } } /* Modal header */ .homebox-header { background: #10b981; color: #ffffff; padding: 16px 20px; font-size: 18px; font-weight: 700; display: flex; justify-content: space-between; align-items: center; } .homebox-header-close { cursor: pointer; font-size: 24px; border: none; background: none; color: #ffffff; line-height: 1; padding: 0; opacity: 0.8; transition: opacity 0.15s; } .homebox-header-close:hover { opacity: 1; } /* Modal forms */ .homebox-body { padding: 20px; max-height: 70vh; overflow-y: auto; } .homebox-form-group { margin-bottom: 16px; } .homebox-label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 13px; color: #4b5563; } .homebox-input, .homebox-select { width: 100%; height: 40px !important; min-height: 40px !important; border: 1px solid #d1d5db !important; border-radius: 6px !important; box-sizing: border-box !important; font-size: 14px !important; background-color: #ffffff !important; color: #1f2937 !important; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; margin: 0 !important; } .homebox-input { padding: 0 12px !important; line-height: normal !important; } .homebox-select:not([multiple]) { height: 40px !important; line-height: 38px !important; padding: 0 32px 0 12px !important; background-image: url('data:image/svg+xml;charset=US-ASCII,') !important; background-repeat: no-repeat !important; background-position: right 10px center !important; background-size: 16px 16px !important; -webkit-appearance: none !important; -moz-appearance: none !important; appearance: none !important; } .homebox-select[multiple] { height: 90px !important; padding: 6px 8px !important; line-height: 1.4 !important; -webkit-appearance: listbox !important; appearance: listbox !important; } .homebox-input:focus, .homebox-select:focus { outline: none !important; border-color: #10b981 !important; box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2) !important; } /* Modal footer and buttons */ .homebox-footer { padding: 16px 20px; background: #f9fafb; border-top: 1px solid #e5e7eb; display: flex; justify-content: flex-end; gap: 12px; } .homebox-btn { height: 40px !important; min-height: 40px !important; line-height: 40px !important; padding: 0 18px !important; border: none !important; border-radius: 6px !important; font-weight: 600 !important; font-size: 14px !important; cursor: pointer !important; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; box-sizing: border-box !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; white-space: nowrap !important; margin: 0 !important; transition: background-color 0.15s, transform 0.1s !important; } .homebox-btn-secondary { background: #e5e7eb; color: #374151; } .homebox-btn-secondary:hover { background: #d1d5db; } .homebox-btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; } .homebox-btn-primary { background: #10b981; color: #ffffff; } .homebox-btn-primary:hover { background: #059669; } /* Base Styling for injected button elements */ .homebox-injected-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 600; cursor: pointer; border: none; box-sizing: border-box; transition: background-color 0.15s, transform 0.1s, opacity 0.2s; } .homebox-injected-btn-fallback { background: #10b981; color: #ffffff; padding: 10px 20px; border-radius: 6px; font-size: 14px; } .homebox-injected-btn-fallback:hover { background: #059669; } .homebox-injected-btn.homebox-loading { pointer-events: none; opacity: 0.8; } /* Spinner animation nested in buttons */ .homebox-btn-spinner { display: none; animation: homeboxRotate 1s linear infinite; } .homebox-loading .homebox-btn-spinner { display: inline-block; } .homebox-loading .homebox-btn-icon, .homebox-loading .homebox-btn-text { display: none; } /* Success indicator nested in buttons */ .homebox-btn-success-icon { display: none; } .homebox-success .homebox-btn-success-icon { display: inline-flex; align-items: center; gap: 6px; } .homebox-success .homebox-btn-icon, .homebox-success .homebox-btn-text { display: none; } /* Product card quick-add catalog button */ .homebox-list-btn { position: absolute; top: 10px; right: 10px; background: rgba(16, 185, 129, 0.9); color: #ffffff; border: none; border-radius: 50%; width: 34px; height: 34px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 49; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); transition: background-color 0.2s, transform 0.1s, opacity 0.2s; opacity: 0; } .homebox-list-btn:hover { background: rgba(5, 150, 105, 1) !important; transform: scale(1.1); } .homebox-list-btn:active { transform: scale(0.95); } .homebox-list-btn svg.homebox-btn-icon { width: 55%; height: 55%; max-width: 16px; max-height: 16px; } /* Standard triggers for quick-add visibility */ li.product:hover .homebox-list-btn, .products .product:hover .homebox-list-btn, .wpb-wps-slider-item:hover .homebox-list-btn, .product-grid-item:hover .homebox-list-btn, .product-item-info:hover .homebox-list-btn, .offer-thumb:hover .homebox-list-btn, .plp-mastercard:hover .homebox-list-btn, .product-card:hover .homebox-list-btn, .catalogue-product-wrapper:hover .homebox-list-btn { opacity: 1; } /* General Spinner Rotation */ @keyframes homeboxRotate { 100% { transform: rotate(360deg); } } `; // --- Helpers --- function showToast(message, type = "info") { const toast = document.createElement("div"); toast.className = "homebox-toast"; let icon = "â„šī¸"; if (type === "success") { icon = "✅"; toast.style.borderLeft = "4px solid #10b981"; } else if (type === "error") { icon = "❌"; toast.style.borderLeft = "4px solid #ef4444"; } toast.innerHTML = `${icon} ${message}`; document.body.appendChild(toast); setTimeout(() => { toast.style.transition = "opacity 0.4s ease-out, transform 0.4s ease-out"; toast.style.opacity = "0"; toast.style.transform = "translateY(15px)"; setTimeout(() => { toast.remove(); }, 400); }, 3500); } // --- Debug Logger --- function logDebug(action, url, headers, status, responseText) { console.group( `%c[Homebox Connector Core Debug] - ${action}`, "color: #10b981; font-weight: bold; font-size: 12px;", ); console.log(`%cURL:%c ${url}`, "font-weight: bold;", "color: #2563eb;"); console.log("%cHeaders:", "font-weight: bold;", headers); console.log( `%cStatus Code:%c ${status}`, "font-weight: bold;", status === 200 || status === 201 ? "color: #059669; font-weight: bold;" : "color: #dc2626; font-weight: bold;", ); try { const parsed = JSON.parse(responseText); console.log("%cResponse (JSON):", "font-weight: bold;", parsed); } catch { console.log( `%cResponse (Text):%c ${responseText}`, "font-weight: bold;", "color: #4b5563;", ); } console.groupEnd(); } // --- Config Manager --- const Config = { load() { let tagIds = []; try { tagIds = JSON.parse(GM_getValue("homebox_tag_ids", "[]")); } catch { // use default } return { url: GM_getValue("homebox_url", ""), token: GM_getValue("homebox_token", ""), tenantId: GM_getValue("homebox_tenant_id", ""), tenantName: GM_getValue("homebox_tenant_name", ""), locationId: GM_getValue("homebox_location_id", ""), locationName: GM_getValue("homebox_location_name", ""), currency: GM_getValue("homebox_currency", "EUR"), enableListingButtons: GM_getValue( "homebox_enable_listing_buttons", true, ), tagIds, }; }, save(config) { GM_setValue("homebox_url", config.url); GM_setValue("homebox_token", config.token); GM_setValue("homebox_tenant_id", config.tenantId); GM_setValue("homebox_tenant_name", config.tenantName); GM_setValue("homebox_location_id", config.locationId); GM_setValue("homebox_location_name", config.locationName); GM_setValue("homebox_currency", config.currency); GM_setValue( "homebox_enable_listing_buttons", config.enableListingButtons !== false, ); GM_setValue("homebox_tag_ids", JSON.stringify(config.tagIds || [])); }, isConfigured() { const config = this.load(); return config.url && config.token; }, showSettingsModal() { if (document.querySelector(".homebox-overlay")) return; const config = this.load(); const overlay = document.createElement("div"); overlay.className = "homebox-overlay"; let loadedGroups = []; let shopOverrides = {}; // Retrieve registered shops from storage and purge non-store subdomains let rawRegistered = []; try { rawRegistered = JSON.parse( GM_getValue("homebox_registered_entities", "[]"), ); } catch { // ignore } // Purge legacy storage keys if present GM_deleteValue("homebox_dynamic_shops"); const IGNORED_HOST_PREFIXES = [ "files.", "img.", "img-", "cdn.", "assets.", "static.", "api.", "media.", ]; let registeredEntities = rawRegistered.filter( (item) => item.host && !IGNORED_HOST_PREFIXES.some((prefix) => item.host.startsWith(prefix)), ); // Detect current page context const currentHost = window.location.hostname .replace(/^www\./i, "") .toLowerCase(); let activeAdapterId = Object.keys(activeAdapters)[0] || ""; if (!activeAdapterId) { const matchedEntity = registeredEntities.find( (e) => e.host === currentHost, ); if (matchedEntity) { activeAdapterId = matchedEntity.adapterId || ""; } } const activeAdapterName = activeAdapterId ? activeAdapters[activeAdapterId]?.name || ADAPTER_NAMES[activeAdapterId] || activeAdapterId : ""; // Ensure current host is in registered entities if an adapter is active if ( activeAdapterId && !registeredEntities.some((e) => e.host === currentHost) ) { registeredEntities.push({ host: currentHost, adapterId: activeAdapterId, adapterName: activeAdapterName, }); } // Group distinct platforms that are actually registered const registeredPlatforms = []; registeredEntities.forEach((entity) => { if (!registeredPlatforms.some((p) => p.id === entity.adapterId)) { registeredPlatforms.push({ id: entity.adapterId, name: entity.adapterName || ADAPTER_NAMES[entity.adapterId] || entity.adapterId, }); } }); // Load overrides only for registered shops and platforms const allOverrideKeys = [ ...registeredEntities.map((e) => e.host), ...registeredPlatforms.map((p) => p.id), ]; allOverrideKeys.forEach((key) => { try { shopOverrides[key] = JSON.parse( GM_getValue(`homebox_override_${key}`, "null"), ) || { locationId: "", locationName: "", tagIds: [] }; } catch { shopOverrides[key] = { locationId: "", locationName: "", tagIds: [] }; } }); // Automatically prune uninstalled/stale shops that have no configured overrides registeredEntities = registeredEntities.filter((entity) => { if (entity.host === currentHost) return true; const ov = shopOverrides[entity.host]; return ov && (ov.locationId || (ov.tagIds && ov.tagIds.length > 0)); }); // Commit cleaned entities back to storage GM_setValue( "homebox_registered_entities", JSON.stringify(registeredEntities), ); const isCurrentShopActive = !!activeAdapterId; const otherShops = registeredEntities.filter( (e) => e.host !== currentHost, ); const otherPlatforms = registeredPlatforms.filter( (p) => p.id !== activeAdapterId && !p.id.startsWith("custom-"), ); function isConfigured(key) { const ov = shopOverrides[key]; return !!(ov && (ov.locationId || (ov.tagIds && ov.tagIds.length > 0))); } overlay.innerHTML = `
Homebox Settings
Hold Ctrl/Cmd to select multiple tags.
Uncheck to hide floating buttons on product grid cards.
${ isCurrentShopActive ? `
Active Store & Detected Platform
${currentHost}
${activeAdapterId.startsWith("custom-") ? "Module: Custom Shop Adapter" : `Platform module: ${activeAdapterName}`}
` : "" }
${ registeredEntities.length === 0 ? `
No store modules have registered yet. Visit a supported store to configure overrides for it.
` : ` ` }
`; document.body.appendChild(overlay); const closeBtn = overlay.querySelector(".homebox-header-close"); const cancelBtn = overlay.querySelector("#hb-cancel"); const saveBtn = overlay.querySelector("#hb-save"); const loadTenantsBtn = overlay.querySelector("#hb-load-tenants"); const tenantSelect = overlay.querySelector("#hb-tenant"); const loadLocationsBtn = overlay.querySelector("#hb-load-locations"); const locationSelect = overlay.querySelector("#hb-location"); const loadTagsBtn = overlay.querySelector("#hb-load-tags"); const tagsSelect = overlay.querySelector("#hb-tags"); const overrideShopSelect = overlay.querySelector("#hb-override-shop"); const overrideFieldsDiv = overlay.querySelector( "#hb-shop-override-fields", ); const overrideLocSelect = overlay.querySelector("#hb-override-location"); const overrideTagsSelect = overlay.querySelector("#hb-override-tags"); let currentOverrideShop = isCurrentShopActive ? currentHost : ""; const close = () => overlay.remove(); closeBtn.addEventListener("click", close); cancelBtn.addEventListener("click", close); // Trigger automatic cascading load if credentials are valid if (config.url && config.token) { loadTenants(true); } function loadTenants(autoLoadLocations = false) { const url = overlay.querySelector("#hb-url").value.trim(); const token = overlay.querySelector("#hb-token").value.trim(); if (!url || !token) { showToast("Please enter server URL and Token first!", "error"); return; } loadTenantsBtn.disabled = true; loadTenantsBtn.textContent = "Loading..."; HomeboxAPI.getSelf(url, token) .then((userData) => { const defaultGroup = (userData.item && userData.item.defaultGroup) || userData.defaultGroup || ""; return HomeboxAPI.getGroupsAll(url, token).then((groups) => { tenantSelect.innerHTML = ""; loadedGroups = groups; if (groups.length === 0) { tenantSelect.innerHTML = ''; loadLocationsBtn.disabled = true; locationSelect.disabled = true; loadTagsBtn.disabled = true; tagsSelect.disabled = true; return; } groups.forEach((group) => { const option = document.createElement("option"); option.value = group.id; option.textContent = group.name || `Collection (${group.id.substring(0, 8)}...)`; if ( group.id === config.tenantId || (group.id === defaultGroup && !config.tenantId) ) { option.selected = true; } tenantSelect.appendChild(option); }); loadLocationsBtn.disabled = false; locationSelect.disabled = false; loadTagsBtn.disabled = false; tagsSelect.disabled = false; showToast("Collections loaded successfully!", "success"); if (autoLoadLocations) { loadLocations(); } }); }) .catch((err) => { showToast(err.message, "error"); }) .finally(() => { loadTenantsBtn.disabled = false; loadTenantsBtn.textContent = "Load collections"; }); } function loadLocations() { const url = overlay.querySelector("#hb-url").value.trim(); const token = overlay.querySelector("#hb-token").value.trim(); const tenantId = tenantSelect.value; if (!url || !token) { showToast("Please enter server URL and Token first!", "error"); return; } if (!tenantId) { showToast("Please select a Collection first!", "error"); return; } loadLocationsBtn.disabled = true; loadLocationsBtn.textContent = "Loading..."; HomeboxAPI.getLocations(url, token, tenantId) .then((locations) => { locationSelect.innerHTML = ""; if (locations.length === 0) { locationSelect.innerHTML = ''; return; } locations.forEach((loc) => { const option = document.createElement("option"); option.value = loc.id; option.textContent = loc.name; if (loc.id === config.locationId) { option.selected = true; } locationSelect.appendChild(option); }); showToast("Locations loaded!", "success"); loadTags(); updateOverrideFields(); }) .catch((err) => { showToast(err.message, "error"); }) .finally(() => { loadLocationsBtn.disabled = false; loadLocationsBtn.textContent = "Load"; }); } function loadTags() { const url = overlay.querySelector("#hb-url").value.trim(); const token = overlay.querySelector("#hb-token").value.trim(); const tenantId = tenantSelect.value; if (!url || !token || !tenantId) return; loadTagsBtn.disabled = true; loadTagsBtn.textContent = "Loading..."; HomeboxAPI.getTags(url, token, tenantId) .then((tags) => { tagsSelect.innerHTML = ""; if (tags.length === 0) { tagsSelect.innerHTML = ''; return; } tags.forEach((tag) => { const option = document.createElement("option"); option.value = tag.id; option.textContent = tag.name; if (config.tagIds && config.tagIds.includes(tag.id)) { option.selected = true; } tagsSelect.appendChild(option); }); tagsSelect.disabled = false; showToast("Tags loaded!", "success"); updateOverrideFields(); }) .catch((err) => { showToast(err.message, "error"); }) .finally(() => { loadTagsBtn.disabled = false; loadTagsBtn.textContent = "Load"; }); } function updateOverrideFields() { if (!currentOverrideShop) { overrideFieldsDiv.style.display = "none"; return; } overrideFieldsDiv.style.display = "block"; // Rebuild Location select overrideLocSelect.innerHTML = ''; Array.from(locationSelect.options).forEach((opt) => { const clone = document.createElement("option"); clone.value = opt.value; clone.textContent = opt.textContent; if (opt.value === shopOverrides[currentOverrideShop].locationId) { clone.selected = true; } overrideLocSelect.appendChild(clone); }); // Rebuild Tags select overrideTagsSelect.innerHTML = ""; Array.from(tagsSelect.options).forEach((opt) => { const clone = document.createElement("option"); clone.value = opt.value; clone.textContent = opt.textContent; if ( shopOverrides[currentOverrideShop].tagIds && shopOverrides[currentOverrideShop].tagIds.includes(opt.value) ) { clone.selected = true; } overrideTagsSelect.appendChild(clone); }); // Update status badge const statusBadge = overrideFieldsDiv.querySelector( "#hb-override-status-badge", ); if (statusBadge) { statusBadge.style.display = "block"; if (isConfigured(currentOverrideShop)) { statusBadge.style.background = "#ecfdf5"; statusBadge.style.color = "#065f46"; statusBadge.style.border = "1px solid #a7f3d0"; statusBadge.innerHTML = "âš™ī¸ Override active: this store has a custom location or tags."; } else { statusBadge.style.background = "#f3f4f6"; statusBadge.style.color = "#4b5563"; statusBadge.style.border = "1px solid #e5e7eb"; statusBadge.innerHTML = "â„šī¸ No override: using global default location and tags."; } } } function refreshOptionBadges() { if (!overrideShopSelect) return; Array.from(overrideShopSelect.options).forEach((opt) => { if (!opt.value) return; const has = isConfigured(opt.value); const cleanText = opt.textContent.replace(/\s*âš™ī¸\s*$/, ""); opt.textContent = cleanText + (has ? " âš™ī¸" : ""); }); } function saveActiveOverride() { if (!currentOverrideShop) return; const locId = overrideLocSelect.value; const selectedLocOpt = overrideLocSelect.options[overrideLocSelect.selectedIndex]; const locName = selectedLocOpt ? selectedLocOpt.textContent : ""; const tagIds = Array.from(overrideTagsSelect.selectedOptions) .map((opt) => opt.value) .filter(Boolean); shopOverrides[currentOverrideShop] = { locationId: locId, locationName: locId ? locName : "", tagIds, }; refreshOptionBadges(); } overrideShopSelect.addEventListener("change", (e) => { saveActiveOverride(); currentOverrideShop = e.target.value; updateOverrideFields(); }); const deleteOverrideBtn = overlay.querySelector("#hb-delete-override"); if (deleteOverrideBtn) { deleteOverrideBtn.addEventListener("click", () => { if (!currentOverrideShop) return; if ( !confirm( `Are you sure you want to remove the override for "${currentOverrideShop}"?`, ) ) { return; } GM_deleteValue(`homebox_override_${currentOverrideShop}`); delete shopOverrides[currentOverrideShop]; // Also prune from registered entities if not currently active on this page let currentRegistered = []; try { currentRegistered = JSON.parse( GM_getValue("homebox_registered_entities", "[]"), ); } catch { // ignore } currentRegistered = currentRegistered.filter( (e) => e.host !== currentOverrideShop && e.adapterId !== currentOverrideShop, ); GM_setValue( "homebox_registered_entities", JSON.stringify(currentRegistered), ); showToast("Override removed!", "info"); close(); Config.showSettingsModal(); }); } loadTenantsBtn.addEventListener("click", () => loadTenants(false)); loadLocationsBtn.addEventListener("click", loadLocations); loadTagsBtn.addEventListener("click", loadTags); tenantSelect.addEventListener("change", () => { loadLocations(); }); saveBtn.addEventListener("click", () => { const url = overlay.querySelector("#hb-url").value.trim(); const token = overlay.querySelector("#hb-token").value.trim(); const tenantId = tenantSelect.value; const selectedTenantOption = tenantSelect.options[tenantSelect.selectedIndex]; const tenantName = selectedTenantOption ? selectedTenantOption.textContent : ""; const locationId = locationSelect.value; const selectedLocationOption = locationSelect.options[locationSelect.selectedIndex]; const locationName = selectedLocationOption ? selectedLocationOption.textContent : ""; const tagIds = Array.from(tagsSelect.selectedOptions) .map((opt) => opt.value) .filter(Boolean); if (!url || !token) { showToast("Please fill in URL and Token fields!", "error"); return; } const matchedGroup = loadedGroups.find((g) => g.id === tenantId); const currency = matchedGroup ? matchedGroup.currency || "EUR" : config.currency || "EUR"; const enableListingsInput = overlay.querySelector( "#hb-enable-listings", ); const enableListingButtons = enableListingsInput ? enableListingsInput.checked : true; Config.save({ url, token, tenantId, tenantName, locationId, locationName, currency, enableListingButtons, tagIds, }); // Clean up or re-inject listing buttons based on preference if (!enableListingButtons) { document .querySelectorAll(".homebox-list-btn") .forEach((btn) => btn.remove()); } else { injectInterfaceButtons(); } // Commit active overrides saveActiveOverride(); Object.keys(shopOverrides).forEach((key) => { GM_setValue( `homebox_override_${key}`, JSON.stringify(shopOverrides[key]), ); }); showToast("Settings saved successfully!", "success"); close(); }); }, }; // --- Homebox API Interface --- const HomeboxAPI = { getSelf(url, token) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/users/self`; const headers = { Authorization: `Bearer ${token}`, Accept: "application/json", }; GM_xmlhttpRequest({ method: "GET", url: requestUrl, headers: headers, onload: (response) => { logDebug( "Get User Self (User Profile)", requestUrl, headers, response.status, response.responseText, ); if (response.status === 200) { try { const data = JSON.parse(response.responseText); resolve(data); } catch { reject( new Error("Invalid JSON response for user profile request."), ); } } else { reject( new Error( `Failed to load user profile (Kood ${response.status})`, ), ); } }, onerror: () => { logDebug( "Get User Self (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while requesting user profile.")); }, }); }); }, getGroupsAll(url, token) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/groups/all`; const headers = { Authorization: `Bearer ${token}`, Accept: "application/json", }; GM_xmlhttpRequest({ method: "GET", url: requestUrl, headers: headers, onload: (response) => { logDebug( "Get All Groups (Collections)", requestUrl, headers, response.status, response.responseText, ); if (response.status === 200) { try { const data = JSON.parse(response.responseText); resolve(Array.isArray(data) ? data : []); } catch { reject(new Error("Invalid JSON response for groups request.")); } } else { reject( new Error(`Failed to load groups (Kood ${response.status})`), ); } }, onerror: () => { logDebug( "Get All Groups (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while requesting groups.")); }, }); }); }, getTags(url, token, tenantId) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/tags`; const headers = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (tenantId) { headers["X-Tenant"] = tenantId; } GM_xmlhttpRequest({ method: "GET", url: requestUrl, headers: headers, onload: (response) => { logDebug( "Get Tags", requestUrl, headers, response.status, response.responseText, ); if (response.status === 200) { try { const data = JSON.parse(response.responseText); const tags = data.tags || (Array.isArray(data) ? data : []); resolve(tags); } catch { reject(new Error("Invalid JSON response for tags request.")); } } else { reject( new Error(`Failed to load tags (Kood ${response.status})`), ); } }, onerror: () => { logDebug( "Get Tags (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while requesting tags.")); }, }); }); }, getLocations(url, token, tenantId) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/entities/tree`; const headers = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (tenantId) { headers["X-Tenant"] = tenantId; } GM_xmlhttpRequest({ method: "GET", url: requestUrl, headers: headers, onload: (response) => { logDebug( "Get Location Tree", requestUrl, headers, response.status, response.responseText, ); if (response.status === 200) { try { const data = JSON.parse(response.responseText); const tree = Array.isArray(data) ? data : data.item || []; const flattenTree = (nodes, depth = 0) => { let list = []; nodes.forEach((node) => { const prefix = "\u00A0\u00A0\u00A0".repeat(depth) + (depth > 0 ? "└─ " : ""); list.push({ id: node.id, name: prefix + node.name, }); if (node.children && node.children.length > 0) { list = list.concat(flattenTree(node.children, depth + 1)); } }); return list; }; const flatLocations = flattenTree(tree); resolve(flatLocations); } catch { reject( new Error("Invalid JSON response for locations request."), ); } } else { reject( new Error(`Failed to load locations (Kood ${response.status})`), ); } }, onerror: () => { logDebug( "Get Location Tree (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while requesting locations.")); }, }); }); }, createItem(url, token, tenantId, itemData) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/entities`; const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }; if (tenantId) { headers["X-Tenant"] = tenantId; } GM_xmlhttpRequest({ method: "POST", url: requestUrl, headers: headers, data: JSON.stringify(itemData), onload: (response) => { logDebug( "Create Entity (Item)", requestUrl, headers, response.status, response.responseText, ); if (response.status === 201 || response.status === 200) { try { const data = JSON.parse(response.responseText); resolve(data); } catch { reject( new Error( "Item was created, but response could not be read.", ), ); } } else { reject( new Error(`Failed to create item (Kood ${response.status})`), ); } }, onerror: () => { logDebug( "Create Entity (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while creating item.")); }, }); }); }, uploadAttachmentFromUrl( url, token, tenantId, itemId, imageUrl, nameOverride, ) { return new Promise((resolve, reject) => { if (!imageUrl) { resolve(); return; } GM_xmlhttpRequest({ method: "GET", url: imageUrl, responseType: "blob", onload: (imgRes) => { if (imgRes.status !== 200) { reject( new Error(`Failed to download image (Kood ${imgRes.status})`), ); return; } const blob = imgRes.response; if (!blob) { reject(new Error("Invalid image data.")); return; } const formData = new FormData(); let fileName = nameOverride || "product.jpg"; if (!nameOverride) { try { const urlPath = new URL(imageUrl).pathname; fileName = urlPath.substring(urlPath.lastIndexOf("/") + 1) || "product.jpg"; } catch { // Fallback } } else { if ( imageUrl.toLowerCase().endsWith(".pdf") && !fileName.toLowerCase().endsWith(".pdf") ) { fileName += ".pdf"; } } formData.append("name", fileName); formData.append("file", blob, fileName); const requestUrl = `${url.replace(/\/$/, "")}/api/v1/entities/${itemId}/attachments`; const headers = { Authorization: `Bearer ${token}`, }; if (tenantId) { headers["X-Tenant"] = tenantId; } GM_xmlhttpRequest({ method: "POST", url: requestUrl, headers: headers, data: formData, fetch: true, onload: (uploadRes) => { logDebug( "Upload Attachment", requestUrl, headers, uploadRes.status, uploadRes.responseText, ); if (uploadRes.status === 201 || uploadRes.status === 200) { resolve(); } else { reject( new Error( `Failed to upload file (Kood ${uploadRes.status})`, ), ); } }, onerror: () => { logDebug( "Upload Attachment (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while uploading file.")); }, }); }, onerror: () => { reject( new Error("Network error while downloading file from store."), ); }, }); }); }, updateItem(url, token, tenantId, itemId, updateData) { return new Promise((resolve, reject) => { const requestUrl = `${url.replace(/\/$/, "")}/api/v1/entities/${itemId}`; const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", }; if (tenantId) { headers["X-Tenant"] = tenantId; } GM_xmlhttpRequest({ method: "PUT", url: requestUrl, headers: headers, data: JSON.stringify(updateData), onload: (response) => { logDebug( "Update Entity (PUT)", requestUrl, headers, response.status, response.responseText, ); if (response.status === 200) { try { const data = JSON.parse(response.responseText); resolve(data); } catch { reject( new Error( "Item was updated, but response could not be read.", ), ); } } else { reject( new Error(`Failed to update item (Kood ${response.status})`), ); } }, onerror: () => { logDebug( "Update Entity (Error)", requestUrl, headers, 0, "Network error.", ); reject(new Error("Network error while updating item.")); }, }); }); }, }; // --- Dynamic UI Injection Engine --- const activeAdapters = {}; function handleImportClick(e, adapterId, mode, cardIndex, btnElement) { console.log( `%c[Homebox Core] Button Clicked! Starting import flow for adapter: ${adapterId} (mode: ${mode}, cardIndex: ${cardIndex})`, "color: #10b981; font-weight: bold; font-size: 13px;", ); if (!Config.isConfigured()) { console.warn( "[Homebox Core] Credentials not configured! Opening settings modal.", ); showToast("Please configure Homebox connection first!", "error"); Config.showSettingsModal(); return; } const originalHtml = btnElement.innerHTML; btnElement.disabled = true; btnElement.classList.add("homebox-loading"); const messageId = crypto.randomUUID(); console.log(`[Homebox Core] Generated messageId: ${messageId}`); // Set up a listener for the scrape result from the active adapter const onScrapeResult = (resEvent) => { const detail = resEvent.detail; const incomingMsgId = detail?.messageId || detail?.payload?.messageId; console.log( `[Homebox Core] Received homebox:scrape:result (incomingMsgId: ${incomingMsgId}, adapter: ${detail?.adapterId})`, detail, ); if (incomingMsgId !== messageId || detail?.adapterId !== adapterId) { console.log( `[Homebox Core] Ignoring scrape result - messageId mismatch (${incomingMsgId} !== ${messageId}) or adapter mismatch (${detail?.adapterId} !== ${adapterId})`, ); return; } window.removeEventListener("homebox:scrape:result", onScrapeResult); const payload = detail.payload || {}; if (!payload.success || !payload.itemDetails) { console.error("[Homebox Core] Scrape failed:", payload.error); showToast( `Error scraping product details: ${payload.error || "Unknown error"}`, "error", ); resetButton(); return; } console.log( "[Homebox Core] Scrape successful! Item details:", payload.itemDetails, ); executeImport(payload.itemDetails); }; window.addEventListener("homebox:scrape:result", onScrapeResult); // Trigger the scrape event to the matching adapter console.log( `[Homebox Core] Dispatching homebox:scrape:trigger to adapter: ${adapterId}...`, ); window.dispatchEvent( new CustomEvent("homebox:scrape:trigger", { detail: { protocolVersion: PROTOCOL_VERSION, messageId, sender: "core", adapterId, action: "scrape:trigger", payload: { messageId, mode, cardIndex, }, }, }), ); function resetButton() { btnElement.innerHTML = originalHtml; btnElement.classList.remove("homebox-loading"); btnElement.disabled = false; } function executeImport(itemDetails) { const config = Config.load(); // Resolve host / domain from item URL or current location let host; try { const u = new URL( itemDetails.url || window.location.href, window.location.href, ); host = u.hostname.replace(/^www\./i, "").toLowerCase(); } catch { host = window.location.hostname.replace(/^www\./i, "").toLowerCase(); } // Priority override resolution: // 1. Specific Hostname override (e.g. "handymann.ee") // 2. Platform Adapter override (e.g. "woocommerce") // 3. Global settings default let hostOverride = null; let adapterOverride = null; try { hostOverride = JSON.parse( GM_getValue(`homebox_override_${host}`, "null"), ); } catch { // ignore } try { adapterOverride = JSON.parse( GM_getValue(`homebox_override_${adapterId}`, "null"), ); } catch { // ignore } const finalLocationId = (hostOverride && hostOverride.locationId) || (adapterOverride && adapterOverride.locationId) || config.locationId; const finalTagIds = hostOverride && hostOverride.tagIds && hostOverride.tagIds.length > 0 ? hostOverride.tagIds : adapterOverride && adapterOverride.tagIds && adapterOverride.tagIds.length > 0 ? adapterOverride.tagIds : config.tagIds || []; console.log( `[Homebox Core] Override resolution for host "${host}" / adapter "${adapterId}":`, { hostMatched: !!hostOverride, adapterMatched: !!adapterOverride, finalLocationId, finalTagIds, }, ); console.log( "[Homebox Core] Starting 3-step import pipeline for:", itemDetails.name, ); console.log( `[Homebox Core] Target collection: ${config.tenantId || "(default)"}, Location: ${finalLocationId || "(none)"}, Tags:`, finalTagIds, ); showToast("Product scraped! Creating Homebox item...", "info"); // Cent value representation const centsValue = Math.round((parseFloat(itemDetails.value) || 0) * 100); let description = itemDetails.description || ""; if (description.length > 255) { description = description.substring(0, 255); } const payload = { name: itemDetails.name, description: description, type: "item", quantity: 1, value: centsValue, currency: itemDetails.currency || config.currency || "EUR", url: itemDetails.url, purchasedAt: new Date().toISOString(), }; if (finalLocationId) { payload.parentId = finalLocationId; } if (itemDetails.manufacturer) { payload.manufacturer = itemDetails.manufacturer; } console.log( `[Homebox Core] Step 1/3: Creating item entity at ${config.url}/api/v1/entities...`, payload, ); HomeboxAPI.createItem(config.url, config.token, config.tenantId, payload) .then((createdItem) => { const itemId = createdItem.id || (createdItem.item && createdItem.item.id); const entityTypeId = createdItem.entityTypeId || (createdItem.entityType && (typeof createdItem.entityType === "string" ? createdItem.entityType : createdItem.entityType.id)); if (!itemId) { throw new Error( "Item was created, but item ID was not found in response.", ); } console.log( `[Homebox Core] Step 1/3 completed! Created item ID: ${itemId}`, ); showToast("Item created! Saving purchase details...", "info"); const updatePayload = { id: itemId, name: itemDetails.name, description: description, quantity: 1, parentId: finalLocationId || null, entityTypeId: entityTypeId || null, purchasePrice: parseFloat(itemDetails.value) || 0, purchaseDate: itemDetails.purchaseDate || new Date().toISOString(), purchaseFrom: itemDetails.url || "", manufacturer: itemDetails.manufacturer || "", modelNumber: itemDetails.sku || "", tagIds: finalTagIds, }; const customFields = []; if (itemDetails.sellerName) { customFields.push( { name: "seller_name", textValue: itemDetails.sellerName, type: "text", }, { name: "seller_url", textValue: itemDetails.sellerUrl || "", type: "text", }, ); } if (Array.isArray(itemDetails.fields)) { itemDetails.fields.forEach((f) => { if (f && f.name && f.textValue !== undefined) { customFields.push(f); } }); } if (customFields.length > 0) { updatePayload.fields = customFields; } console.log( `[Homebox Core] Step 2/3: Updating entity details for ${itemId}...`, updatePayload, ); return HomeboxAPI.updateItem( config.url, config.token, config.tenantId, itemId, updatePayload, ).then(() => { console.log(`[Homebox Core] Step 2/3 completed!`); showToast("Item and purchase details saved!", "success"); return { itemId, itemDetails }; }); }) .then(({ itemId, itemDetails }) => { // Upload gallery photos sequentially if (itemDetails.imageUrls && itemDetails.imageUrls.length > 0) { console.log( `[Homebox Core] Step 3/3: Uploading ${itemDetails.imageUrls.length} images...`, itemDetails.imageUrls, ); showToast( `Uploading ${itemDetails.imageUrls.length} images...`, "info", ); let uploadChain = Promise.resolve(); itemDetails.imageUrls.forEach((imgUrl, idx) => { uploadChain = uploadChain.then(() => { console.log( `[Homebox Core] Uploading image ${idx + 1}/${itemDetails.imageUrls.length}: ${imgUrl}`, ); showToast( `Uploading image ${idx + 1}/${itemDetails.imageUrls.length}...`, "info", ); return HomeboxAPI.uploadAttachmentFromUrl( config.url, config.token, config.tenantId, itemId, imgUrl, ); }); }); return uploadChain.then(() => { console.log("[Homebox Core] All images uploaded successfully!"); showToast("Images uploaded successfully!", "success"); return { itemId, itemDetails }; }); } return { itemId, itemDetails }; }) .then(({ itemId, itemDetails }) => { // Upload extra file attachments sequentially if (itemDetails.attachments && itemDetails.attachments.length > 0) { console.log( `[Homebox Core] Uploading ${itemDetails.attachments.length} attachments...`, itemDetails.attachments, ); showToast( `Uploading ${itemDetails.attachments.length} files...`, "info", ); let uploadChain = Promise.resolve(); itemDetails.attachments.forEach((file, idx) => { uploadChain = uploadChain.then(() => { console.log( `[Homebox Core] Uploading attachment ${idx + 1}/${itemDetails.attachments.length}: ${file.name}`, ); showToast( `Uploading file ${idx + 1}/${itemDetails.attachments.length}: ${file.name}...`, "info", ); return HomeboxAPI.uploadAttachmentFromUrl( config.url, config.token, config.tenantId, itemId, file.url, file.name, ); }); }); return uploadChain.then(() => { console.log( "[Homebox Core] All attachments uploaded successfully!", ); showToast("Files uploaded successfully!", "success"); }); } }) .then(() => { console.log( `%c[Homebox Core] Import completed successfully for ${itemDetails.name}!`, "color: #059669; font-weight: bold; font-size: 13px;", ); btnElement.innerHTML = ` Added! `; btnElement.classList.remove("homebox-loading"); btnElement.classList.add("homebox-success"); setTimeout(() => { btnElement.classList.remove("homebox-success"); resetButton(); }, 2500); }) .catch((err) => { console.error("[Homebox Core Error during import]", err); showToast(err.message, "error"); resetButton(); }); } } function injectInterfaceButtons() { const config = Config.load(); Object.keys(activeAdapters).forEach((id) => { const adapter = activeAdapters[id]; const ui = adapter.ui || {}; // 1. Ingest Detail Product Page buttons if (ui.detail && ui.detail.targetSelector) { if (!document.querySelector(".homebox-detail-injected")) { let anchor = null; const selectors = Array.isArray(ui.detail.targetSelector) ? ui.detail.targetSelector : ui.detail.targetSelector.split(",").map((s) => s.trim()); for (const sel of selectors) { const candidates = document.querySelectorAll(sel); for (const el of candidates) { if (ui.detail.textFilter && Array.isArray(ui.detail.textFilter)) { const text = el.textContent || ""; if ( !ui.detail.textFilter.some((filter) => text.includes(filter)) ) { continue; } } if (el.closest(".owp-floating-bar")) { continue; } // Skip elements that are hidden (e.g. mobile containers hidden on desktop) try { const style = window.getComputedStyle(el); if (style.display === "none" || style.visibility === "hidden") { continue; } } catch { // ignore } anchor = el; break; } if (anchor) break; } if (anchor) { console.log( `%c[Homebox Core] Injected detail button into:`, "color: #10b981; font-weight: bold;", anchor, ); const theme = ui.detail.theme || {}; const btn = document.createElement("button"); btn.type = "button"; btn.className = "homebox-injected-btn homebox-detail-injected"; if (theme.nativeClasses && theme.nativeClasses.length > 0) { theme.nativeClasses.forEach((cls) => btn.classList.add(cls)); } else { btn.classList.add("homebox-injected-btn-fallback"); } if (theme.styleOverrides) { Object.assign(btn.style, theme.styleOverrides); } const iconHtml = ` `; btn.innerHTML = ` ✓ ${iconHtml} ${ui.detail.buttonText || "Add to Homebox"} `; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); handleImportClick(e, id, "detail", 0, btn); }); const pos = ui.detail.insertPosition || "beforeend"; anchor.insertAdjacentElement(pos, btn); } else { console.warn( `%c[Homebox Core] Could not find visible anchor for detail button using selectors:`, "color: #f59e0b; font-weight: bold;", selectors, ); } } } // 2. Ingest Grid Product Lists buttons if (ui.listing && ui.listing.cardSelector) { if (config.enableListingButtons === false) { return; } const cards = document.querySelectorAll(ui.listing.cardSelector); cards.forEach((card, idx) => { if (card.querySelector(".homebox-list-btn")) return; const anchor = ui.listing.targetSelector ? card.querySelector(ui.listing.targetSelector) : card; if (!anchor) return; const theme = ui.listing.theme || {}; const btn = document.createElement("button"); btn.type = "button"; btn.className = "homebox-list-btn"; btn.title = "Add to Homebox"; if (theme.nativeClasses && theme.nativeClasses.length > 0) { theme.nativeClasses.forEach((cls) => btn.classList.add(cls)); } if (theme.styleOverrides) { Object.assign(btn.style, theme.styleOverrides); } btn.innerHTML = ` ✓ `; btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); handleImportClick(e, id, "listing", idx, btn); }); // Ensure parent relative position for correct layouting on grid hover dislays const originalPosition = window.getComputedStyle(card).position; if (originalPosition === "static" || !originalPosition) { card.style.position = "relative"; } const pos = ui.listing.insertPosition || "beforeend"; anchor.insertAdjacentElement(pos, btn); }); } }); } // --- IPC Event Listeners & Router --- window.addEventListener("homebox:register", (e) => { const detail = e.detail; if (!detail || detail.sender !== "adapter" || detail.action !== "register") return; const adapterId = detail.adapterId; activeAdapters[adapterId] = { name: detail.adapterName, ui: detail.payload?.ui, }; // Auto-record registered shop and its platform const rawHostname = detail.payload?.hostname || window.location.hostname || ""; const cleanHost = rawHostname.replace(/^www\./i, "").toLowerCase(); if (cleanHost) { try { let registered = JSON.parse( GM_getValue("homebox_registered_entities", "[]"), ); const existing = registered.find((item) => item.host === cleanHost); const adapterLabel = detail.adapterName || ADAPTER_NAMES[detail.adapterId] || detail.adapterId; if (!existing) { registered.push({ host: cleanHost, adapterId: detail.adapterId, adapterName: adapterLabel, }); GM_setValue( "homebox_registered_entities", JSON.stringify(registered), ); } else if (!existing.adapterName && adapterLabel) { existing.adapterName = adapterLabel; GM_setValue( "homebox_registered_entities", JSON.stringify(registered), ); } } catch { // ignore } } logDebug( "Handshake / Registration Received", window.location.href, {}, 200, JSON.stringify(detail), ); // Reply with handshake confirmation window.dispatchEvent( new CustomEvent("homebox:handshake-ack", { detail: { protocolVersion: PROTOCOL_VERSION, messageId: detail.messageId, sender: "core", adapterId, action: "handshake-ack", payload: { coreVersion: "1.0.0", isConfigured: Config.isConfigured(), }, }, }), ); // Kickoff immediate injection check injectInterfaceButtons(); }); // Announce Core presence to any adapters that loaded earlier window.dispatchEvent( new CustomEvent("homebox:core-ready", { detail: { protocolVersion: PROTOCOL_VERSION, coreVersion: "1.0.0", }, }), ); // --- Initializer --- function init() { console.log( `%c[Homebox Core] Engine active on ${window.location.hostname}`, "color: #10b981; font-weight: bold;", ); const style = document.createElement("style"); style.textContent = GLOBAL_CSS; document.head.appendChild(style); if (typeof GM_registerMenuCommand !== "undefined") { GM_registerMenuCommand("Configure Homebox connection", () => { Config.showSettingsModal(); }); } injectInterfaceButtons(); const observer = new MutationObserver(() => { injectInterfaceButtons(); }); observer.observe(document.body, { childList: true, subtree: true }); // Missing adapter health check (allow ample time for adapter retry handshake) setTimeout(() => { if (Object.keys(activeAdapters).length === 0) { console.warn( `%c[Homebox Core]%c No adapter connected for "${window.location.hostname}". An adapter userscript for this site is missing and needs to be installed.`, "color: #10b981; font-weight: bold;", "color: #d97706; font-weight: 500;", ); } }, 3500); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();