// @ts-nocheck const fieldGrid = document.querySelector("#fieldGrid"); const variantHeader = document.querySelector("#variantHeader"); const manufacturerSelect = document.querySelector("#manufacturerSelect"); const manufacturerTrigger = document.querySelector("#manufacturerTrigger"); const manufacturerMenu = document.querySelector("#manufacturerMenu"); const manufacturerSearch = document.querySelector("#manufacturerSearch"); const manufacturerOptions = document.querySelector("#manufacturerOptions"); const manufacturerValue = document.querySelector("#manufacturerValue"); const languageSelect = document.querySelector("#languageSelect"); const languageValue = document.querySelector("#languageValue"); const recordIdElement = document.querySelector("#recordId"); const loadProductButton = document.querySelector("#loadProductButton"); const previousProductButton = document.querySelector("#previousProductButton"); const nextProductButton = document.querySelector("#nextProductButton"); const tableBody = document.querySelector("#variantRows"); const variantTable = document.querySelector(".variant-grid"); const pictureSlot = document.querySelector("#pictureSlot"); const scanInput = document.querySelector("#scanInput"); const suggestedModal = document.querySelector("#suggestedModal"); const suggestedTitle = document.querySelector("#suggestedTitle"); const suggestedList = document.querySelector("#suggestedList"); const suggestedCloseButton = document.querySelector("#suggestedCloseButton"); const mappingState = document.querySelector("#mappingState"); const mappingStatusText = document.querySelector("#mappingStatusText"); const mappingList = document.querySelector("#mappingList"); const mappingDetails = document.querySelector("#mappingDetails"); const findSourcesButton = document.querySelector("#findSourcesButton"); const getPicturesButton = document.querySelector("#getPicturesButton"); const sidebarDrawer = document.querySelector("#sidebarDrawer"); const sidebarOpenButton = document.querySelector("#sidebarOpenButton"); const sidebarCloseButton = document.querySelector("#sidebarCloseButton"); const sidebarOverlay = document.querySelector("#sidebarOverlay"); const sourceModal = document.querySelector("#sourceModal"); const sourceTitle = document.querySelector("#sourceTitle"); const sourceList = document.querySelector("#sourceList"); const sourceBatchActionButton = document.querySelector("#sourceBatchActionButton"); const sourceReloadButton = document.querySelector("#sourceReloadButton"); const sourceCloseButton = document.querySelector("#sourceCloseButton"); const settingsButton = document.querySelector("#settingsButton"); const themeToggle = document.querySelector("#themeToggle"); const settingsModal = document.querySelector("#settingsModal"); const settingsCloseButton = document.querySelector("#settingsCloseButton"); const databaseModeNote = document.querySelector("#databaseModeNote"); const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]'); const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle"); const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]'); const databaseStatus = document.querySelector("#databaseStatus"); const databaseStateDot = document.querySelector("#databaseStateDot"); const localDbFields = document.querySelector("#localDbFields"); const localDbInputs = document.querySelectorAll("#localDbFields input"); const testLocalDbButton = document.querySelector("#testLocalDbButton"); const localDbTestResult = document.querySelector("#localDbTestResult"); const openAdminerButton = document.querySelector("#openAdminerButton"); const openDbGateButton = document.querySelector("#openDbGateButton"); const adminerServerValue = document.querySelector("#adminerServerValue"); const adminerUserValue = document.querySelector("#adminerUserValue"); const adminerDatabaseValue = document.querySelector("#adminerDatabaseValue"); const lightContrastSelect = document.querySelector("#lightContrastSelect"); const darkContrastSelect = document.querySelector("#darkContrastSelect"); const catalogScreen = document.querySelector(".catalog-screen"); const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle"); const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle"); const MIN_PRODUCT_PICTURE_SIZE = 800; let sidebarUserClosed = false; let mappingLoadToken = 0; const mappingCacheKey = "catalog-maker:mapping-cache"; const legacyPermissionStorageKey = "catalog-maker:db-permissions"; const permissionStorageKeys = { local: "catalog-maker:db-permissions:local-9bplus", live: "catalog-maker:db-permissions:live-9bplus", scrape: "catalog-maker:db-permissions:local-scrape", }; const nativeFetch = window.fetch.bind(window); window.fetch = (input, init = {}) => { const url = typeof input === "string" ? input : input.url; if (!url.startsWith("/api/")) return nativeFetch(input, init); const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; const permissions = readDatabasePermissions(selectedMode); const localWritesEnabled = ["local", "scrape"].includes(selectedMode) && [ permissions.allowInsertToggle, permissions.allowUpdateToggle, permissions.allowDeleteToggle, ].some(Boolean); // Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus. headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local"); headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0"); headers.set("X-Local-Write-Access", localWritesEnabled ? "1" : "0"); return nativeFetch(input, { ...init, headers }); }; let currentPosition = 1; let totalPositions = 0; let currentProduct = null; let currentCombinations = []; let loadRequestId = 0; let applicationMode = "dry-run"; let databaseConnectionState = "unknown"; const localDraftKey = "catalog-maker:current-product-draft:v3"; const selectedManufacturerKey = "catalog-maker:selected-manufacturer"; let localDraftProductKey = ""; const fieldNames = [ "id_manufacturer", "code_manufacture", "voc_discount", "max_moc_discount", "supplier_reference", "catalog_valid_until", "name", "price - moc", "combinations", "id_attribute_group", "id_supplier", "id_category_default", "9b_category_name", "manufacturer_name", "catalog category", "gender", "position/total", "Photos loaded", "Reference separator", ]; const variantColumns = [ { key: "status", label: "status", width: 92 }, { key: "color", label: "color", width: 156 }, { key: "combi", label: "combi", width: 60, align: "center" }, { key: "refer", label: "refer", width: 64 }, { key: "ean13", label: "ean13", width: 148, align: "center", problem: true }, { key: "nineB", label: "9b", width: 58, align: "center" }, { key: "supplierStock", label: "sup", width: 58, align: "center" }, { key: "id", label: "id", width: 74, align: "center" }, { key: "voc", label: "voc", width: 74, align: "center", amount: true }, { key: "moc", label: "moc", width: 74, align: "center", amount: true }, { key: "productAttribute", label: "pr.at", width: 70, align: "center" }, { key: "catalogColor", label: "color", width: 110 }, { key: "catalogCombi", label: "combi", width: 70, align: "center" }, { key: "nineBVoc", label: "9b.voc", width: 74, align: "center", amount: true }, { key: "nineBMoc", label: "9b.moc", width: 74, align: "center", amount: true }, { key: "eanStatus", label: "", width: 84, align: "center" }, ]; const productFieldMap = { id_manufacturer: "idManufacturer", code_manufacture: "codeManufacture", voc_discount: "vocDiscount", max_moc_discount: "maxMocDiscount", supplier_reference: "supplierReference", catalog_valid_until: "catalogValidUntil", name: "name", "price - moc": "priceMoc", combinations: "combinations", id_attribute_group: "idAttributeGroup", id_supplier: "idSupplier", id_category_default: "idCategoryDefault", "9b_category_name": "category9bName", manufacturer_name: "manufacturerName", "catalog category": "catalogCategory", gender: "gender", "position/total": "positionTotal", "Photos loaded": "photosLoaded", "Reference separator": "referenceSeparator", }; await loadStatus(); loadTheme(); loadAppearance(); renderFields(); renderTableHeader(); setTableWidth(); await loadManufacturers(); await loadLanguages(); languageSelect.addEventListener("change", syncLanguagePickerLabel); manufacturerSelect.addEventListener("change", () => { if (manufacturerSelect.value) { localStorage.setItem(selectedManufacturerKey, manufacturerSelect.value); } else { localStorage.removeItem(selectedManufacturerKey); } syncManufacturerPickerLabel(); closeManufacturerPicker(); if (manufacturerSelect.value) { loadMappingSources({ checkUrls: false, preferCache: false }); loadProductForSelectedManufacturer(1); } else { renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); clearProductState("Select manufacturer to load data."); } }); manufacturerTrigger.addEventListener("click", toggleManufacturerPicker); manufacturerSearch.addEventListener("input", renderManufacturerOptions); manufacturerSearch.addEventListener("keydown", (event) => { if (event.key === "Escape") closeManufacturerPicker(); }); document.addEventListener("click", (event) => { if (!event.target.closest("#manufacturerPicker")) closeManufacturerPicker(); }); loadProductButton.disabled = true; loadProductButton.title = "Load existing product attribute info."; loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); sourceReloadButton.addEventListener("click", () => findSourcesForCurrentProduct(true)); sourceBatchActionButton.addEventListener("click", handleSourceBatchAction); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel); mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace); sidebarOpenButton.addEventListener("click", openSidebar); sidebarCloseButton.addEventListener("click", closeSidebar); sidebarOverlay.addEventListener("click", closeSidebar); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeSidebar(); }); previousProductButton.addEventListener("click", () => { if (!manufacturerSelect.value || currentPosition <= 1) return; loadProductForSelectedManufacturer(currentPosition - 1); }); nextProductButton.addEventListener("click", () => { if (!manufacturerSelect.value || (totalPositions && currentPosition >= totalPositions)) return; loadProductForSelectedManufacturer(currentPosition + 1); }); scanInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); loadProductByScannedEan(); }); suggestedCloseButton.addEventListener("click", closeSuggestedProducts); suggestedModal.addEventListener("click", (event) => { if (event.target === suggestedModal) closeSuggestedProducts(); }); sourceCloseButton.addEventListener("click", closeSourceCandidates); sourceModal.addEventListener("click", (event) => { if (event.target === sourceModal) closeSourceCandidates(); }); settingsButton.addEventListener("click", openSettings); themeToggle.addEventListener("click", toggleTheme); settingsCloseButton.addEventListener("click", closeSettings); settingsModal.addEventListener("click", (event) => { if (event.target === settingsModal) closeSettings(); }); for (const input of databaseModeInputs) input.addEventListener("change", saveDatabaseMode); for (const input of permissionInputs) input.addEventListener("change", saveSettings); for (const input of transactionModeInputs) input.addEventListener("change", saveSettings); for (const input of localDbInputs) input.addEventListener("change", saveLocalDbSettings); testLocalDbButton.addEventListener("click", testLocalDbConnection); openAdminerButton.addEventListener("click", openAdminer); openDbGateButton.addEventListener("click", openDbGate); lightContrastSelect.addEventListener("change", saveAppearance); darkContrastSelect.addEventListener("change", saveAppearance); loadDatabaseMode(); syncSidebarForViewport(); setTimeout(() => { if (manufacturerSelect.value) { loadMappingSources({ checkUrls: false, preferCache: true }); loadProductForSelectedManufacturer(1); } else { renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); updateNavigationState(); } }, 0); async function loadStatus() { const response = await fetch("/api/status"); const status = await response.json(); applicationMode = status.mode || "dry-run"; if (status.database) { databaseStatus.dataset.driver = status.database.driver; const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; databaseConnectionState = selectedMode === "local" && status.database.driver !== "mariadb" ? "error" : "ready"; } } function renderFields() { fieldGrid.replaceChildren(...fieldNames.flatMap((name) => { const label = document.createElement("div"); label.className = "field-label"; label.textContent = name; const value = document.createElement("div"); value.className = "field-value"; value.dataset.field = name; value.textContent = ""; return [label, value]; })); } function renderTableHeader() { variantHeader.replaceChildren(...variantColumns.map((column) => { const cell = document.createElement("div"); cell.className = "variant-header-cell"; cell.setAttribute("role", "columnheader"); cell.textContent = column.label; cell.dataset.column = column.key; cell.style.textAlign = column.align || ""; return cell; })); } function setTableWidth() { const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0); variantTable.style.setProperty("--variant-min-width", `${Math.max(totalWidth, 820)}px`); variantTable.style.setProperty("--variant-columns", variantColumns.map((column) => `${column.width}px`).join(" ")); } async function loadManufacturers() { manufacturerSelect.replaceChildren(createOption("", "Select manufacturer")); try { const response = await fetch("/api/manufacturers"); const data = await response.json(); if (!response.ok) throw new Error(data.message || "Local DB is not configured."); for (const manufacturer of data.items || []) { manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name)); } if (data.message) manufacturerSelect.append(createOption("", data.message)); restoreSelectedManufacturer(); syncManufacturerPickerLabel(); renderManufacturerOptions(); } catch (error) { manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); restoreSelectedManufacturer(); syncManufacturerPickerLabel(); renderManufacturerOptions(); } } function restoreSelectedManufacturer() { const savedManufacturerId = localStorage.getItem(selectedManufacturerKey); if (!savedManufacturerId) return; const savedOption = [...manufacturerSelect.options].find((option) => option.value === savedManufacturerId); if (savedOption) { manufacturerSelect.value = savedManufacturerId; } else { localStorage.removeItem(selectedManufacturerKey); } } function toggleManufacturerPicker() { if (manufacturerMenu.hidden) { manufacturerMenu.hidden = false; manufacturerTrigger.setAttribute("aria-expanded", "true"); manufacturerSearch.value = ""; renderManufacturerOptions(); if (manufacturerSelect.options.length > 11) manufacturerSearch.focus(); } else { closeManufacturerPicker(); } } function closeManufacturerPicker() { manufacturerMenu.hidden = true; manufacturerTrigger.setAttribute("aria-expanded", "false"); } function syncManufacturerPickerLabel() { const option = manufacturerSelect.selectedOptions[0]; manufacturerValue.textContent = option?.textContent || "Select manufacturer"; } function renderManufacturerOptions() { const options = [...manufacturerSelect.options].filter((option) => option.value); const query = manufacturerSearch.value.trim().toLocaleLowerCase(); const filtered = options.filter((option) => option.textContent.toLocaleLowerCase().includes(query)); const visible = filtered.slice(0, 10); manufacturerOptions.replaceChildren(...visible.map((option) => { const item = document.createElement("button"); item.className = "manufacturer-option"; item.type = "button"; item.setAttribute("role", "option"); item.textContent = option.textContent; item.setAttribute("aria-selected", String(option.value === manufacturerSelect.value)); item.addEventListener("click", () => { manufacturerSelect.value = option.value; manufacturerSelect.dispatchEvent(new Event("change", { bubbles: true })); }); return item; })); if (!visible.length) { const empty = document.createElement("div"); empty.className = "manufacturer-empty"; empty.textContent = "No manufacturer found."; manufacturerOptions.append(empty); } } function createOption(value, label) { const option = document.createElement("option"); option.value = value; option.textContent = label; return option; } function setButtonLabel(button, label) { const labelElement = button.querySelector(":scope > span"); if (labelElement) { labelElement.textContent = label; } else { button.textContent = label; } } function setButtonIcon(button, iconName) { const useElement = button.querySelector(":scope > svg use"); if (useElement) useElement.setAttribute("href", `/icons.svg#${iconName}`); } function setSourceBatchAction(mode) { sourceBatchActionButton.dataset.mode = mode; sourceBatchActionButton.hidden = false; sourceBatchActionButton.disabled = false; if (mode === "get-pictures") { setButtonIcon(sourceBatchActionButton, "image"); setButtonLabel(sourceBatchActionButton, "Get all pictures"); return; } setButtonIcon(sourceBatchActionButton, "save"); setButtonLabel(sourceBatchActionButton, "Save all"); } async function loadLanguages() { languageSelect.replaceChildren(createOption("", "Select language")); try { const response = await fetch("/api/languages"); const data = await response.json(); if (!response.ok) throw new Error(data.message || "Local DB is not configured."); for (const language of data.items || []) { const label = language.isoCode ? `${language.name} (${language.isoCode})` : language.name; languageSelect.append(createOption(String(language.id), label)); } if (data.message) languageSelect.append(createOption("", data.message)); } catch (error) { languageSelect.append(createOption("", error.message || "Local DB is not configured.")); } syncLanguagePickerLabel(); } function syncLanguagePickerLabel() { const option = languageSelect.selectedOptions[0]; languageValue.textContent = option?.textContent || "Select language"; } async function loadMappingSources({ checkUrls = false, preferCache = false } = {}) { const manufacturerId = manufacturerSelect.value; const loadToken = ++mappingLoadToken; if (!manufacturerId) { renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); return; } const cached = preferCache ? readMappingCache(manufacturerId) : null; if (cached) { renderMappingSources(cached); return; } setMappingStatus("Loading mapping...", "is-loading"); try { const params = new URLSearchParams({ manufacturerId }); if (checkUrls) params.set("check", "1"); const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`); const data = await response.json(); if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId) return; if (!response.ok) { throw new Error(data.message || "Mapping load failed."); } renderMappingSources(data); if (data.mapped && (data.items || []).some((item) => item.enabled)) { saveMappingCache(manufacturerId, data); } } catch (error) { if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId) return; renderMappingSources({ mapped: false, items: [], message: error.message || "Mapping load failed.", }); } } function readMappingCache(manufacturerId) { try { const cache = JSON.parse(localStorage.getItem(mappingCacheKey) || "{}"); return cache[String(manufacturerId)] || null; } catch { return null; } } function saveMappingCache(manufacturerId, data) { try { const cache = JSON.parse(localStorage.getItem(mappingCacheKey) || "{}"); cache[String(manufacturerId)] = { ...data, cachedAt: new Date().toISOString(), }; localStorage.setItem(mappingCacheKey, JSON.stringify(cache)); } catch { // Mapping cache is only a UI convenience. If storage fails, normal loading still works. } } function renderMappingSources(data) { const items = data.items || []; if (!data.mapped || !items.length) { setMappingStatus(data.message || "Mapping missing", "is-missing"); mappingList.replaceChildren(); mappingDetails.open = false; return; } const enabledCount = items.filter((item) => item.enabled).length; const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; const checkedCount = items.filter((item) => item.enabled && item.health).length; const ready = enabledCount > 0 && failedCount === 0; const statusText = ready ? `Sources OK ✓ (${testedCount}/${enabledCount})` : data.message || `Sources problem (${testedCount}/${enabledCount})`; const displayStatusText = ready && !checkedCount ? `Mapping OK (${enabledCount})` : statusText; setMappingStatus(displayStatusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing"); mappingDetails.open = false; mappingList.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`; const name = document.createElement("span"); name.className = "mapping-source-name"; name.textContent = formatValue(item.name || item.key || item.url || "Source"); const status = document.createElement("span"); status.className = `mapping-source-status ${getMappingHealthClass(item)}`; status.textContent = getMappingHealthText(item); row.append(name, status); return row; })); } function setMappingStatus(text, stateClass) { mappingState.textContent = ""; mappingState.className = `mapping-state-dot ${stateClass}`; mappingState.setAttribute("aria-label", text); mappingStatusText.textContent = text; } function getMappingHealthText(item) { if (!item.enabled) return "off"; if (!item.health) return "ready"; if (item.health === "ok") return `${item.httpStatus || 200} OK`; if (item.health === "missing-url") return "no URL"; if (item.httpStatus) return `${item.httpStatus}`; if (item.health) return "error"; return "netest"; } function getMappingHealthClass(item) { if (!item.enabled) return "is-muted"; if (!item.health) return "is-ok"; if (item.health === "ok") return "is-ok"; return "is-bad"; } async function loadProductForSelectedManufacturer(position = currentPosition) { const requestId = ++loadRequestId; const manufacturerId = manufacturerSelect.value; if (!manufacturerId) { clearProductState("Select manufacturer to load data."); return; } setNavigationEnabled(false); setTableMessage("Loading product data..."); try { const response = await fetch(`/api/catalog-maker/product?manufacturerId=${encodeURIComponent(manufacturerId)}&position=${encodeURIComponent(position)}`); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "Product load failed."); } if (!data.product) { if (requestId !== loadRequestId) return; clearProductState(data.message || "No product to do for selected manufacturer."); return; } if (requestId !== loadRequestId) return; renderProduct(data.product); currentCombinations = data.combinations; renderCombinations(currentCombinations); } catch (error) { setTableMessage(error.message); } finally { updateNavigationState(); } } async function loadProductByScannedEan() { const requestId = ++loadRequestId; const manufacturerId = manufacturerSelect.value; const ean = normalizeScannedEan(scanInput.value); if (!manufacturerId) { setTableMessage("Select manufacturer before scanning EAN."); return; } if (!ean) { setTableMessage("Scan EAN first."); return; } setNavigationEnabled(false); scanInput.disabled = true; setTableMessage(`Searching EAN ${ean}...`); try { const params = new URLSearchParams({ manufacturerId, ean }); const response = await fetch(`/api/catalog-maker/product-by-ean?${params.toString()}`); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "EAN search failed."); } if (!data.product) { if (requestId !== loadRequestId) return; await showSuggestedProducts({ ean }); return; } if (requestId !== loadRequestId) return; renderProduct(data.product); currentCombinations = data.combinations; renderCombinations(currentCombinations); scanInput.value = ""; if (!data.product.idProduct) { await showSuggestedProducts({ ean, supplierReference: data.product.supplierReference, productName: data.product.name, }); } } catch (error) { setTableMessage(error.message); } finally { scanInput.disabled = false; scanInput.focus(); updateNavigationState(); } } async function showSuggestedProducts({ ean, supplierReference = "", productName = "" }) { const params = new URLSearchParams({ manufacturerId: manufacturerSelect.value, supplierReference: supplierReference || currentProduct?.supplierReference || scanInput.value || ean, productName: productName || currentProduct?.name || "", }); const response = await fetch(`/api/catalog-maker/suggested-products?${params.toString()}`); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "Suggested product lookup failed."); } suggestedTitle.textContent = `Sugested product for ${ean}`; renderSuggestedProducts(data.items); suggestedModal.hidden = false; } function renderSuggestedProducts(items) { if (!items.length) { const empty = document.createElement("div"); empty.className = "suggested-row"; empty.textContent = "No suggested products found."; suggestedList.replaceChildren(empty); return; } suggestedList.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.className = "suggested-row"; const image = document.createElement("img"); image.alt = item.name || "Product"; if (item.imageUrl) image.src = item.imageUrl; const id = document.createElement("div"); id.className = "suggested-id"; id.textContent = formatValue(item.idProduct); const name = document.createElement("div"); name.className = "suggested-name"; name.textContent = formatValue(item.name); name.title = name.textContent; const reference = document.createElement("div"); reference.className = "suggested-reference"; reference.textContent = formatValue(item.supplierReference); reference.title = reference.textContent; const active = document.createElement("div"); active.className = "suggested-active"; active.textContent = formatValue(item.active); const pick = document.createElement("button"); pick.className = "suggested-pick"; pick.type = "button"; pick.textContent = "Use"; pick.addEventListener("click", () => selectSuggestedProduct(item)); row.append(image, id, name, reference, active, pick); return row; })); } function selectSuggestedProduct(item) { if (!currentProduct) return; currentProduct = { ...currentProduct, idProduct: item.idProduct, imageUrl: item.imageUrl || currentProduct.imageUrl, }; recordIdElement.textContent = formatRecordId(item.idProduct); renderPicture(currentProduct.imageUrl); closeSuggestedProducts(); updateNavigationState(); } function closeSuggestedProducts() { suggestedModal.hidden = true; suggestedList.replaceChildren(); } async function findSourcesForCurrentProduct(forceRefresh = false) { if (!manufacturerSelect.value || !currentProduct) { setTableMessage("Select manufacturer and product first."); return; } const savedDraft = readLocalDraft(); if (savedDraft?.sources && !forceRefresh) { renderSourceCandidates(savedDraft.sources.items || []); updateFindSourcesButtonState(); sourceTitle.textContent = "Source candidates (cached)"; sourceModal.hidden = false; return; } const supplierColorGroup = getFirstSupplierColorGroup(); findSourcesButton.disabled = true; sourceReloadButton.disabled = true; setButtonLabel(findSourcesButton, "Finding sources..."); try { const params = new URLSearchParams({ manufacturerId: manufacturerSelect.value, productName: currentProduct.name || "", supplierReference: currentProduct.supplierReference || "", ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), color: supplierColorGroup?.color || "", }); const response = await fetch(`/api/catalog-maker/find-sources?${params.toString()}`); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "Source search failed."); } saveLocalDraft({ sources: data }); updateFindSourcesButtonState(); renderSourceCandidates(data.items || []); const targetLabel = supplierColorGroup ? `${supplierColorGroup.color} / EAN ${supplierColorGroup.supplierEan}` : currentProduct.name || currentProduct.supplierReference; sourceTitle.textContent = `Source candidates for ${targetLabel}`; sourceModal.hidden = false; } catch (error) { setTableMessage(error.message || "Source search failed."); } finally { sourceReloadButton.disabled = false; updateFindSourcesButtonState(); } } async function getPicturesForCurrentProduct(sourceKey = "") { if (!manufacturerSelect.value || !currentProduct) { setTableMessage("Select manufacturer and product first."); return; } const savedDraft = readLocalDraft(); const cachedPictures = savedDraft?.picturesBySource?.[sourceKey || "all"]; if (cachedPictures) { renderPictureCandidates(cachedPictures.items || []); sourceTitle.textContent = `Picture candidates (cached${sourceKey ? `: ${sourceKey}` : ""})`; sourceModal.hidden = false; return; } const supplierColorGroup = getFirstSupplierColorGroup(); getPicturesButton.disabled = true; setButtonLabel(getPicturesButton, "Loading pictures..."); try { const params = new URLSearchParams({ manufacturerId: manufacturerSelect.value, productName: currentProduct.name || "", supplierReference: currentProduct.supplierReference || "", ean: supplierColorGroup?.supplierEan || currentCombinations[0]?.ean13 || "", eanGroup: (supplierColorGroup?.supplierRows || []).map((row) => row.ean13).filter(Boolean).join(","), color: supplierColorGroup?.color || "", }); if (sourceKey) params.set("sourceKey", sourceKey); const response = await fetch(`/api/catalog-maker/get-pictures?${params.toString()}`); const data = await response.json(); if (!response.ok) throw new Error(data.message || "Picture lookup failed."); const picturesBySource = readLocalDraft()?.picturesBySource || {}; picturesBySource[sourceKey || "all"] = data; saveLocalDraft({ picturesBySource }); renderPictureCandidates(data.items || []); sourceTitle.textContent = `Picture candidates${sourceKey ? `: ${sourceKey}` : ""}`; sourceModal.hidden = false; } catch (error) { setTableMessage(error.message || "Picture lookup failed."); } finally { getPicturesButton.disabled = false; setButtonLabel(getPicturesButton, "Get pictures"); updateNavigationState(); } } function getFirstSupplierColorGroup() { const groups = new Map(); for (const combination of currentCombinations) { const color = formatValue(combination.color) || "Unknown color"; const group = groups.get(color) || { color, rows: [], supplierRows: [], supplierEan: "", }; group.rows.push(combination); if (Number(combination.supplierStock) === 1) { group.supplierRows.push(combination); if (!group.supplierEan) group.supplierEan = combination.ean13; } groups.set(color, group); } return [...groups.values()].find((group) => group.supplierRows.length > 0) || null; } function renderSourceCandidates(items) { sourceBatchActionButton.hidden = true; if (!items.length) { const empty = document.createElement("div"); empty.className = "source-row"; empty.textContent = "No source mapping found."; sourceList.replaceChildren(empty); return; } setSourceBatchAction("get-pictures"); sourceList.replaceChildren(...items.map((item) => { const section = document.createElement("section"); section.className = "source-section"; const header = document.createElement("div"); header.className = "source-section-header"; const name = document.createElement("strong"); name.textContent = formatValue(item.source?.name || item.source?.key || "Source"); const status = document.createElement("span"); status.className = `source-status ${item.status === "found" ? "is-ok" : "is-muted"}`; status.textContent = item.status === "found" ? `${item.candidates.length} found` : item.status; const actions = document.createElement("div"); actions.className = "source-section-actions"; const pictures = document.createElement("button"); pictures.type = "button"; pictures.className = "source-picture-action"; pictures.textContent = "Get pictures"; pictures.addEventListener("click", () => getPicturesForCurrentProduct(item.source?.key || "")); actions.append(status, pictures); header.append(name, actions); const rows = item.candidates.length ? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source)) : [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)]; section.append(header, ...rows); return section; })); } function renderPictureCandidates(items) { sourceBatchActionButton.hidden = true; const rows = []; let position = 0; for (const item of items) { for (const candidate of item.candidates || []) { for (const picture of candidate.pictures || []) { position += 1; const sourcePosition = position; const target = buildPictureTargetInfo(); const row = document.createElement("div"); row.className = "picture-candidate"; const preview = document.createElement("a"); preview.href = picture; preview.target = "_blank"; preview.rel = "noreferrer"; preview.className = "picture-candidate-preview"; const image = document.createElement("img"); image.src = picture; image.alt = candidate.rawTitle || item.source?.name || "Product picture"; image.loading = "lazy"; preview.append(image); const details = document.createElement("div"); details.className = "picture-candidate-meta"; const label = document.createElement("span"); label.className = "picture-candidate-title"; label.textContent = `${item.source?.name || item.source?.key || "Source"} picture #${sourcePosition}`; const dimensions = document.createElement("span"); dimensions.className = "picture-candidate-dimensions"; dimensions.textContent = "Size: loading..."; image.addEventListener("load", () => { if (!image.naturalWidth || !image.naturalHeight) { dimensions.textContent = "Size: unknown"; row.classList.remove("is-too-small"); return; } const shortestSide = Math.min(image.naturalWidth, image.naturalHeight); const longestSide = Math.max(image.naturalWidth, image.naturalHeight); const isTooSmall = longestSide < MIN_PRODUCT_PICTURE_SIZE; row.classList.toggle("is-too-small", isTooSmall); dimensions.textContent = isTooSmall ? `Size: ${image.naturalWidth} x ${image.naturalHeight}px - too small, min long side ${MIN_PRODUCT_PICTURE_SIZE}px` : shortestSide < MIN_PRODUCT_PICTURE_SIZE ? `Size: ${image.naturalWidth} x ${image.naturalHeight}px - OK for 1:1 crop` : `Size: ${image.naturalWidth} x ${image.naturalHeight}px`; }); image.addEventListener("error", () => { dimensions.textContent = "Size: image preview failed"; row.classList.add("is-too-small"); }); const assignment = document.createElement("span"); assignment.className = "picture-candidate-target"; assignment.textContent = target.label; const duplicateHint = document.createElement("span"); duplicateHint.className = "picture-candidate-duplicate"; duplicateHint.textContent = "DB: not saved"; details.append(label, dimensions, assignment, duplicateHint); const action = document.createElement("button"); action.type = "button"; action.className = "source-picture-action picture-candidate-save"; action.textContent = "Save to DB"; action.savePictureCandidate = () => savePictureCandidateToDb({ picture, item, candidate, image, position: sourcePosition, target, action, duplicateHint, }); action.addEventListener("click", () => action.savePictureCandidate()); row.append(preview, details, action); rows.push(row); } } } if (!rows.length) { const empty = document.createElement("div"); empty.className = "source-row"; empty.textContent = "No pictures found. Open the source manually to inspect it."; sourceList.replaceChildren(empty); return; } setSourceBatchAction("save-pictures"); sourceList.replaceChildren(...rows); } function buildPictureTargetInfo() { const supplierColorGroup = getFirstSupplierColorGroup(); const firstSupplierRow = supplierColorGroup?.supplierRows?.[0] || currentCombinations[0] || {}; const color = supplierColorGroup?.color || formatValue(firstSupplierRow.color); const ean13 = supplierColorGroup?.supplierEan || firstSupplierRow.ean13 || ""; if (color) { return { strategy: "color", color, sizeValue: "", combi: "", ean13, idProductCatalog: firstSupplierRow.idProductCatalog || currentProduct?.idProductCatalog || "", label: `Target: color ${color}${ean13 ? ` / EAN ${ean13}` : ""}`, }; } return { strategy: "unknown", color: "", sizeValue: "", combi: "", ean13, idProductCatalog: firstSupplierRow.idProductCatalog || currentProduct?.idProductCatalog || "", label: ean13 ? `Target: unknown / EAN ${ean13}` : "Target: needs manual assignment", }; } async function savePictureCandidateToDb({ picture, item, candidate, image, position, target, action, duplicateHint, }) { action.disabled = true; action.textContent = "Saving..."; duplicateHint.textContent = "DB: saving..."; try { const payload = { sourceKey: item.source?.key || "", sourceName: item.source?.name || "", sourceType: item.source?.type || "", imageUrl: picture, pageUrl: candidate.url || item.searchUrl || "", sourcePosition: position, manufacturerId: manufacturerSelect.value, idProductCatalog: currentProduct?.idProductCatalog || target.idProductCatalog || "", idProduct: currentProduct?.idProduct || "", supplierReference: currentProduct?.supplierReference || "", ean13: target.ean13 || "", color: target.color || "", sizeValue: target.sizeValue || "", combi: target.combi || "", targetStrategy: target.strategy, targetColor: target.color || "", targetSize: target.sizeValue || "", targetCombi: target.combi || "", targetEan13: target.ean13 || "", targetIdProductCatalog: target.idProductCatalog || currentProduct?.idProductCatalog || "", matchConfidence: target.strategy === "color" ? 70 : 0, width: image.naturalWidth || "", height: image.naturalHeight || "", }; const response = await fetch("/api/catalog-maker/save-picture", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || "Picture save failed."); action.textContent = "Saved"; action.dataset.saved = "1"; duplicateHint.textContent = data.duplicateStatus === "exact_duplicate" ? `DB: duplicate of #${data.duplicateOfCandidateId || "known"}` : `DB: saved #${data.candidateId}`; return true; } catch (error) { action.disabled = false; action.textContent = "Save to DB"; duplicateHint.textContent = `DB: ${error.message || "save failed"}`; return false; } } async function saveAllVisiblePictureCandidates() { const buttons = [...sourceList.querySelectorAll(".picture-candidate-save")] .filter((button) => button.savePictureCandidate && button.dataset.saved !== "1"); if (!buttons.length) return; sourceBatchActionButton.disabled = true; setButtonLabel(sourceBatchActionButton, `Saving 0/${buttons.length}`); let savedCount = 0; for (const button of buttons) { const saved = await button.savePictureCandidate(); if (saved) savedCount += 1; setButtonLabel(sourceBatchActionButton, `Saving ${savedCount}/${buttons.length}`); } sourceBatchActionButton.disabled = false; setButtonLabel(sourceBatchActionButton, savedCount === buttons.length ? "Saved all" : `Saved ${savedCount}/${buttons.length}`); } async function handleSourceBatchAction() { if (sourceBatchActionButton.dataset.mode === "get-pictures") { sourceBatchActionButton.disabled = true; setButtonLabel(sourceBatchActionButton, "Loading pictures..."); try { await getPicturesForCurrentProduct(""); } finally { sourceBatchActionButton.disabled = false; if (sourceBatchActionButton.dataset.mode === "get-pictures") { setButtonLabel(sourceBatchActionButton, "Get all pictures"); } } return; } await saveAllVisiblePictureCandidates(); } function collectPictureUrls(items) { return items.flatMap((item) => (item.candidates || []).flatMap((candidate) => candidate.pictures || [])).filter((url, index, all) => all.indexOf(url) === index); } function saveLocalDraft(patch) { if (!localDraftProductKey) return; let draft = {}; try { draft = JSON.parse(localStorage.getItem(localDraftKey) || "{}"); } catch { draft = {}; } localStorage.setItem(localDraftKey, JSON.stringify({ ...draft, productKey: localDraftProductKey, ...patch, })); } function readLocalDraft() { if (!localDraftProductKey) return null; try { const draft = JSON.parse(localStorage.getItem(localDraftKey) || "null"); return draft?.productKey === localDraftProductKey ? draft : null; } catch { return null; } } function clearLocalDraft() { localStorage.removeItem(localDraftKey); localDraftProductKey = ""; updateFindSourcesButtonState(); } function updateFindSourcesButtonState() { const hasSources = Boolean(readLocalDraft()?.sources); setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources"); findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; } function renderSourceCandidateRow(candidate, source) { const row = document.createElement("div"); row.className = "source-row"; const title = document.createElement("span"); title.className = "source-row-title"; title.textContent = formatValue(candidate.title || candidate.url); title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); const action = document.createElement("button"); action.type = "button"; action.className = "source-row-action"; action.textContent = "Open"; action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action)); row.append(title, action); return row; } async function openSourceCandidate(url, source, button) { if (!url || !source?.key || !manufacturerSelect.value) return; const previousText = button.textContent; button.disabled = true; button.textContent = "Opening..."; try { const params = new URLSearchParams({ manufacturerId: manufacturerSelect.value, sourceKey: source.key, url, }); const response = await fetch(`/api/catalog-maker/open-source?${params.toString()}`); const data = await response.json(); if (!response.ok || data.error) throw new Error(data.error || "Source browser could not be opened."); } catch (error) { alert(error.message || "Source browser could not be opened."); } finally { button.disabled = false; button.textContent = previousText; } } function renderManualSearchRow(url, label, source) { return renderSourceCandidateRow({ title: label, url, }, source); } function closeSourceCandidates() { sourceModal.hidden = true; sourceList.replaceChildren(); } function openSettings() { loadDatabaseMode(); settingsModal.hidden = false; } function syncSidebarForViewport() { if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) openSidebar(); else closeSidebar(); } function openSidebar() { sidebarUserClosed = false; sidebarDrawer.classList.add("is-open"); sidebarOverlay.hidden = false; sidebarOpenButton.hidden = true; sidebarOpenButton.setAttribute("aria-expanded", "true"); sidebarDrawer.setAttribute("aria-hidden", "false"); catalogScreen.classList.remove("sidebar-collapsed"); } function closeSidebar() { sidebarUserClosed = true; sidebarDrawer.classList.remove("is-open"); sidebarOverlay.hidden = true; sidebarOpenButton.hidden = false; sidebarOpenButton.setAttribute("aria-expanded", "false"); sidebarDrawer.setAttribute("aria-hidden", "true"); catalogScreen.classList.add("sidebar-collapsed"); } function toggleSecondaryPanel() { const isCollapsed = catalogScreen.classList.toggle("secondary-collapsed"); secondaryPanelToggle.setAttribute("aria-expanded", String(!isCollapsed)); secondaryPanelToggle.setAttribute("aria-label", isCollapsed ? "Expand product details" : "Collapse product details"); secondaryPanelToggle.title = isCollapsed ? "Expand product details" : "Collapse product details"; secondaryPanelToggle.textContent = isCollapsed ? "›" : "‹"; } function toggleMainWorkspace() { const isCollapsed = catalogScreen.classList.toggle("workspace-collapsed"); mainWorkspaceToggle.setAttribute("aria-expanded", String(!isCollapsed)); mainWorkspaceToggle.setAttribute("aria-label", isCollapsed ? "Expand main workspace" : "Collapse main workspace"); mainWorkspaceToggle.title = isCollapsed ? "Expand main workspace" : "Collapse main workspace"; mainWorkspaceToggle.textContent = isCollapsed ? "›" : "‹"; } function loadTheme() { const isDark = localStorage.getItem("catalog-maker:theme") === "dark"; document.documentElement.classList.toggle("dark", isDark); themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme"; themeToggle.setAttribute("aria-pressed", String(isDark)); } function loadAppearance() { const settings = JSON.parse(localStorage.getItem("catalog-maker:appearance") || "{}"); lightContrastSelect.value = settings.lightContrast || "medium"; darkContrastSelect.value = settings.darkContrast || "medium"; applyAppearance(); } function saveAppearance() { localStorage.setItem("catalog-maker:appearance", JSON.stringify({ lightContrast: lightContrastSelect.value, darkContrast: darkContrastSelect.value, })); applyAppearance(); } function applyAppearance() { const root = document.documentElement; root.classList.remove("contrast-light-low", "contrast-light-medium", "contrast-light-high", "contrast-dark-low", "contrast-dark-medium", "contrast-dark-high"); root.classList.add(`contrast-light-${lightContrastSelect.value || "medium"}`); root.classList.add(`contrast-dark-${darkContrastSelect.value || "medium"}`); } function toggleTheme() { const isDark = !document.documentElement.classList.contains("dark"); localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light"); loadTheme(); } function closeSettings() { settingsModal.hidden = true; } function loadDatabaseMode() { const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; const input = document.querySelector(`input[name="databaseMode"][value="${mode}"]`); if (input) input.checked = true; applyPermissionInputs(readDatabasePermissions(mode)); const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; const transactionInput = document.querySelector(`input[name="transactionMode"][value="${transactionMode}"]`); if (transactionInput) transactionInput.checked = true; const localDbSettings = JSON.parse(localStorage.getItem("catalog-maker:local-db-settings") || "{}"); for (const input of localDbInputs) { if (Object.prototype.hasOwnProperty.call(localDbSettings, input.id)) input.value = localDbSettings[input.id]; } syncDatabasePermissions(mode); syncLocalDbFields(mode); syncAdminerLoginHint(); renderDatabaseModeNote(mode); refreshDatabaseStatusLabel(); } function saveDatabaseMode(event) { const mode = ["local", "live", "scrape"].includes(event.target.value) ? event.target.value : "local"; localStorage.setItem("catalog-maker:database-mode", mode); applyPermissionInputs(readDatabasePermissions(mode)); syncDatabasePermissions(mode); syncLocalDbFields(mode); saveSettings(); renderDatabaseModeNote(mode); refreshDatabaseStatusLabel(); } function saveLocalDbSettings() { const settings = {}; for (const input of localDbInputs) settings[input.id] = input.value; localStorage.setItem("catalog-maker:local-db-settings", JSON.stringify(settings)); syncAdminerLoginHint(); } function syncAdminerLoginHint() { const host = document.querySelector("#localDbHost")?.value || "127.0.0.1"; const port = document.querySelector("#localDbPort")?.value || "3306"; const user = document.querySelector("#localDbUser")?.value || "root"; const catalogDb = document.querySelector("#localDbName")?.value || "9bplus"; adminerServerValue.textContent = `${host}:${port}`; adminerUserValue.textContent = user; adminerDatabaseValue.textContent = `${catalogDb} / catalog_scrape`; } async function testLocalDbConnection() { saveLocalDbSettings(); testLocalDbButton.disabled = true; localDbTestResult.textContent = "Testing connection..."; localDbTestResult.className = "settings-note"; const payload = Object.fromEntries([...localDbInputs].map((input) => [ input.id.replace("localDb", "").toLowerCase(), input.value, ])); try { const response = await fetch("/api/database/test", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); const data = await response.json(); if (!response.ok || !data.connected) throw new Error(data.message || "Connection failed."); localDbTestResult.textContent = `Connected: ${data.version}`; localDbTestResult.className = "settings-note connection-ok"; } catch (error) { localDbTestResult.textContent = `Connection error: ${error.message}`; localDbTestResult.className = "settings-note connection-error"; } finally { testLocalDbButton.disabled = false; } } async function openAdminer() { openAdminerButton.disabled = true; const previousNote = databaseModeNote.textContent; databaseModeNote.textContent = "Opening Adminer..."; try { const response = await fetch("/api/tools/open-adminer"); const data = await response.json(); if (!response.ok || !data.opened) throw new Error(data.message || "Adminer could not be opened."); databaseModeNote.textContent = `Adminer opened: ${data.url}`; } catch (error) { databaseModeNote.textContent = `Adminer error: ${error.message}`; } finally { openDbGateButton.disabled = false; window.setTimeout(() => { if (databaseModeNote.textContent.startsWith("Adminer")) { renderDatabaseModeNote(localStorage.getItem("catalog-maker:database-mode") || "local"); } else if (databaseModeNote.textContent === "Opening Adminer...") { databaseModeNote.textContent = previousNote; } }, 3500); } } async function openDbGate() { openDbGateButton.disabled = true; const previousNote = databaseModeNote.textContent; databaseModeNote.textContent = "Opening DBGate..."; try { const response = await fetch("/api/tools/open-dbgate"); const data = await response.json(); if (!response.ok || !data.opened) throw new Error(data.message || "DBGate could not be opened."); databaseModeNote.textContent = `DBGate opened: ${data.url}`; } catch (error) { databaseModeNote.textContent = `DBGate error: ${error.message}`; } finally { openDbGateButton.disabled = false; window.setTimeout(() => { if (databaseModeNote.textContent.startsWith("DBGate")) { renderDatabaseModeNote(localStorage.getItem("catalog-maker:database-mode") || "local"); } else if (databaseModeNote.textContent === "Opening DBGate...") { databaseModeNote.textContent = previousNote; } }, 3500); } } function syncLocalDbFields(mode) { localDbFields.hidden = mode !== "local"; } function syncDatabasePermissions(mode) { const isReadOnly = mode === "live"; for (const input of permissionInputs) { input.disabled = isReadOnly; if (isReadOnly) input.checked = false; } } function saveSettings() { const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; const permissions = {}; for (const input of permissionInputs) permissions[input.id] = input.checked; localStorage.setItem(permissionStorageKeys[mode] || permissionStorageKeys.local, JSON.stringify(permissions)); if (mode === "local") { localStorage.setItem(legacyPermissionStorageKey, JSON.stringify(permissions)); } const transactionMode = [...transactionModeInputs].find((input) => input.checked)?.value || "transaction"; localStorage.setItem("catalog-maker:transaction-mode", transactionMode); refreshDatabaseStatusLabel(); } function refreshDatabaseStatusLabel() { const mode = localStorage.getItem("catalog-maker:database-mode") || "local"; const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction"; const permissions = readDatabasePermissions(mode); const writesEnabled = ["local", "scrape"].includes(mode) && (permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle); const dbLabel = mode === "live" ? "Live 9bplus DB" : mode === "scrape" ? "Local scrape DB" : "Local 9bplus DB"; const configuredDriver = databaseStatus.dataset.driver; const connectionError = (mode === "local" && configuredDriver !== "mariadb") || (mode === "live" && configuredDriver !== "endpoint"); const connectionLabel = connectionError ? "connection error" : "connected"; const accessLabel = writesEnabled ? "writes enabled" : "read-only"; const executionLabel = applicationMode === "dry-run" ? "dry-run" : (transactionMode === "dry-run" ? "dry-run" : "transaction"); databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; databaseStatus.classList.toggle("database-error", connectionError); databaseStateDot.className = `database-state-dot ${databaseConnectionState === "unknown" ? "is-pending" : connectionError ? "is-error" : "is-ready"}`; databaseStateDot.setAttribute("aria-label", connectionError ? "Database disconnected" : databaseConnectionState === "unknown" ? "Database connection pending" : "Database connected"); } function readDatabasePermissions(mode = "local") { const storageKey = permissionStorageKeys[mode] || permissionStorageKeys.local; const stored = readJsonStorage(storageKey); if (stored) return stored; if (mode === "local") { const legacy = readJsonStorage(legacyPermissionStorageKey); if (legacy) return legacy; } return {}; } function applyPermissionInputs(permissions) { for (const input of permissionInputs) input.checked = permissions[input.id] === true; } function readJsonStorage(key) { try { const value = localStorage.getItem(key); return value ? JSON.parse(value) : null; } catch { return null; } } function renderDatabaseModeNote(mode) { databaseModeNote.textContent = mode === "live" ? "Live 9bplus DB selected for read-only data." : mode === "scrape" ? "Local scrape DB selected for scraper settings and scraped data. Catalog reads stay on Local 9bplus DB." : "Local 9bplus DB selected."; } function renderProduct(product) { const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`; if (localDraftProductKey && localDraftProductKey !== productKey) clearLocalDraft(); localDraftProductKey = productKey; currentProduct = product; recordIdElement.textContent = formatRecordId(product.idProduct); currentPosition = Number(product.position) || currentPosition; totalPositions = Number(product.total) || 0; const enrichedProduct = { ...product, positionTotal: product.position && product.total ? `${product.position}/${product.total}` : "", }; for (const [label, key] of Object.entries(productFieldMap)) { const valueElement = document.querySelector(`[data-field="${CSS.escape(label)}"]`); if (!valueElement) continue; valueElement.textContent = formatValue(enrichedProduct[key]); } renderPicture(product.imageUrl); updateFindSourcesButtonState(); updateNavigationState(); } function renderCombinations(combinations) { if (!combinations.length) { setTableMessage("No combinations found for selected product."); return; } tableBody.replaceChildren(...combinations.map((item) => { const row = document.createElement("div"); row.className = "variant-row"; row.setAttribute("role", "row"); row.append(...variantColumns.map((column) => { const value = item[column.key]; const text = formatTableValue(value, column); const cell = document.createElement("div"); cell.className = "variant-cell"; cell.setAttribute("role", "cell"); cell.textContent = text; cell.title = text; cell.dataset.column = column.key; cell.dataset.label = column.label; cell.style.textAlign = column.align || ""; if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) { cell.classList.add("problem-ean"); } return cell; })); return row; })); } function clearProductState(message) { clearLocalDraft(); recordIdElement.textContent = ""; currentProduct = null; currentCombinations = []; currentPosition = 1; totalPositions = 0; for (const valueElement of document.querySelectorAll(".field-value")) { valueElement.textContent = ""; } renderPicture(); setTableMessage(message); updateNavigationState(); } function setTableMessage(message) { const row = document.createElement("div"); row.className = "variant-row variant-message-row"; row.setAttribute("role", "row"); const cell = document.createElement("div"); cell.className = "empty-row"; cell.setAttribute("role", "cell"); cell.style.gridColumn = "1 / -1"; cell.textContent = message; row.append(cell); tableBody.replaceChildren(row); } function formatValue(value) { if (value === null || value === undefined) return ""; if (typeof value === "number") return String(value); const text = String(value).trim(); if (/^\d{4}-\d{2}-\d{2}T/.test(text)) return formatDate(text); if (/^-?\d+\.0+$/.test(text)) return String(Number.parseInt(text, 10)); if (/^-?\d+\.\d+$/.test(text)) return trimDecimal(text); return text; } function formatTableValue(value, column) { if (value === null || value === undefined || value === "") return ""; if (column.amount) { const amount = Number(value); if (Number.isFinite(amount)) return String(Math.round(amount)); } return formatValue(value); } function formatRecordId(value) { const text = formatValue(value); return text || ""; } function formatDate(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("cs-CZ", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC", }) .format(date) .replaceAll(" ", ""); } function trimDecimal(value) { const asNumber = Number(value); if (!Number.isFinite(asNumber)) return value; return String(asNumber); } function setNavigationEnabled(enabled) { previousProductButton.disabled = !enabled; nextProductButton.disabled = !enabled; } function updateNavigationState() { previousProductButton.disabled = !manufacturerSelect.value || currentPosition <= 1; nextProductButton.disabled = !manufacturerSelect.value || (totalPositions > 0 && currentPosition >= totalPositions); loadProductButton.disabled = !currentProduct?.idProduct; findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; getPicturesButton.disabled = !manufacturerSelect.value || !currentProduct; } function normalizeScannedEan(value) { const czechKeyboardDigits = new Map([ ["+", "1"], ["ě", "2"], ["š", "3"], ["č", "4"], ["ř", "5"], ["ž", "6"], ["ý", "7"], ["á", "8"], ["í", "9"], ["é", "0"], ]); return String(value ?? "") .trim() .toLowerCase() .split("") .map((char) => czechKeyboardDigits.get(char) || char) .join("") .replace(/\D/g, ""); } function renderPicture(imageUrl) { if (!imageUrl) { pictureSlot.replaceChildren(Object.assign(document.createElement("span"), { textContent: "No picture" })); return; } const image = document.createElement("img"); image.src = imageUrl; image.alt = currentProduct?.name || "Product preview"; image.loading = "lazy"; pictureSlot.replaceChildren(image); } async function loadProductInfoForCurrentProduct() { if (!currentProduct?.idProduct || !manufacturerSelect.value || !currentProduct.supplierReference) { return; } loadProductButton.disabled = true; setButtonLabel(loadProductButton, "Loading info..."); try { const params = new URLSearchParams({ productId: currentProduct.idProduct, manufacturerId: manufacturerSelect.value, supplierReference: currentProduct.supplierReference, }); const response = await fetch(`/api/catalog-maker/product-info?${params.toString()}`); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "Product info load failed."); } const infoByCatalogId = new Map(data.items.map((item) => [String(item.idProductCatalog), item])); currentCombinations = currentCombinations.map((combination) => ({ ...combination, ...(infoByCatalogId.get(String(combination.id)) || {}), })); renderCombinations(currentCombinations); } catch (error) { setTableMessage(error.message); } finally { setButtonLabel(loadProductButton, "Get product info - 00"); updateNavigationState(); } } export {};