Files
product-import-ai/public/app.js
T

1208 lines
48 KiB
JavaScript

// @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 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 mappingList = document.querySelector("#mappingList");
const mappingDetails = document.querySelector("#mappingDetails");
const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton");
const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
const parserDrawer = document.querySelector("#parserDrawer");
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 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 localDbFields = document.querySelector("#localDbFields");
const localDbInputs = document.querySelectorAll("#localDbFields input");
const testLocalDbButton = document.querySelector("#testLocalDbButton");
const localDbTestResult = document.querySelector("#localDbTestResult");
const lightContrastSelect = document.querySelector("#lightContrastSelect");
const darkContrastSelect = document.querySelector("#darkContrastSelect");
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));
headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local");
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:v2";
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();
manufacturerSelect.addEventListener("change", () => {
syncManufacturerPickerLabel();
closeManufacturerPicker();
if (manufacturerSelect.value) {
loadMappingSources();
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);
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
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);
lightContrastSelect.addEventListener("change", saveAppearance);
darkContrastSelect.addEventListener("change", saveAppearance);
loadDatabaseMode();
syncSidebarForViewport();
window.matchMedia("(min-width: 981px)").addEventListener("change", syncSidebarForViewport);
setTimeout(() => {
if (manufacturerSelect.value) {
loadMappingSources();
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));
syncManufacturerPickerLabel();
renderManufacturerOptions();
}
catch (error) {
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
syncManufacturerPickerLabel();
renderManufacturerOptions();
}
}
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;
}
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."));
}
}
async function loadMappingSources() {
const manufacturerId = manufacturerSelect.value;
if (!manufacturerId) {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
return;
}
mappingState.textContent = "Loading mapping...";
mappingState.className = "mapping-state";
mappingList.replaceChildren();
try {
const params = new URLSearchParams({ manufacturerId, check: "1" });
const response = await fetch(`/api/catalog-maker/mapping-sources?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "Mapping load failed.");
}
renderMappingSources(data);
}
catch (error) {
renderMappingSources({
mapped: false,
items: [],
message: error.message || "Mapping load failed.",
});
}
}
function renderMappingSources(data) {
const items = data.items || [];
if (!data.mapped || !items.length) {
mappingState.textContent = data.message || "Mapping missing";
mappingState.className = "mapping-state is-missing";
mappingList.replaceChildren();
mappingDetails.open = true;
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 ready = enabledCount > 0 && failedCount === 0;
mappingState.textContent = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${testedCount}/${enabledCount})`;
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing";
mappingDetails.open = !ready || failedCount > 0;
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 getMappingHealthText(item) {
if (!item.enabled)
return "off";
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 === "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() {
if (!manufacturerSelect.value || !currentProduct) {
setTableMessage("Select manufacturer and product first.");
return;
}
const savedDraft = readLocalDraft();
if (savedDraft?.sources) {
renderSourceCandidates(savedDraft.sources.items || []);
updateFindSourcesButtonState();
sourceTitle.textContent = "Source candidates (cached)";
sourceModal.hidden = false;
return;
}
const supplierColorGroup = getFirstSupplierColorGroup();
findSourcesButton.disabled = true;
findSourcesButton.textContent = "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 {
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;
getPicturesButton.textContent = "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;
getPicturesButton.textContent = "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) {
if (!items.length) {
const empty = document.createElement("div");
empty.className = "source-row";
empty.textContent = "No source mapping found.";
sourceList.replaceChildren(empty);
return;
}
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(renderSourceCandidateRow)
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")];
section.append(header, ...rows);
return section;
}));
}
function renderPictureCandidates(items) {
const rows = [];
for (const item of items) {
for (const candidate of item.candidates || []) {
for (const picture of candidate.pictures || []) {
const row = document.createElement("a");
row.className = "picture-candidate";
row.href = picture;
row.target = "_blank";
row.rel = "noreferrer";
const image = document.createElement("img");
image.src = picture;
image.alt = candidate.rawTitle || item.source?.name || "Product picture";
image.loading = "lazy";
const label = document.createElement("span");
label.textContent = item.source?.name || item.source?.key || "Source";
row.append(image, label);
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;
}
sourceList.replaceChildren(...rows);
}
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);
findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources";
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
}
function renderSourceCandidateRow(candidate) {
const row = document.createElement("a");
row.className = "source-row";
row.href = candidate.url;
row.target = "_blank";
row.rel = "noreferrer";
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("span");
action.className = "source-row-action";
action.textContent = "Open";
row.append(title, action);
return row;
}
function renderManualSearchRow(url, label) {
return renderSourceCandidateRow({
title: label,
url,
});
}
function closeSourceCandidates() {
sourceModal.hidden = true;
sourceList.replaceChildren();
}
function openSettings() {
settingsModal.hidden = false;
}
function toggleParserDrawer() {
const isOpen = !parserDrawer.hidden;
parserDrawer.hidden = isOpen;
parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen));
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
}
function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches)
openSidebar();
else
closeSidebar();
}
function openSidebar() {
sidebarDrawer.classList.add("is-open");
sidebarOverlay.hidden = false;
sidebarOpenButton.hidden = true;
sidebarOpenButton.setAttribute("aria-expanded", "true");
sidebarDrawer.setAttribute("aria-hidden", "false");
}
function closeSidebar() {
sidebarDrawer.classList.remove("is-open");
sidebarOverlay.hidden = true;
sidebarOpenButton.hidden = false;
sidebarOpenButton.setAttribute("aria-expanded", "false");
sidebarDrawer.setAttribute("aria-hidden", "true");
}
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;
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
for (const input of permissionInputs)
input.checked = permissions[input.id] === true;
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);
renderDatabaseModeNote(mode);
refreshDatabaseStatusLabel();
}
function saveDatabaseMode(event) {
const mode = event.target.value === "live" ? "live" : "local";
localStorage.setItem("catalog-maker:database-mode", 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));
}
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;
}
}
function syncLocalDbFields(mode) {
localDbFields.hidden = mode !== "local";
}
function syncDatabasePermissions(mode) {
const isLive = mode === "live";
for (const input of permissionInputs) {
input.disabled = isLive;
if (isLive)
input.checked = false;
}
}
function saveSettings() {
const permissions = {};
for (const input of permissionInputs)
permissions[input.id] = input.checked;
localStorage.setItem("catalog-maker:db-permissions", 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 = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
const writesEnabled = mode === "local" && (permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle);
const dbLabel = mode === "live" ? "Live DB" : "Local 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);
}
function renderDatabaseModeNote(mode) {
databaseModeNote.textContent = mode === "live"
? "Live DB selected for preparation. The current project still stays read-only."
: "Local test database 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;
loadProductButton.textContent = "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 {
loadProductButton.textContent = "Get product info - 00";
updateNavigationState();
}
}
export {};