Cache mapping status on reload

This commit is contained in:
2026-08-05 22:57:06 +02:00
parent 9b1898a588
commit e3caf25823
4 changed files with 143 additions and 12 deletions
+1
View File
@@ -105,6 +105,7 @@ There are three selectable data sources:
- Uses the separate local MariaDB database named `catalog_scrape`. - Uses the separate local MariaDB database named `catalog_scrape`.
- Stores scraper source configuration, manufacturer-to-source mapping, scrape runs, discovered products and assets. - 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. - 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 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`. - 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. - 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.
+50 -6
View File
@@ -61,6 +61,8 @@ const catalogScreen = document.querySelector(".catalog-screen");
const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle"); const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle");
const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle"); const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle");
let sidebarUserClosed = false; let sidebarUserClosed = false;
let mappingLoadToken = 0;
const mappingCacheKey = "catalog-maker:mapping-cache";
const nativeFetch = window.fetch.bind(window); const nativeFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => { window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input.url; const url = typeof input === "string" ? input : input.url;
@@ -169,7 +171,7 @@ manufacturerSelect.addEventListener("change", () => {
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
closeManufacturerPicker(); closeManufacturerPicker();
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
loadMappingSources(); loadMappingSources({ checkUrls: false, preferCache: false });
loadProductForSelectedManufacturer(1); loadProductForSelectedManufacturer(1);
} }
else { else {
@@ -252,7 +254,7 @@ loadDatabaseMode();
syncSidebarForViewport(); syncSidebarForViewport();
setTimeout(() => { setTimeout(() => {
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
loadMappingSources(); loadMappingSources({ checkUrls: false, preferCache: true });
loadProductForSelectedManufacturer(1); loadProductForSelectedManufacturer(1);
} }
else { else {
@@ -419,24 +421,38 @@ function syncLanguagePickerLabel() {
const option = languageSelect.selectedOptions[0]; const option = languageSelect.selectedOptions[0];
languageValue.textContent = option?.textContent || "Select language"; languageValue.textContent = option?.textContent || "Select language";
} }
async function loadMappingSources() { async function loadMappingSources({ checkUrls = false, preferCache = false } = {}) {
const manufacturerId = manufacturerSelect.value; const manufacturerId = manufacturerSelect.value;
const loadToken = ++mappingLoadToken;
if (!manufacturerId) { if (!manufacturerId) {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
return; return;
} }
const cached = preferCache ? readMappingCache(manufacturerId) : null;
if (cached) {
renderMappingSources(cached);
return;
}
setMappingStatus("Loading mapping...", "is-loading"); setMappingStatus("Loading mapping...", "is-loading");
mappingList.replaceChildren();
try { 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 response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`);
const data = await response.json(); const data = await response.json();
if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId)
return;
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || "Mapping load failed."); throw new Error(data.message || "Mapping load failed.");
} }
renderMappingSources(data); renderMappingSources(data);
if (data.mapped && (data.items || []).some((item) => item.enabled)) {
saveMappingCache(manufacturerId, data);
}
} }
catch (error) { catch (error) {
if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId)
return;
renderMappingSources({ renderMappingSources({
mapped: false, mapped: false,
items: [], 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) { function renderMappingSources(data) {
const items = data.items || []; const items = data.items || [];
if (!data.mapped || !items.length) { if (!data.mapped || !items.length) {
@@ -455,11 +493,13 @@ function renderMappingSources(data) {
const enabledCount = items.filter((item) => item.enabled).length; const enabledCount = items.filter((item) => item.enabled).length;
const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").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 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 ready = enabledCount > 0 && failedCount === 0;
const statusText = ready const statusText = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})` ? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${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; mappingDetails.open = false;
mappingList.replaceChildren(...items.map((item) => { mappingList.replaceChildren(...items.map((item) => {
const row = document.createElement("div"); const row = document.createElement("div");
@@ -483,6 +523,8 @@ function setMappingStatus(text, stateClass) {
function getMappingHealthText(item) { function getMappingHealthText(item) {
if (!item.enabled) if (!item.enabled)
return "off"; return "off";
if (!item.health)
return "ready";
if (item.health === "ok") if (item.health === "ok")
return `${item.httpStatus || 200} OK`; return `${item.httpStatus || 200} OK`;
if (item.health === "missing-url") if (item.health === "missing-url")
@@ -496,6 +538,8 @@ function getMappingHealthText(item) {
function getMappingHealthClass(item) { function getMappingHealthClass(item) {
if (!item.enabled) if (!item.enabled)
return "is-muted"; return "is-muted";
if (!item.health)
return "is-ok";
if (item.health === "ok") if (item.health === "ok")
return "is-ok"; return "is-ok";
return "is-bad"; return "is-bad";
+46 -6
View File
@@ -61,6 +61,8 @@ const catalogScreen = document.querySelector(".catalog-screen");
const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle"); const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle");
const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle"); const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle");
let sidebarUserClosed = false; let sidebarUserClosed = false;
let mappingLoadToken = 0;
const mappingCacheKey = "catalog-maker:mapping-cache";
const nativeFetch = window.fetch.bind(window); const nativeFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => { window.fetch = (input, init = {}) => {
@@ -175,7 +177,7 @@ manufacturerSelect.addEventListener("change", () => {
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
closeManufacturerPicker(); closeManufacturerPicker();
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
loadMappingSources(); loadMappingSources({ checkUrls: false, preferCache: false });
loadProductForSelectedManufacturer(1); loadProductForSelectedManufacturer(1);
} else { } else {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
@@ -252,7 +254,7 @@ syncSidebarForViewport();
setTimeout(() => { setTimeout(() => {
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
loadMappingSources(); loadMappingSources({ checkUrls: false, preferCache: true });
loadProductForSelectedManufacturer(1); loadProductForSelectedManufacturer(1);
} else { } else {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
@@ -440,27 +442,39 @@ function syncLanguagePickerLabel() {
languageValue.textContent = option?.textContent || "Select language"; languageValue.textContent = option?.textContent || "Select language";
} }
async function loadMappingSources() { async function loadMappingSources({ checkUrls = false, preferCache = false } = {}) {
const manufacturerId = manufacturerSelect.value; const manufacturerId = manufacturerSelect.value;
const loadToken = ++mappingLoadToken;
if (!manufacturerId) { if (!manufacturerId) {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
return; return;
} }
const cached = preferCache ? readMappingCache(manufacturerId) : null;
if (cached) {
renderMappingSources(cached);
return;
}
setMappingStatus("Loading mapping...", "is-loading"); setMappingStatus("Loading mapping...", "is-loading");
mappingList.replaceChildren();
try { 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 response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`);
const data = await response.json(); const data = await response.json();
if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId) return;
if (!response.ok) { if (!response.ok) {
throw new Error(data.message || "Mapping load failed."); throw new Error(data.message || "Mapping load failed.");
} }
renderMappingSources(data); renderMappingSources(data);
if (data.mapped && (data.items || []).some((item) => item.enabled)) {
saveMappingCache(manufacturerId, data);
}
} catch (error) { } catch (error) {
if (loadToken !== mappingLoadToken || manufacturerSelect.value !== manufacturerId) return;
renderMappingSources({ renderMappingSources({
mapped: false, mapped: false,
items: [], 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) { function renderMappingSources(data) {
const items = data.items || []; const items = data.items || [];
@@ -482,11 +518,13 @@ function renderMappingSources(data) {
const enabledCount = items.filter((item) => item.enabled).length; const enabledCount = items.filter((item) => item.enabled).length;
const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").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 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 ready = enabledCount > 0 && failedCount === 0;
const statusText = ready const statusText = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})` ? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${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; mappingDetails.open = false;
mappingList.replaceChildren( mappingList.replaceChildren(
@@ -517,6 +555,7 @@ function setMappingStatus(text, stateClass) {
function getMappingHealthText(item) { function getMappingHealthText(item) {
if (!item.enabled) return "off"; if (!item.enabled) return "off";
if (!item.health) return "ready";
if (item.health === "ok") return `${item.httpStatus || 200} OK`; if (item.health === "ok") return `${item.httpStatus || 200} OK`;
if (item.health === "missing-url") return "no URL"; if (item.health === "missing-url") return "no URL";
if (item.httpStatus) return `${item.httpStatus}`; if (item.httpStatus) return `${item.httpStatus}`;
@@ -526,6 +565,7 @@ function getMappingHealthText(item) {
function getMappingHealthClass(item) { function getMappingHealthClass(item) {
if (!item.enabled) return "is-muted"; if (!item.enabled) return "is-muted";
if (!item.health) return "is-ok";
if (item.health === "ok") return "is-ok"; if (item.health === "ok") return "is-ok";
return "is-bad"; return "is-bad";
} }
+46
View File
@@ -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"]); 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 }) => { test("catalog maker remains usable on a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/"); await page.goto("/");