// viz/app/linker_page.js import { esc, renderThumbHtml } from "./dom.js"; import { tokenizeQuery, matchesAllTokens, displaySku, keySkuForRow, normSearchText } from "./sku.js"; import { loadIndex } from "./state.js"; import { aggregateBySku } from "./catalog.js"; import { loadSkuRules, clearSkuRulesCache } from "./mapping.js"; import { inferGithubOwnerRepo, isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore, } from "./api.js"; import { addPendingLink, addPendingIgnore, pendingCounts, movePendingToSubmitted, clearPendingEdits, } from "./pending.js"; // ✅ NEW imports (refactor) import { buildUrlBySkuStore } from "./linker/url_map.js"; import { buildCanonStoreCache, makeSameStoreCanonFn } from "./linker/store_cache.js"; import { buildSizePenaltyForPair } from "./linker/size.js"; import { pickPreferredCanonical } from "./linker/canonical_pref.js"; import { smwsKeyFromName } from "./linker/similarity.js"; import { buildPricePenaltyForPair } from "./linker/price.js"; import { topSuggestions, recommendSimilar, computeInitialPairsFast } from "./linker/suggestions.js"; /* ---------------- Page ---------------- */ export async function renderSkuLinker($app) { const localWrite = isLocalWriteMode(); let rules = await loadSkuRules(); $app.innerHTML = `
${!localWrite ? `` : ``} ${ !localWrite ? `` : `` } SKU Linker ${ localWrite ? `LOCAL WRITE` : `` }
Existing mapped SKUs are excluded from auto-suggestions. Same-store pairs are never suggested. LINK SKU writes map (can merge groups); IGNORE PAIR writes a "do-not-suggest" pair (local only).
Left
Right
`; document.getElementById("back").addEventListener("click", () => (location.hash = "#/")); const $qL = document.getElementById("qL"); const $qR = document.getElementById("qR"); const $listL = document.getElementById("listL"); const $listR = document.getElementById("listR"); const $linkBtn = document.getElementById("linkBtn"); const $ignoreBtn = document.getElementById("ignoreBtn"); const $status = document.getElementById("status"); const $pendingTop = !localWrite ? document.getElementById("pendingTop") : null; const $clearPendingBtn = !localWrite ? document.getElementById("clearPendingBtn") : null; $listL.innerHTML = `
Loading index…
`; $listR.innerHTML = `
Loading index…
`; const idx = await loadIndex(); const allRows = Array.isArray(idx.items) ? idx.items : []; // ✅ moved into helper const URL_BY_SKU_STORE = buildUrlBySkuStore(allRows); const allAgg = aggregateBySku(allRows, (x) => x); const meta = await loadSkuMetaBestEffort(); const mappedSkus = buildMappedSkuSet(meta.links || [], rules); let ignoreSet = rules.ignoreSet; // ✅ canonical-group store cache + helper let CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules); let sameStoreCanon = makeSameStoreCanonFn(rules, CANON_STORE_CACHE); // ✅ canonical-group size cache + helper let sizePenaltyForPair = buildSizePenaltyForPair({ allRows, allAgg, rules }); // ✅ canonical-group price cache + helper let pricePenaltyForPair = buildPricePenaltyForPair({ allAgg, rules }); function rebuildCachesAfterRulesReload() { CANON_STORE_CACHE = buildCanonStoreCache(allAgg, rules); sameStoreCanon = makeSameStoreCanonFn(rules, CANON_STORE_CACHE); sizePenaltyForPair = buildSizePenaltyForPair({ allRows, allAgg, rules }); pricePenaltyForPair = buildPricePenaltyForPair({ allAgg, rules }); } function isIgnoredPair(a, b) { return rules.isIgnoredPair(String(a || ""), String(b || "")); } function sameGroup(aSku, bSku) { if (!aSku || !bSku) return false; return String(rules.canonicalSku(aSku)) === String(rules.canonicalSku(bSku)); } let initialPairs = null; function getInitialPairsIfNeeded() { // never compute if either side is pinned if (pinnedL || pinnedR) return null; // never compute if URL query param was used (preselect flow) if (shouldReloadAfterLink) return null; if (initialPairs) return initialPairs; initialPairs = computeInitialPairsFast( allAgg, mappedSkus, 28, isIgnoredPair, sameStoreCanon, sameGroup, // ✅ NEW: hard-block already-linked pairs (incl SMWS stage) sizePenaltyForPair, pricePenaltyForPair, ); return initialPairs; } let pinnedL = null; let pinnedR = null; // if page was opened with #/link/?left=... (or sku=...), reload after LINK completes let shouldReloadAfterLink = false; function renderCard(it, pinned) { const storeCount = it.stores.size || 0; const plus = storeCount > 1 ? ` +${storeCount - 1}` : ""; const price = it.cheapestPriceStr ? it.cheapestPriceStr : "(no price)"; const store = it.cheapestStoreLabel || [...it.stores][0] || "Store"; const href = URL_BY_SKU_STORE.get(String(it.sku || ""))?.get(String(store || "")) || String(it.sampleUrl || "").trim() || ""; const storeBadge = href ? `${esc( store, )}${esc(plus)}` : `${esc(store)}${esc(plus)}`; const pinnedBadge = pinned ? `PINNED` : ``; return `
${esc(it.name || "(no name)")}
${esc(displaySku(it.sku))}
${pinnedBadge} ${esc(price)} ${storeBadge}
`; } function sideItems(side, query, otherPinned) { const tokens = tokenizeQuery(query); const otherSku = otherPinned ? String(otherPinned.sku || "") : ""; // manual search: allow mapped SKUs so you can merge groups, // BUT if the other side is pinned, hide anything already in that pinned's group if (tokens.length) { let out = allAgg .filter((it) => it && it.sku !== otherSku && matchesAllTokens(it.searchText, tokens)) .slice(0, 120); if (otherPinned) { const oSku = String(otherPinned.sku || ""); out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || ""))); out = out.filter((it) => !sameStoreCanon(oSku, String(it.sku || ""))); out = out.filter((it) => !sameGroup(oSku, String(it.sku || ""))); } return out.slice(0, 80); } // auto-suggestions: never include mapped skus if (otherPinned) return recommendSimilar( allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair, sizePenaltyForPair, pricePenaltyForPair, sameStoreCanon, sameGroup, ); const pairs = getInitialPairsIfNeeded(); if (pairs && pairs.length) { const list = side === "L" ? pairs.map((p) => p.a) : pairs.map((p) => p.b); return list.filter( (it) => it && it.sku !== otherSku && (!mappedSkus.has(String(it.sku)) || smwsKeyFromName(it.name || "")), ); } return topSuggestions(allAgg, 60, otherSku, mappedSkus); } function attachHandlers($root, side) { for (const el of Array.from($root.querySelectorAll(".thumbInternalLink"))) { el.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const sku = (el.getAttribute("data-sku") || "").trim(); if (!sku) return; const u = new URL(location.href); u.hash = `#/item/${encodeURIComponent(sku)}`; window.open(u.toString(), "_blank", "noopener,noreferrer"); }); } for (const el of Array.from($root.querySelectorAll(".item"))) { el.addEventListener("click", () => { const skuKey = el.getAttribute("data-sku") || ""; const it = allAgg.find((x) => String(x.sku || "") === skuKey); if (!it) return; const other = side === "L" ? pinnedR : pinnedL; if (other && String(other.sku || "") === String(it.sku || "")) { $status.textContent = "Not allowed: both sides cannot be the same SKU."; return; } // HARD BLOCK: store overlap (canonical-group) if (other && sameStoreCanon(String(other.sku || ""), String(it.sku || ""))) { $status.textContent = "Not allowed: both items belong to the same store."; return; } // HARD BLOCK: already linked group if (other && sameGroup(String(other.sku || ""), String(it.sku || ""))) { $status.textContent = "Already linked: both SKUs are in the same group."; return; } if (side === "L") pinnedL = pinnedL && pinnedL.sku === it.sku ? null : it; else pinnedR = pinnedR && pinnedR.sku === it.sku ? null : it; updateAll(); }); } } function renderSide(side) { const pinned = side === "L" ? pinnedL : pinnedR; const other = side === "L" ? pinnedR : pinnedL; const query = side === "L" ? $qL.value : $qR.value; const $list = side === "L" ? $listL : $listR; if (pinned) { $list.innerHTML = renderCard(pinned, true); attachHandlers($list, side); return; } const items = sideItems(side, query, other); $list.innerHTML = items.length ? items.map((it) => renderCard(it, false)).join("") : `
No matches.
`; attachHandlers($list, side); } function updateButtons() { const isPages = !localWrite; const $pr = isPages ? document.getElementById("createPrBtn") : null; if ($pr) { const c0 = pendingCounts(); $pr.disabled = c0.total === 0; } if ($pendingTop && $clearPendingBtn) { const c0 = pendingCounts(); $pendingTop.textContent = c0.total ? `Pending: ${c0.total}` : ""; $pendingTop.style.display = c0.total ? "inline-flex" : "none"; $clearPendingBtn.style.display = c0.total ? "inline-flex" : "none"; $clearPendingBtn.disabled = c0.total === 0; } if (!(pinnedL && pinnedR)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; if (isPages) { const c = pendingCounts(); $status.textContent = c.total ? `Pending changes: ${c.links} link(s), ${c.ignores} ignore(s). Create PR when ready.` : "Pin one item on each side to enable linking / ignoring."; } else { if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring."; } return; } const a = String(pinnedL.sku || ""); const b = String(pinnedR.sku || ""); if (a === b) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; $status.textContent = "Not allowed: both sides cannot be the same SKU."; return; } if (sameStoreCanon(a, b)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; $status.textContent = "Not allowed: both items belong to the same store."; return; } if (sameGroup(a, b)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; $status.textContent = "Already linked: both SKUs are in the same group."; return; } $linkBtn.disabled = false; $ignoreBtn.disabled = false; if (isIgnoredPair(a, b)) { $status.textContent = "This pair is already ignored."; } else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") { $status.textContent = ""; } if ($pr) { const c = pendingCounts(); $pr.disabled = c.total === 0; } } if ($clearPendingBtn) { $clearPendingBtn.addEventListener("click", async () => { const c0 = pendingCounts(); if (c0.total === 0) return; clearPendingEdits(); clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; rebuildCachesAfterRulesReload(); const rebuilt = buildMappedSkuSet(rules.links || [], rules); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); const $pr = document.getElementById("createPrBtn"); if ($pr) { const c = pendingCounts(); $pr.disabled = c.total === 0; } pinnedL = null; pinnedR = null; $status.textContent = "Cleared pending staged edits."; updateAll(); }); } const $createPrBtn = document.getElementById("createPrBtn"); if ($createPrBtn) { $createPrBtn.addEventListener("click", async () => { const c = pendingCounts(); if (c.total === 0) return; const { owner, repo } = inferGithubOwnerRepo(); const editsToSend = movePendingToSubmitted(); const payload = JSON.stringify( { schema: "stviz-sku-edits-v1", createdAt: editsToSend.createdAt || new Date().toISOString(), links: editsToSend.links, ignores: editsToSend.ignores, }, null, 2, ); const title = `[stviz] sku link updates (${editsToSend.links.length} link, ${editsToSend.ignores.length} ignore)`; const body = `Automated request from GitHub Pages SKU Linker.\n\n` + `\n` + payload + `\n\n`; const u = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + `/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; window.open(u, "_blank", "noopener,noreferrer"); clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; rebuildCachesAfterRulesReload(); const rebuilt = buildMappedSkuSet(rules.links || [], rules); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); const c2 = pendingCounts(); $createPrBtn.disabled = c2.total === 0; $status.textContent = "PR request opened. Staged edits moved to submitted (won’t re-suggest)."; pinnedL = null; pinnedR = null; updateAll(); }); } function findAggForPreselectSku(rawSku) { const want = String(rawSku || "").trim(); if (!want) return null; let it = allAgg.find((x) => String(x?.sku || "") === want); if (it) return it; const canonWant = String(rules.canonicalSku(want) || want).trim(); if (!canonWant) return null; it = allAgg.find((x) => String(x?.sku || "") === canonWant); if (it) return it; return allAgg.find((x) => String(rules.canonicalSku(String(x?.sku || "")) || "") === canonWant) || null; } function updateAll() { if (!updateAll._didPreselect) { updateAll._didPreselect = true; const h = String(location.hash || ""); const qi = h.indexOf("?"); if (qi !== -1) { const qs = new URLSearchParams(h.slice(qi + 1)); const leftSkuRaw = qs.get("left") || qs.get("sku"); const leftSku = String(leftSkuRaw || "").trim(); if (leftSku) shouldReloadAfterLink = true; if (leftSku && !pinnedL) { const it = findAggForPreselectSku(leftSku); if (it) pinnedL = it; } } } renderSide("L"); renderSide("R"); updateButtons(); } let tL = null, tR = null; $qL.addEventListener("input", () => { if (tL) clearTimeout(tL); tL = setTimeout(() => { $status.textContent = ""; updateAll(); }, 60); }); $qR.addEventListener("input", () => { if (tR) clearTimeout(tR); tR = setTimeout(() => { $status.textContent = ""; updateAll(); }, 60); }); $linkBtn.addEventListener("click", async () => { if (!(pinnedL && pinnedR)) return; const a = String(pinnedL.sku || ""); const b = String(pinnedR.sku || ""); if (!a || !b) { $status.textContent = "Not allowed: missing SKU."; return; } if (a === b) { $status.textContent = "Not allowed: both sides cannot be the same SKU."; return; } if (sameStoreCanon(a, b)) { $status.textContent = "Not allowed: both items belong to the same store."; return; } if (sameGroup(a, b)) { $status.textContent = "Already linked: both SKUs are in the same group."; return; } if (isIgnoredPair(a, b)) { $status.textContent = "This pair is already ignored."; return; } const aCanon = rules.canonicalSku(a); const bCanon = rules.canonicalSku(b); const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]); if (!preferred) { $status.textContent = "Write failed: could not choose a canonical SKU."; return; } const writes = []; function addWrite(fromSku, toSku) { const f = String(fromSku || "").trim(); const t = String(toSku || "").trim(); if (!f || !t || f === t) return; if (rules.canonicalSku(f) === t) return; writes.push({ fromSku: f, toSku: t }); } addWrite(aCanon, preferred); addWrite(bCanon, preferred); addWrite(a, preferred); addWrite(b, preferred); const seenW = new Set(); const uniq = []; for (const w of writes) { const k = `${w.fromSku}→${w.toSku}`; if (seenW.has(k)) continue; seenW.add(k); uniq.push(w); } if (!localWrite) { for (const w of uniq) addPendingLink(w.fromSku, w.toSku); clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; rebuildCachesAfterRulesReload(); const rebuilt = buildMappedSkuSet(rules.links || [], rules); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); const c = pendingCounts(); $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`; const $pr = document.getElementById("createPrBtn"); if ($pr) $pr.disabled = c.total === 0; pinnedL = null; pinnedR = null; updateAll(); location.reload(); return; } $status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)} …`; try { for (let i = 0; i < uniq.length; i++) { const w = uniq[i]; $status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku( w.fromSku, )} → ${displaySku(w.toSku)} …`; await apiWriteSkuLink(w.fromSku, w.toSku); } clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; rebuildCachesAfterRulesReload(); const meta2 = await loadSkuMetaBestEffort(); const rebuilt = buildMappedSkuSet(meta2?.links || [], rules); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); $status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`; pinnedL = null; pinnedR = null; updateAll(); location.reload(); } catch (e) { $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; } }); $ignoreBtn.addEventListener("click", async () => { if (!(pinnedL && pinnedR)) return; const a = String(pinnedL.sku || ""); const b = String(pinnedR.sku || ""); if (!a || !b) { $status.textContent = "Not allowed: missing SKU."; return; } if (a === b) { $status.textContent = "Not allowed: both sides cannot be the same SKU."; return; } if (sameStoreCanon(a, b)) { $status.textContent = "Not allowed: both items belong to the same store."; return; } if (sameGroup(a, b)) { $status.textContent = "Already linked: both SKUs are in the same group."; return; } if (isIgnoredPair(a, b)) { $status.textContent = "This pair is already ignored."; return; } if (!localWrite) { $status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)} …`; addPendingIgnore(a, b); clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; rebuildCachesAfterRulesReload(); const rebuilt = buildMappedSkuSet(rules.links || [], rules); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); const c = pendingCounts(); $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`; const $pr = document.getElementById("createPrBtn"); if ($pr) $pr.disabled = c.total === 0; pinnedL = null; pinnedR = null; updateAll(); location.reload(); return; } $status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)} …`; try { const out = await apiWriteSkuIgnore(a, b); ignoreSet.add(rules.canonicalPairKey(a, b)); $status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`; pinnedL = null; pinnedR = null; updateAll(); location.reload(); } catch (e) { $status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`; } }); updateAll(); /* ---------------- Mapping helpers (kept local) ---------------- */ function buildMappedSkuSet(links, rules0) { const s = new Set(); function add(k) { const x = String(k || "").trim(); if (!x) return; s.add(x); if (rules0 && typeof rules0.canonicalSku === "function") { const c = String(rules0.canonicalSku(x) || "").trim(); if (c) s.add(c); } } // ✅ NEW: always include rules.links (meta can be incomplete) const merged = [ ...(rules0 && Array.isArray(rules0.links) ? rules0.links : []), ...(Array.isArray(links) ? links : []), ]; for (const x of merged) { add(x?.fromSku); add(x?.toSku); } return s; } }