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 { 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) {
<button id="back" class="btn"> Back</button>
<div style="flex:1"></div>
<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 class="card" style="padding:14px;">
@ -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;
@ -584,7 +616,6 @@ 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();
@ -639,31 +670,35 @@ export async function renderSkuLinker($app) {
$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` +
`<!-- stviz-sku-edits:BEGIN -->\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 (wont 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(() => {
@ -760,7 +812,6 @@ export async function renderSkuLinker($app) {
uniq.push(w);
}
// ---------------- GitHub Pages mode: stage edits locally ----------------
if (!localWrite) {
for (const w of uniq) addPendingLink(w.fromSku, w.toSku);
@ -768,7 +819,6 @@ export async function renderSkuLinker($app) {
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);
@ -785,7 +835,6 @@ export async function renderSkuLinker($app) {
return;
}
// ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Writing ${uniq.length} link(s) to canonical ${displaySku(preferred)}`;
try {
@ -813,7 +862,6 @@ export async function renderSkuLinker($app) {
}
});
$ignoreBtn.addEventListener("click", async () => {
if (!(pinnedL && pinnedR)) return;
@ -841,7 +889,6 @@ export async function renderSkuLinker($app) {
return;
}
// ---------------- GitHub Pages mode: stage ignore locally ----------------
if (!localWrite) {
$status.textContent = `Staging ignore: ${displaySku(a)} × ${displaySku(b)}`;
@ -851,6 +898,10 @@ export async function renderSkuLinker($app) {
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).`;
@ -863,7 +914,6 @@ export async function renderSkuLinker($app) {
return;
}
// ---------------- Local mode: write via disk-backed API ----------------
$status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)}`;
try {
@ -878,6 +928,5 @@ export async function renderSkuLinker($app) {
}
});
updateAll();
}

View file

@ -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 : [];

View file

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