Add batch picture candidate workflow

This commit is contained in:
2026-08-06 00:16:25 +02:00
parent a8e3e62dbd
commit 0987edd112
5 changed files with 289 additions and 14 deletions
+66 -4
View File
@@ -34,6 +34,7 @@ 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");
@@ -200,6 +201,7 @@ 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);
@@ -404,6 +406,23 @@ function setButtonLabel(button, label) {
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 {
@@ -821,6 +840,7 @@ function getFirstSupplierColorGroup() {
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";
@@ -828,6 +848,7 @@ function renderSourceCandidates(items) {
sourceList.replaceChildren(empty);
return;
}
setSourceBatchAction("get-pictures");
sourceList.replaceChildren(...items.map((item) => {
const section = document.createElement("section");
section.className = "source-section";
@@ -855,12 +876,14 @@ function renderSourceCandidates(items) {
}));
}
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";
@@ -878,7 +901,7 @@ function renderPictureCandidates(items) {
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 #${position}`;
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...";
@@ -901,16 +924,17 @@ function renderPictureCandidates(items) {
action.type = "button";
action.className = "source-picture-action picture-candidate-save";
action.textContent = "Save to DB";
action.addEventListener("click", () => savePictureCandidateToDb({
action.savePictureCandidate = () => savePictureCandidateToDb({
picture,
item,
candidate,
image,
position,
position: sourcePosition,
target,
action,
duplicateHint,
}));
});
action.addEventListener("click", () => action.savePictureCandidate());
row.append(preview, details, action);
rows.push(row);
}
@@ -923,6 +947,7 @@ function renderPictureCandidates(items) {
sourceList.replaceChildren(empty);
return;
}
setSourceBatchAction("save-pictures");
sourceList.replaceChildren(...rows);
}
function buildPictureTargetInfo() {
@@ -990,16 +1015,53 @@ async function savePictureCandidateToDb({ picture, item, candidate, image, posit
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);
}
+74 -5
View File
@@ -34,6 +34,7 @@ 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");
@@ -205,6 +206,7 @@ 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);
@@ -425,6 +427,25 @@ function setButtonLabel(button, 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 {
@@ -880,6 +901,7 @@ function getFirstSupplierColorGroup() {
}
function renderSourceCandidates(items) {
sourceBatchActionButton.hidden = true;
if (!items.length) {
const empty = document.createElement("div");
empty.className = "source-row";
@@ -888,6 +910,7 @@ function renderSourceCandidates(items) {
return;
}
setSourceBatchAction("get-pictures");
sourceList.replaceChildren(
...items.map((item) => {
const section = document.createElement("section");
@@ -927,12 +950,14 @@ function renderSourceCandidates(items) {
}
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";
@@ -954,7 +979,7 @@ function renderPictureCandidates(items) {
const label = document.createElement("span");
label.className = "picture-candidate-title";
label.textContent = `${item.source?.name || item.source?.key || "Source"} picture #${position}`;
label.textContent = `${item.source?.name || item.source?.key || "Source"} picture #${sourcePosition}`;
const dimensions = document.createElement("span");
dimensions.className = "picture-candidate-dimensions";
@@ -982,18 +1007,18 @@ function renderPictureCandidates(items) {
action.type = "button";
action.className = "source-picture-action picture-candidate-save";
action.textContent = "Save to DB";
action.addEventListener("click", () =>
action.savePictureCandidate = () =>
savePictureCandidateToDb({
picture,
item,
candidate,
image,
position,
position: sourcePosition,
target,
action,
duplicateHint,
}),
);
});
action.addEventListener("click", () => action.savePictureCandidate());
row.append(preview, details, action);
rows.push(row);
@@ -1008,6 +1033,7 @@ function renderPictureCandidates(items) {
sourceList.replaceChildren(empty);
return;
}
setSourceBatchAction("save-pictures");
sourceList.replaceChildren(...rows);
}
@@ -1089,16 +1115,59 @@ async function savePictureCandidateToDb({
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 || []),
+1
View File
@@ -147,6 +147,7 @@
<header class="suggested-header">
<h2 id="sourceTitle">Source candidates</h2>
<div class="source-dialog-actions">
<button class="modal-close" id="sourceBatchActionButton" type="button" hidden><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#image"></use></svg><span>Get all pictures</span></button>
<button class="modal-close" id="sourceReloadButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#refresh"></use></svg><span>Reload</span></button>
<button class="modal-close" id="sourceCloseButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg><span>Close</span></button>
</div>