diff --git a/agent.md b/agent.md index fcc886f..17d1344 100644 --- a/agent.md +++ b/agent.md @@ -105,6 +105,7 @@ There are three selectable data sources: - Uses the separate local MariaDB database named `catalog_scrape`. - Stores scraper source configuration, manufacturer-to-source mapping, scrape runs, discovered products and assets. - Supplier/manufacturer website settings such as source URL, search templates, fallback mode, browser engine, headless mode, wait time, notes and enabled/priority flags belong in Local scrape DB, not in the product catalog database. +- On first manufacturer selection, mapping status may be loaded quietly from Local scrape DB in the background without opening browsers or running Playwright URL checks. Page reloads should reuse the cached mapping status instead of doing the mapping check again. Manual manufacturer changes, including switching away and back to the same manufacturer, must load mapping from DB again. Browser/search work starts only after explicit user actions such as `Find sources`, `Get pictures`, or opening a source. - The Settings modal should expose an `Open Adminer` action beside `Local scrape DB`; it opens the local Adminer URL from `ADMINER_URL` / `databaseTool.url` for inspecting local MariaDB databases. - The Settings modal may also expose `Open DBGate` beside Adminer for a richer local database browser. DBGate should stay local, start with `npm run dbgate`, and use `DBGATE_URL` / `dbGate.url`. - Start Adminer only through `npm run adminer`; the script downloads the ignored local `tools/adminer/adminer.php` file when missing and runs it on the configured local Adminer URL. Adminer requires local PHP CLI. diff --git a/public/app.js b/public/app.js index e89879c..9102118 100644 --- a/public/app.js +++ b/public/app.js @@ -61,6 +61,8 @@ const catalogScreen = document.querySelector(".catalog-screen"); const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle"); const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle"); let sidebarUserClosed = false; +let mappingLoadToken = 0; +const mappingCacheKey = "catalog-maker:mapping-cache"; const nativeFetch = window.fetch.bind(window); window.fetch = (input, init = {}) => { const url = typeof input === "string" ? input : input.url; @@ -169,7 +171,7 @@ manufacturerSelect.addEventListener("change", () => { syncManufacturerPickerLabel(); closeManufacturerPicker(); if (manufacturerSelect.value) { - loadMappingSources(); + loadMappingSources({ checkUrls: false, preferCache: false }); loadProductForSelectedManufacturer(1); } else { @@ -252,7 +254,7 @@ loadDatabaseMode(); syncSidebarForViewport(); setTimeout(() => { if (manufacturerSelect.value) { - loadMappingSources(); + loadMappingSources({ checkUrls: false, preferCache: true }); loadProductForSelectedManufacturer(1); } else { @@ -419,24 +421,38 @@ function syncLanguagePickerLabel() { const option = languageSelect.selectedOptions[0]; languageValue.textContent = option?.textContent || "Select language"; } -async function loadMappingSources() { +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"); - mappingList.replaceChildren(); try { - const params = new URLSearchParams({ manufacturerId, check: "1" }); + 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: [], @@ -444,6 +460,28 @@ async function loadMappingSources() { }); } } +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) { @@ -455,11 +493,13 @@ function renderMappingSources(data) { 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})`; - setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing"); + 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"); @@ -483,6 +523,8 @@ function setMappingStatus(text, stateClass) { 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") @@ -496,6 +538,8 @@ function getMappingHealthText(item) { 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"; diff --git a/public/app.ts b/public/app.ts index 7f7d5b4..57f6fe7 100644 --- a/public/app.ts +++ b/public/app.ts @@ -61,6 +61,8 @@ const catalogScreen = document.querySelector(".catalog-screen"); const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle"); const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle"); let sidebarUserClosed = false; +let mappingLoadToken = 0; +const mappingCacheKey = "catalog-maker:mapping-cache"; const nativeFetch = window.fetch.bind(window); window.fetch = (input, init = {}) => { @@ -175,7 +177,7 @@ manufacturerSelect.addEventListener("change", () => { syncManufacturerPickerLabel(); closeManufacturerPicker(); if (manufacturerSelect.value) { - loadMappingSources(); + loadMappingSources({ checkUrls: false, preferCache: false }); loadProductForSelectedManufacturer(1); } else { renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); @@ -252,7 +254,7 @@ syncSidebarForViewport(); setTimeout(() => { if (manufacturerSelect.value) { - loadMappingSources(); + loadMappingSources({ checkUrls: false, preferCache: true }); loadProductForSelectedManufacturer(1); } else { renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); @@ -440,27 +442,39 @@ function syncLanguagePickerLabel() { languageValue.textContent = option?.textContent || "Select language"; } -async function loadMappingSources() { +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"); - mappingList.replaceChildren(); try { - const params = new URLSearchParams({ manufacturerId, check: "1" }); + 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: [], @@ -469,6 +483,28 @@ async function loadMappingSources() { } } +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 || []; @@ -482,11 +518,13 @@ function renderMappingSources(data) { 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})`; - setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing"); + const displayStatusText = ready && !checkedCount ? `Mapping OK (${enabledCount})` : statusText; + setMappingStatus(displayStatusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing"); mappingDetails.open = false; mappingList.replaceChildren( @@ -517,6 +555,7 @@ function setMappingStatus(text, stateClass) { 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}`; @@ -526,6 +565,7 @@ function getMappingHealthText(item) { 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"; } diff --git a/tests/ui/catalog-maker.smoke.spec.ts b/tests/ui/catalog-maker.smoke.spec.ts index 69e7ded..ab39b32 100644 --- a/tests/ui/catalog-maker.smoke.spec.ts +++ b/tests/ui/catalog-maker.smoke.spec.ts @@ -33,6 +33,52 @@ test("manufacturer picker shows ten results and supports search", async ({ page await expect(page.locator("#manufacturerOptions .manufacturer-option")).toHaveText(["La Sportiva"]); }); +test("mapping uses cache on reload and reloads from DB on manual manufacturer change", async ({ page }) => { + let mappingRequests = 0; + await page.route("**/api/catalog-maker/mapping-sources**", async (route) => { + mappingRequests += 1; + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + mapped: true, + items: [{ key: "hudy", name: "Hudy", enabled: true }], + }), + }); + }); + + await page.addInitScript(() => { + localStorage.setItem("catalog-maker:selected-manufacturer", "13"); + localStorage.setItem( + "catalog-maker:mapping-cache", + JSON.stringify({ + 13: { + mapped: true, + items: [{ key: "cached-hudy", name: "Cached Hudy", enabled: true }], + }, + }), + ); + }); + + await page.goto("/"); + await page.waitForFunction(() => document.querySelector("#manufacturerSelect")?.value === "13"); + await expect(page.locator("#mappingStatusText")).toContainText("Mapping OK"); + await page.waitForTimeout(300); + expect(mappingRequests).toBe(0); + + await page.locator("#manufacturerSelect").evaluate((select) => { + const nextOption = [...select.options].find((option) => option.value && option.value !== "13"); + select.value = nextOption?.value || ""; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expect.poll(() => mappingRequests).toBe(1); + + await page.locator("#manufacturerSelect").evaluate((select) => { + select.value = "13"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await expect.poll(() => mappingRequests).toBe(2); +}); + test("catalog maker remains usable on a narrow viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/");