feat: changes to linker

This commit is contained in:
Brennan Wilkes (Text Groove) 2026-01-22 11:06:47 -08:00
parent 25433757be
commit 3bcccb6a46
3 changed files with 265 additions and 132 deletions

View file

@ -10,8 +10,19 @@ import {
import { loadIndex } from "./state.js"; import { loadIndex } from "./state.js";
import { aggregateBySku } from "./catalog.js"; import { aggregateBySku } from "./catalog.js";
import { loadSkuRules, clearSkuRulesCache } from "./mapping.js"; import { loadSkuRules, clearSkuRulesCache } from "./mapping.js";
import { inferGithubOwnerRepo, isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js"; import {
import { addPendingLink, addPendingIgnore, pendingCounts, loadPendingEdits } from "./pending.js"; inferGithubOwnerRepo,
isLocalWriteMode,
loadSkuMetaBestEffort,
apiWriteSkuLink,
apiWriteSkuIgnore,
} from "./api.js";
import {
addPendingLink,
addPendingIgnore,
pendingCounts,
movePendingToSubmitted,
} from "./pending.js";
/* ---------------- Similarity helpers ---------------- */ /* ---------------- Similarity helpers ---------------- */
@ -93,7 +104,10 @@ function fastSimilarityScore(aTokens, bTokens, aNormName, bNormName) {
const a = String(aNormName || ""); const a = String(aNormName || "");
const b = String(bNormName || ""); const b = String(bNormName || "");
const pref = 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.2
: 0; : 0;
@ -229,8 +243,16 @@ function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
return scored.slice(0, limit).map((x) => x.it); return scored.slice(0, limit).map((x) => x.it);
} }
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) { function recommendSimilar(
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus); allAgg,
pinned,
limit,
otherPinnedSku,
mappedSkus,
isIgnoredPairFn
) {
if (!pinned || !pinned.name)
return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
const base = String(pinned.name || ""); const base = String(pinned.name || "");
const pinnedSku = String(pinned.sku || ""); 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 (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
if (storesOverlap(pinned, it)) 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; continue;
const s = similarityScore(base, it.name || ""); const s = similarityScore(base, it.name || "");
@ -276,7 +301,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
const itemNormName = new Map(); const itemNormName = new Map();
for (const it of work) { 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); itemTokens.set(it.sku, toks);
itemNormName.set(it.sku, normSearchText(it.name || "")); itemNormName.set(it.sku, normSearchText(it.name || ""));
for (const t of toks) { for (const t of toks) {
@ -307,7 +334,8 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn
if (!bSku || bSku === aSku) continue; if (!bSku || bSku === aSku) continue;
if (mappedSkus && mappedSkus.has(bSku)) 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; if (storesOverlap(a, b)) continue;
cand.set(bSku, b); cand.set(bSku, b);
@ -378,7 +406,11 @@ export async function renderSkuLinker($app) {
<button id="back" class="btn"> Back</button> <button id="back" class="btn"> Back</button>
<div style="flex:1"></div> <div style="flex:1"></div>
<span class="badge">SKU Linker</span> <span class="badge">SKU Linker</span>
${localWrite ? `<span class="badge mono">LOCAL WRITE</span>` : `<button id="createPrBtn" class="btn" disabled>Create PR</button>`} ${
localWrite
? `<span class="badge mono">LOCAL WRITE</span>`
: `<button id="createPrBtn" class="btn" disabled>Create PR</button>`
}
</div> </div>
<div class="card" style="padding:14px;"> <div class="card" style="padding:14px;">
@ -512,14 +544,15 @@ export async function renderSkuLinker($app) {
const oSku = String(otherPinned.sku || ""); const oSku = String(otherPinned.sku || "");
out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || ""))); out = out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
out = out.filter((it) => !storesOverlap(otherPinned, it)); 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); return out.slice(0, 80);
} }
// auto-suggestions: never include mapped skus // 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) { if (initialPairs && initialPairs.length) {
const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b); const list = side === "L" ? initialPairs.map((p) => p.a) : initialPairs.map((p) => p.b);
@ -548,7 +581,6 @@ export async function renderSkuLinker($app) {
return; 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 || ""))) { if (other && sameGroup(String(other.sku || ""), String(it.sku || ""))) {
$status.textContent = "Already linked: both SKUs are in the same group."; $status.textContent = "Already linked: both SKUs are in the same group.";
return; return;
@ -583,18 +615,17 @@ export async function renderSkuLinker($app) {
function updateButtons() { function updateButtons() {
const isPages = !localWrite; const isPages = !localWrite;
// Pages: keep Create PR button state in sync with pending edits
const $pr = isPages ? document.getElementById("createPrBtn") : null; const $pr = isPages ? document.getElementById("createPrBtn") : null;
if ($pr) { if ($pr) {
const c0 = pendingCounts(); const c0 = pendingCounts();
$pr.disabled = c0.total === 0; $pr.disabled = c0.total === 0;
} }
if (!(pinnedL && pinnedR)) { if (!(pinnedL && pinnedR)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
if (isPages) { if (isPages) {
const c = pendingCounts(); const c = pendingCounts();
$status.textContent = c.total $status.textContent = c.total
@ -605,65 +636,69 @@ export async function renderSkuLinker($app) {
} }
return; return;
} }
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (a === b) { if (a === b) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both sides cannot be the same SKU."; $status.textContent = "Not allowed: both sides cannot be the same SKU.";
return; return;
} }
if (storesOverlap(pinnedL, pinnedR)) { if (storesOverlap(pinnedL, pinnedR)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Not allowed: both items belong to the same store."; $status.textContent = "Not allowed: both items belong to the same store.";
return; return;
} }
if (sameGroup(a, b)) { if (sameGroup(a, b)) {
$linkBtn.disabled = true; $linkBtn.disabled = true;
$ignoreBtn.disabled = true; $ignoreBtn.disabled = true;
$status.textContent = "Already linked: both SKUs are in the same group."; $status.textContent = "Already linked: both SKUs are in the same group.";
return; return;
} }
$linkBtn.disabled = false; $linkBtn.disabled = false;
$ignoreBtn.disabled = false; $ignoreBtn.disabled = false;
if (isIgnoredPair(a, b)) { if (isIgnoredPair(a, b)) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
} else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") { } else if ($status.textContent === "Pin one item on each side to enable linking / ignoring.") {
$status.textContent = ""; $status.textContent = "";
} }
// Refresh PR button state after any status changes
if ($pr) { if ($pr) {
const c = pendingCounts(); const c = pendingCounts();
$pr.disabled = c.total === 0; $pr.disabled = c.total === 0;
} }
} }
const $createPrBtn = document.getElementById("createPrBtn"); const $createPrBtn = document.getElementById("createPrBtn");
if ($createPrBtn) { if ($createPrBtn) {
$createPrBtn.addEventListener("click", () => { $createPrBtn.addEventListener("click", async () => {
const c = pendingCounts(); const c = pendingCounts();
if (c.total === 0) return; if (c.total === 0) return;
const { owner, repo } = inferGithubOwnerRepo(); 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( 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, null,
2 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 = const body =
`Automated request from GitHub Pages SKU Linker.\n\n` + `Automated request from GitHub Pages SKU Linker.\n\n` +
`<!-- stviz-sku-edits:BEGIN -->\n` + `<!-- stviz-sku-edits:BEGIN -->\n` +
@ -675,17 +710,34 @@ export async function renderSkuLinker($app) {
`/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; `/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
window.open(u, "_blank", "noopener,noreferrer"); 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 (wont re-suggest).";
pinnedL = null;
pinnedR = null;
updateAll();
}); });
} }
function updateAll() { function updateAll() {
renderSide("L"); renderSide("L");
renderSide("R"); renderSide("R");
updateButtons(); updateButtons();
} }
let tL = null, tR = null; let tL = null,
tR = null;
$qL.addEventListener("input", () => { $qL.addEventListener("input", () => {
if (tL) clearTimeout(tL); if (tL) clearTimeout(tL);
tL = setTimeout(() => { tL = setTimeout(() => {
@ -703,10 +755,10 @@ export async function renderSkuLinker($app) {
$linkBtn.addEventListener("click", async () => { $linkBtn.addEventListener("click", async () => {
if (!(pinnedL && pinnedR)) return; if (!(pinnedL && pinnedR)) return;
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (!a || !b) { if (!a || !b) {
$status.textContent = "Not allowed: missing SKU."; $status.textContent = "Not allowed: missing SKU.";
return; return;
@ -727,16 +779,16 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
return; return;
} }
const aCanon = rules.canonicalSku(a); const aCanon = rules.canonicalSku(a);
const bCanon = rules.canonicalSku(b); const bCanon = rules.canonicalSku(b);
const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]); const preferred = pickPreferredCanonical(allRows, [a, b, aCanon, bCanon]);
if (!preferred) { if (!preferred) {
$status.textContent = "Write failed: could not choose a canonical SKU."; $status.textContent = "Write failed: could not choose a canonical SKU.";
return; return;
} }
const writes = []; const writes = [];
function addWrite(fromSku, toSku) { function addWrite(fromSku, toSku) {
const f = String(fromSku || "").trim(); const f = String(fromSku || "").trim();
@ -745,12 +797,12 @@ export async function renderSkuLinker($app) {
if (rules.canonicalSku(f) === t) return; if (rules.canonicalSku(f) === t) return;
writes.push({ fromSku: f, toSku: t }); writes.push({ fromSku: f, toSku: t });
} }
addWrite(aCanon, preferred); addWrite(aCanon, preferred);
addWrite(bCanon, preferred); addWrite(bCanon, preferred);
addWrite(a, preferred); addWrite(a, preferred);
addWrite(b, preferred); addWrite(b, preferred);
const seenW = new Set(); const seenW = new Set();
const uniq = []; const uniq = [];
for (const w of writes) { for (const w of writes) {
@ -759,51 +811,48 @@ export async function renderSkuLinker($app) {
seenW.add(k); seenW.add(k);
uniq.push(w); uniq.push(w);
} }
// ---------------- GitHub Pages mode: stage edits locally ----------------
if (!localWrite) { if (!localWrite) {
for (const w of uniq) addPendingLink(w.fromSku, w.toSku); for (const w of uniq) addPendingLink(w.fromSku, w.toSku);
clearSkuRulesCache(); clearSkuRulesCache();
rules = await loadSkuRules(); rules = await loadSkuRules();
ignoreSet = rules.ignoreSet; ignoreSet = rules.ignoreSet;
// rebuild mappedSkus based on merged rules (includes pending)
const rebuilt = buildMappedSkuSet(rules.links || []); const rebuilt = buildMappedSkuSet(rules.links || []);
mappedSkus.clear(); mappedSkus.clear();
for (const x of rebuilt) mappedSkus.add(x); for (const x of rebuilt) mappedSkus.add(x);
const c = pendingCounts(); const c = pendingCounts();
$status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`; $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`;
const $pr = document.getElementById("createPrBtn"); const $pr = document.getElementById("createPrBtn");
if ($pr) $pr.disabled = c.total === 0; if ($pr) $pr.disabled = c.total === 0;
pinnedL = null; pinnedL = null;
pinnedR = null; pinnedR = null;
updateAll(); updateAll();
return; return;
} }
// ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)}`; $status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)}`;
try { try {
for (let i = 0; i < uniq.length; i++) { for (let i = 0; i < uniq.length; i++) {
const w = uniq[i]; const w = uniq[i];
$status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)}${displaySku(w.toSku)}`; $status.textContent = `Writing (${i + 1}/${uniq.length}): ${displaySku(w.fromSku)}${displaySku(w.toSku)}`;
await apiWriteSkuLink(w.fromSku, w.toSku); await apiWriteSkuLink(w.fromSku, w.toSku);
} }
clearSkuRulesCache(); clearSkuRulesCache();
rules = await loadSkuRules(); rules = await loadSkuRules();
ignoreSet = rules.ignoreSet; ignoreSet = rules.ignoreSet;
const meta2 = await loadSkuMetaBestEffort(); const meta2 = await loadSkuMetaBestEffort();
const rebuilt = buildMappedSkuSet(meta2?.links || []); const rebuilt = buildMappedSkuSet(meta2?.links || []);
mappedSkus.clear(); mappedSkus.clear();
for (const x of rebuilt) mappedSkus.add(x); for (const x of rebuilt) mappedSkus.add(x);
$status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`; $status.textContent = `Saved. Canonical is now ${displaySku(preferred)}.`;
pinnedL = null; pinnedL = null;
pinnedR = null; pinnedR = null;
@ -812,14 +861,13 @@ export async function renderSkuLinker($app) {
$status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`; $status.textContent = `Write failed: ${String(e && e.message ? e.message : e)}`;
} }
}); });
$ignoreBtn.addEventListener("click", async () => { $ignoreBtn.addEventListener("click", async () => {
if (!(pinnedL && pinnedR)) return; if (!(pinnedL && pinnedR)) return;
const a = String(pinnedL.sku || ""); const a = String(pinnedL.sku || "");
const b = String(pinnedR.sku || ""); const b = String(pinnedR.sku || "");
if (!a || !b) { if (!a || !b) {
$status.textContent = "Not allowed: missing SKU."; $status.textContent = "Not allowed: missing SKU.";
return; return;
@ -840,32 +888,34 @@ export async function renderSkuLinker($app) {
$status.textContent = "This pair is already ignored."; $status.textContent = "This pair is already ignored.";
return; return;
} }
// ---------------- GitHub Pages mode: stage ignore locally ----------------
if (!localWrite) { if (!localWrite) {
$status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)}`; $status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)}`;
addPendingIgnore(a, b); addPendingIgnore(a, b);
clearSkuRulesCache(); clearSkuRulesCache();
rules = await loadSkuRules(); rules = await loadSkuRules();
ignoreSet = rules.ignoreSet; ignoreSet = rules.ignoreSet;
const rebuilt = buildMappedSkuSet(rules.links || []);
mappedSkus.clear();
for (const x of rebuilt) mappedSkus.add(x);
const c = pendingCounts(); const c = pendingCounts();
$status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`; $status.textContent = `Staged locally. Pending: ${c.links} link(s), ${c.ignores} ignore(s).`;
const $pr = document.getElementById("createPrBtn"); const $pr = document.getElementById("createPrBtn");
if ($pr) $pr.disabled = c.total === 0; if ($pr) $pr.disabled = c.total === 0;
pinnedL = null; pinnedL = null;
pinnedR = null; pinnedR = null;
updateAll(); updateAll();
return; return;
} }
// ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)}`; $status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)}`;
try { try {
const out = await apiWriteSkuIgnore(a, b); const out = await apiWriteSkuIgnore(a, b);
ignoreSet.add(rules.canonicalPairKey(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)}`; $status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
} }
}); });
updateAll(); updateAll();
} }

View file

@ -171,9 +171,12 @@ export async function loadSkuRules() {
if (CACHED) return CACHED; if (CACHED) return CACHED;
let meta = await loadSkuMetaBestEffort(); let meta = await loadSkuMetaBestEffort();
// On GitHub Pages (read-only), overlay local pending+submitted edits from localStorage
if (!isLocalWriteMode()) { if (!isLocalWriteMode()) {
meta = applyPendingToMeta(meta); meta = applyPendingToMeta(meta);
} }
const links = Array.isArray(meta?.links) ? meta.links : []; const links = Array.isArray(meta?.links) ? meta.links : [];
const ignores = Array.isArray(meta?.ignores) ? meta.ignores : []; const ignores = Array.isArray(meta?.ignores) ? meta.ignores : [];

View file

@ -1,5 +1,6 @@
// viz/app/pending.js // viz/app/pending.js
const LS_KEY = "stviz:v1:pendingSkuEdits"; const LS_KEY = "stviz:v1:pendingSkuEdits";
const LS_SUBMITTED_KEY = "stviz:v1:submittedSkuEdits";
function safeParseJson(s) { function safeParseJson(s) {
try { 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 = (() => { const raw = (() => {
try { try {
return localStorage.getItem(LS_KEY) || ""; return localStorage.getItem(key) || "";
} catch { } catch {
return ""; return "";
} }
@ -23,82 +42,112 @@ export function loadPendingEdits() {
const ignores = Array.isArray(j?.ignores) ? j.ignores : []; const ignores = Array.isArray(j?.ignores) ? j.ignores : [];
return { 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 || ""), 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 = { const out = {
createdAt: edits?.createdAt || new Date().toISOString(), createdAt: edits?.createdAt || new Date().toISOString(),
links: Array.isArray(edits?.links) ? edits.links : [], links: Array.isArray(edits?.links) ? edits.links : [],
ignores: Array.isArray(edits?.ignores) ? edits.ignores : [], ignores: Array.isArray(edits?.ignores) ? edits.ignores : [],
}; };
try { try {
localStorage.setItem(LS_KEY, JSON.stringify(out)); localStorage.setItem(key, JSON.stringify(out));
} catch {} } catch {}
return out; return out;
} }
export function loadPendingEdits() {
return loadEditsFromKey(LS_KEY);
}
export function savePendingEdits(edits) {
return saveEditsToKey(LS_KEY, edits);
}
export function clearPendingEdits() { export function clearPendingEdits() {
try { try {
localStorage.removeItem(LS_KEY); localStorage.removeItem(LS_KEY);
} catch {} } catch {}
} }
function canonicalPairKey(a, b) { export function loadSubmittedEdits() {
const x = String(a || "").trim(); return loadEditsFromKey(LS_SUBMITTED_KEY);
const y = String(b || "").trim();
if (!x || !y) return "";
return x < y ? `${x}|${y}` : `${y}|${x}`;
} }
export function addPendingLink(fromSku, toSku) { export function saveSubmittedEdits(edits) {
const f = String(fromSku || "").trim(); return saveEditsToKey(LS_SUBMITTED_KEY, edits);
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 addPendingIgnore(skuA, skuB) { export function clearSubmittedEdits() {
const a = String(skuA || "").trim(); try {
const b = String(skuB || "").trim(); localStorage.removeItem(LS_SUBMITTED_KEY);
if (!a || !b || a === b) return false; } catch {}
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 pendingCounts() { export function pendingCounts() {
const e = loadPendingEdits(); 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) { export function applyPendingToMeta(meta) {
const base = { const base = {
generatedAt: String(meta?.generatedAt || ""), generatedAt: String(meta?.generatedAt || ""),
@ -106,33 +155,65 @@ export function applyPendingToMeta(meta) {
ignores: Array.isArray(meta?.ignores) ? meta.ignores.slice() : [], 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) // merge links (dedupe by from→to)
const seenL = new Set( 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) { for (const x of overlay.links) {
const k = `${x.fromSku}${x.toSku}`; const k = linkKey(x.fromSku, x.toSku);
if (!seenL.has(k)) { if (!k || seenL.has(k)) continue;
seenL.add(k); seenL.add(k);
base.links.push({ fromSku: x.fromSku, toSku: x.toSku }); base.links.push({ fromSku: x.fromSku, toSku: x.toSku });
}
} }
// merge ignores (dedupe by canonical pair key) // merge ignores (dedupe by canonical pair key)
const seenI = new Set( const seenI = new Set(
base.ignores.map((x) => base.ignores
canonicalPairKey(String(x?.skuA || x?.a || "").trim(), String(x?.skuB || x?.b || "").trim()) .map((x) => pairKey(String(x?.skuA || x?.a || "").trim(), String(x?.skuB || x?.b || "").trim()))
) .filter(Boolean)
); );
for (const x of p.ignores) { for (const x of overlay.ignores) {
const k = canonicalPairKey(x.skuA, x.skuB); const k = pairKey(x.skuA, x.skuB);
if (!seenI.has(k)) { if (!k || seenI.has(k)) continue;
seenI.add(k); seenI.add(k);
base.ignores.push({ skuA: x.skuA, skuB: x.skuB }); base.ignores.push({ skuA: x.skuA, skuB: x.skuB });
}
} }
return base; 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;
}