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;
+}