From 3bcccb6a463fbdd3e8eb6d0f7a53e86373d79e67 Mon Sep 17 00:00:00 2001 From: "Brennan Wilkes (Text Groove)" Date: Thu, 22 Jan 2026 11:06:47 -0800 Subject: [PATCH] feat: changes to linker --- viz/app/linker_page.js | 183 ++++++++++++++++++++++------------- viz/app/mapping.js | 3 + viz/app/pending.js | 211 ++++++++++++++++++++++++++++------------- 3 files changed, 265 insertions(+), 132 deletions(-) diff --git a/viz/app/linker_page.js b/viz/app/linker_page.js index 8d228c5..2e2aa95 100644 --- a/viz/app/linker_page.js +++ b/viz/app/linker_page.js @@ -10,8 +10,19 @@ import { 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, loadPendingEdits } from "./pending.js"; +import { + inferGithubOwnerRepo, + isLocalWriteMode, + loadSkuMetaBestEffort, + apiWriteSkuLink, + apiWriteSkuIgnore, +} from "./api.js"; +import { + addPendingLink, + addPendingIgnore, + pendingCounts, + movePendingToSubmitted, +} from "./pending.js"; /* ---------------- Similarity helpers ---------------- */ @@ -93,7 +104,10 @@ function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) { const a = String(aNormName || ""); const b = String(bNormName || ""); const pref = - firstMatch && a.slice(0, 10) && b.slice(0, 10) && a.slice(0, 10) === b.slice(0, 10) + firstMatch && + a.slice(0, 10) && + b.slice(0, 10) && + a.slice(0, 10) === b.slice(0, 10) ? 0.2 : 0; @@ -229,8 +243,16 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) { return scored.slice(0, limit).map((x) => x.it); } -function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) { - if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); +function recommendSimilar( + allAgg, + pinned, + limit, + otherPinnedSku, + mappedSkus, + isIgnoredPairFn +) { + if (!pinned || !pinned.name) + return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); const base = String(pinned.name || ""); const pinnedSku = String(pinned.sku || ""); @@ -243,7 +265,10 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isI if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue; if (storesOverlap(pinned, it)) continue; - if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) + if ( + typeof isIgnoredPairFn === "function" && + isIgnoredPairFn(pinnedSku, String(it.sku || "")) + ) continue; const s = similarityScore(base, it.name || ""); @@ -276,7 +301,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn const itemNormName = new Map(); for (const it of work) { - const toks = Array.from(new Set(tokenizeQuery(it.name || ""))).filter(Boolean).slice(0, 10); + const toks = Array.from(new Set(tokenizeQuery(it.name || ""))) + .filter(Boolean) + .slice(0, 10); itemTokens.set(it.sku, toks); itemNormName.set(it.sku, normSearchText(it.name || "")); for (const t of toks) { @@ -307,7 +334,8 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn if (!bSku || bSku === aSku) continue; if (mappedSkus && mappedSkus.has(bSku)) continue; - if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue; + if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) + continue; if (storesOverlap(a, b)) continue; cand.set(bSku, b); @@ -378,7 +406,11 @@ export async function renderSkuLinker($app) {
SKU Linker - ${localWrite ? `LOCAL WRITE` : ``} + ${ + localWrite + ? `LOCAL WRITE` + : `` + }
@@ -512,14 +544,15 @@ export async function renderSkuLinker($app) { const oSku = String(otherPinned.sku || ""); out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || ""))); out = out.filter((it) => !storesOverlap(otherPinned, it)); - out = out.filter((it) => !sameGroup(oSku, String(it.sku || ""))); // <-- KEY FIX + 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); + if (otherPinned) + return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus, isIgnoredPair); if (initialPairs && initialPairs.length) { const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b); @@ -548,7 +581,6 @@ export async function renderSkuLinker($app) { return; } - // NEW: prevent pinning something in the same already-linked group as the other pinned item if (other && sameGroup(String(other.sku || ""), String(it.sku || ""))) { $status.textContent = "Already linked: both SKUs are in the same group."; return; @@ -583,18 +615,17 @@ export async function renderSkuLinker($app) { function updateButtons() { const isPages = !localWrite; - - // Pages: keep Create PR button state in sync with pending edits + const $pr = isPages ? document.getElementById("createPrBtn") : null; if ($pr) { const c0 = pendingCounts(); $pr.disabled = c0.total === 0; } - + if (!(pinnedL && pinnedR)) { $linkBtn.disabled = true; $ignoreBtn.disabled = true; - + if (isPages) { const c = pendingCounts(); $status.textContent = c.total @@ -605,65 +636,69 @@ export async function renderSkuLinker($app) { } 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 (storesOverlap(pinnedL, pinnedR)) { $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 = ""; } - - // Refresh PR button state after any status changes + if ($pr) { const c = pendingCounts(); $pr.disabled = c.total === 0; } } - const $createPrBtn = document.getElementById("createPrBtn"); if ($createPrBtn) { - $createPrBtn.addEventListener("click", () => { + $createPrBtn.addEventListener("click", async () => { const c = pendingCounts(); if (c.total === 0) return; const { owner, repo } = inferGithubOwnerRepo(); - const edits = loadPendingEdits(); - // Payload is small (ops list), action will merge into data/sku_links.json + // Move PENDING -> SUBMITTED (so it won't be sent again, but still affects suggestions/grouping) + const editsToSend = movePendingToSubmitted(); + const payload = JSON.stringify( - { schema: "stviz-sku-edits-v1", createdAt: edits.createdAt || new Date().toISOString(), links: edits.links, ignores: edits.ignores }, + { + 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 (${c.links} link, ${c.ignores} ignore)`; + 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` + @@ -675,17 +710,34 @@ export async function renderSkuLinker($app) { `/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; window.open(u, "_blank", "noopener,noreferrer"); + + // Refresh local rules so UI immediately reflects submitted shadow + clearSkuRulesCache(); + rules = await loadSkuRules(); + ignoreSet = rules.ignoreSet; + + const rebuilt = buildMappedSkuSet(rules.links || []); + 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 updateAll() { renderSide("L"); renderSide("R"); updateButtons(); } - let tL = null, tR = null; + let tL = null, + tR = null; $qL.addEventListener("input", () => { if (tL) clearTimeout(tL); tL = setTimeout(() => { @@ -703,10 +755,10 @@ export async function renderSkuLinker($app) { $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; @@ -727,16 +779,16 @@ export async function renderSkuLinker($app) { $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(); @@ -745,12 +797,12 @@ export async function renderSkuLinker($app) { 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) { @@ -759,51 +811,48 @@ export async function renderSkuLinker($app) { seenW.add(k); uniq.push(w); } - - // ---------------- GitHub Pages mode: stage edits locally ---------------- + if (!localWrite) { for (const w of uniq) addPendingLink(w.fromSku, w.toSku); - + clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; - - // rebuild mappedSkus based on merged rules (includes pending) + const rebuilt = buildMappedSkuSet(rules.links || []); 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(); return; } - - // ---------------- Local mode: write via disk-backed API ---------------- + $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; - + const meta2 = await loadSkuMetaBestEffort(); const rebuilt = buildMappedSkuSet(meta2?.links || []); mappedSkus.clear(); for (const x of rebuilt) mappedSkus.add(x); - + $status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`; pinnedL = null; pinnedR = null; @@ -812,14 +861,13 @@ export async function renderSkuLinker($app) { $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; @@ -840,32 +888,34 @@ export async function renderSkuLinker($app) { $status.textContent = "This pair is already ignored."; return; } - - // ---------------- GitHub Pages mode: stage ignore locally ---------------- + if (!localWrite) { $status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)} …`; - + addPendingIgnore(a, b); - + clearSkuRulesCache(); rules = await loadSkuRules(); ignoreSet = rules.ignoreSet; - + + const rebuilt = buildMappedSkuSet(rules.links || []); + 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(); return; } - - // ---------------- Local mode: write via disk-backed API ---------------- + $status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)} …`; - + try { const out = await apiWriteSkuIgnore(a, b); ignoreSet.add(rules.canonicalPairKey(a, b)); @@ -877,7 +927,6 @@ export async function renderSkuLinker($app) { $status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`; } }); - updateAll(); } diff --git a/viz/app/mapping.js b/viz/app/mapping.js index f2b3a2e..0781078 100644 --- a/viz/app/mapping.js +++ b/viz/app/mapping.js @@ -171,9 +171,12 @@ export async function loadSkuRules() { if (CACHED) return CACHED; let meta = await loadSkuMetaBestEffort(); + + // On GitHub Pages (read-only), overlay local pending+submitted edits from localStorage if (!isLocalWriteMode()) { meta = applyPendingToMeta(meta); } + const links = Array.isArray(meta?.links) ? meta.links : []; const ignores = Array.isArray(meta?.ignores) ? meta.ignores : []; diff --git a/viz/app/pending.js b/viz/app/pending.js index 73c2540..91c1916 100644 --- a/viz/app/pending.js +++ b/viz/app/pending.js @@ -1,5 +1,6 @@ // viz/app/pending.js const LS_KEY = "stviz:v1:pendingSkuEdits"; +const LS_SUBMITTED_KEY = "stviz:v1:submittedSkuEdits"; function safeParseJson(s) { try { @@ -9,10 +10,28 @@ function safeParseJson(s) { } } -export function loadPendingEdits() { +function normSku(s) { + return String(s || "").trim(); +} + +function linkKey(fromSku, toSku) { + const f = normSku(fromSku); + const t = normSku(toSku); + if (!f || !t || f === t) return ""; + return `${f}→${t}`; +} + +function pairKey(a, b) { + const x = normSku(a); + const y = normSku(b); + if (!x || !y || x === y) return ""; + return x < y ? `${x}|${y}` : `${y}|${x}`; +} + +function loadEditsFromKey(key) { const raw = (() => { try { - return localStorage.getItem(LS_KEY) || ""; + return localStorage.getItem(key) || ""; } catch { return ""; } @@ -23,82 +42,112 @@ export function loadPendingEdits() { const ignores = Array.isArray(j?.ignores) ? j.ignores : []; return { - links: links - .map((x) => ({ - fromSku: String(x?.fromSku || "").trim(), - toSku: String(x?.toSku || "").trim(), - })) - .filter((x) => x.fromSku && x.toSku && x.fromSku !== x.toSku), - ignores: ignores - .map((x) => ({ - skuA: String(x?.skuA || x?.a || "").trim(), - skuB: String(x?.skuB || x?.b || "").trim(), - })) - .filter((x) => x.skuA && x.skuB && x.skuA !== x.skuB), createdAt: String(j?.createdAt || ""), + links: links + .map((x) => ({ fromSku: normSku(x?.fromSku), toSku: normSku(x?.toSku) })) + .filter((x) => linkKey(x.fromSku, x.toSku)), + ignores: ignores + .map((x) => ({ skuA: normSku(x?.skuA || x?.a), skuB: normSku(x?.skuB || x?.b) })) + .filter((x) => pairKey(x.skuA, x.skuB)), }; } -export function savePendingEdits(edits) { +function saveEditsToKey(key, edits) { const out = { createdAt: edits?.createdAt || new Date().toISOString(), links: Array.isArray(edits?.links) ? edits.links : [], ignores: Array.isArray(edits?.ignores) ? edits.ignores : [], }; try { - localStorage.setItem(LS_KEY, JSON.stringify(out)); + localStorage.setItem(key, JSON.stringify(out)); } catch {} return out; } +export function loadPendingEdits() { + return loadEditsFromKey(LS_KEY); +} + +export function savePendingEdits(edits) { + return saveEditsToKey(LS_KEY, edits); +} + export function clearPendingEdits() { try { localStorage.removeItem(LS_KEY); } catch {} } -function canonicalPairKey(a, b) { - const x = String(a || "").trim(); - const y = String(b || "").trim(); - if (!x || !y) return ""; - return x < y ? `${x}|${y}` : `${y}|${x}`; +export function loadSubmittedEdits() { + return loadEditsFromKey(LS_SUBMITTED_KEY); } -export function addPendingLink(fromSku, toSku) { - const f = String(fromSku || "").trim(); - const t = String(toSku || "").trim(); - if (!f || !t || f === t) return false; - - const edits = loadPendingEdits(); - const k = `${f}→${t}`; - const seen = new Set(edits.links.map((x) => `${x.fromSku}→${x.toSku}`)); - if (seen.has(k)) return false; - - edits.links.push({ fromSku: f, toSku: t }); - savePendingEdits(edits); - return true; +export function saveSubmittedEdits(edits) { + return saveEditsToKey(LS_SUBMITTED_KEY, edits); } -export function addPendingIgnore(skuA, skuB) { - const a = String(skuA || "").trim(); - const b = String(skuB || "").trim(); - if (!a || !b || a === b) return false; - - const edits = loadPendingEdits(); - const k = canonicalPairKey(a, b); - const seen = new Set(edits.ignores.map((x) => canonicalPairKey(x.skuA, x.skuB))); - if (seen.has(k)) return false; - - edits.ignores.push({ skuA: a, skuB: b }); - savePendingEdits(edits); - return true; +export function clearSubmittedEdits() { + try { + localStorage.removeItem(LS_SUBMITTED_KEY); + } catch {} } export function pendingCounts() { const e = loadPendingEdits(); - return { links: e.links.length, ignores: e.ignores.length, total: e.links.length + e.ignores.length }; + return { + links: e.links.length, + ignores: e.ignores.length, + total: e.links.length + e.ignores.length, + }; } +export function addPendingLink(fromSku, toSku) { + const f = normSku(fromSku); + const t = normSku(toSku); + const k = linkKey(f, t); + if (!k) return false; + + const pending = loadPendingEdits(); + const submitted = loadSubmittedEdits(); + + const seen = new Set( + [ + ...pending.links.map((x) => linkKey(x.fromSku, x.toSku)), + ...submitted.links.map((x) => linkKey(x.fromSku, x.toSku)), + ].filter(Boolean) + ); + + if (seen.has(k)) return false; + + pending.links.push({ fromSku: f, toSku: t }); + savePendingEdits(pending); + return true; +} + +export function addPendingIgnore(skuA, skuB) { + const a = normSku(skuA); + const b = normSku(skuB); + const k = pairKey(a, b); + if (!k) return false; + + const pending = loadPendingEdits(); + const submitted = loadSubmittedEdits(); + + const seen = new Set( + [ + ...pending.ignores.map((x) => pairKey(x.skuA, x.skuB)), + ...submitted.ignores.map((x) => pairKey(x.skuA, x.skuB)), + ].filter(Boolean) + ); + + if (seen.has(k)) return false; + + pending.ignores.push({ skuA: a, skuB: b }); + savePendingEdits(pending); + return true; +} + +// Merge PENDING + SUBMITTED into a meta object {links, ignores} export function applyPendingToMeta(meta) { const base = { generatedAt: String(meta?.generatedAt || ""), @@ -106,33 +155,65 @@ export function applyPendingToMeta(meta) { ignores: Array.isArray(meta?.ignores) ? meta.ignores.slice() : [], }; - const p = loadPendingEdits(); + const p0 = loadPendingEdits(); + const p1 = loadSubmittedEdits(); + const overlay = { + links: [...(p0.links || []), ...(p1.links || [])], + ignores: [...(p0.ignores || []), ...(p1.ignores || [])], + }; // merge links (dedupe by from→to) const seenL = new Set( - base.links.map((x) => `${String(x?.fromSku || "").trim()}→${String(x?.toSku || "").trim()}`) + base.links.map((x) => linkKey(String(x?.fromSku || "").trim(), String(x?.toSku || "").trim())).filter(Boolean) ); - for (const x of p.links) { - const k = `${x.fromSku}→${x.toSku}`; - if (!seenL.has(k)) { - seenL.add(k); - base.links.push({ fromSku: x.fromSku, toSku: x.toSku }); - } + for (const x of overlay.links) { + const k = linkKey(x.fromSku, x.toSku); + if (!k || seenL.has(k)) continue; + seenL.add(k); + base.links.push({ fromSku: x.fromSku, toSku: x.toSku }); } // merge ignores (dedupe by canonical pair key) const seenI = new Set( - base.ignores.map((x) => - canonicalPairKey(String(x?.skuA || x?.a || "").trim(), String(x?.skuB || x?.b || "").trim()) - ) + base.ignores + .map((x) => pairKey(String(x?.skuA || x?.a || "").trim(), String(x?.skuB || x?.b || "").trim())) + .filter(Boolean) ); - for (const x of p.ignores) { - const k = canonicalPairKey(x.skuA, x.skuB); - if (!seenI.has(k)) { - seenI.add(k); - base.ignores.push({ skuA: x.skuA, skuB: x.skuB }); - } + for (const x of overlay.ignores) { + const k = pairKey(x.skuA, x.skuB); + if (!k || seenI.has(k)) continue; + seenI.add(k); + base.ignores.push({ skuA: x.skuA, skuB: x.skuB }); } return base; } + +// Move everything from pending -> submitted, then clear pending. +// Returns the moved payload (what should be sent in PR/issue). +export function movePendingToSubmitted() { + const pending = loadPendingEdits(); + if (!pending.links.length && !pending.ignores.length) return pending; + + const sub = loadSubmittedEdits(); + + const seenL = new Set(sub.links.map((x) => linkKey(x.fromSku, x.toSku)).filter(Boolean)); + for (const x of pending.links) { + const k = linkKey(x.fromSku, x.toSku); + if (!k || seenL.has(k)) continue; + seenL.add(k); + sub.links.push({ fromSku: x.fromSku, toSku: x.toSku }); + } + + const seenI = new Set(sub.ignores.map((x) => pairKey(x.skuA, x.skuB)).filter(Boolean)); + for (const x of pending.ignores) { + const k = pairKey(x.skuA, x.skuB); + if (!k || seenI.has(k)) continue; + seenI.add(k); + sub.ignores.push({ skuA: x.skuA, skuB: x.skuB }); + } + + saveSubmittedEdits(sub); + clearPendingEdits(); + return pending; +}