mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
feat: changes to linker
This commit is contained in:
parent
25433757be
commit
3bcccb6a46
3 changed files with 265 additions and 132 deletions
|
|
@ -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 (won’t 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 : [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue