mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
229 lines
5.8 KiB
JavaScript
229 lines
5.8 KiB
JavaScript
// viz/app/mapping.js
|
|
import { loadSkuMetaBestEffort, isLocalWriteMode } from "./api.js";
|
|
import { applyPendingToMeta } from "./pending.js";
|
|
|
|
let CACHED = null;
|
|
|
|
export function clearSkuRulesCache() {
|
|
CACHED = null;
|
|
}
|
|
|
|
function normalizeImplicitSkuKey(k) {
|
|
const s = String(k || "").trim();
|
|
const m = s.match(/^id:(\d{1,6})$/i);
|
|
if (m) return String(m[1]).padStart(6, "0");
|
|
return s;
|
|
}
|
|
|
|
function canonicalPairKey(a, b) {
|
|
const x = normalizeImplicitSkuKey(a);
|
|
const y = normalizeImplicitSkuKey(b);
|
|
if (!x || !y) return "";
|
|
return x < y ? `${x}|${y}` : `${y}|${x}`;
|
|
}
|
|
|
|
function buildForwardMap(links) {
|
|
// Keep this for reference/debug; grouping no longer depends on direction.
|
|
const m = new Map();
|
|
for (const x of Array.isArray(links) ? links : []) {
|
|
const fromSku = normalizeImplicitSkuKey(x?.fromSku);
|
|
const toSku = normalizeImplicitSkuKey(x?.toSku);
|
|
if (fromSku && toSku && fromSku !== toSku) m.set(fromSku, toSku);
|
|
}
|
|
return m;
|
|
}
|
|
|
|
function buildIgnoreSet(ignores) {
|
|
const s = new Set();
|
|
for (const x of Array.isArray(ignores) ? ignores : []) {
|
|
const a = String(x?.skuA || x?.a || x?.left || "").trim();
|
|
const b = String(x?.skuB || x?.b || x?.right || "").trim();
|
|
const k = canonicalPairKey(a, b);
|
|
if (k) s.add(k);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/* ---------------- Union-Find grouping (hardened) ---------------- */
|
|
|
|
class DSU {
|
|
constructor() {
|
|
this.parent = new Map();
|
|
this.rank = new Map();
|
|
}
|
|
_add(x) {
|
|
if (!this.parent.has(x)) {
|
|
this.parent.set(x, x);
|
|
this.rank.set(x, 0);
|
|
}
|
|
}
|
|
find(x) {
|
|
x = String(x || "").trim();
|
|
if (!x) return "";
|
|
this._add(x);
|
|
let p = this.parent.get(x);
|
|
if (p !== x) {
|
|
p = this.find(p);
|
|
this.parent.set(x, p);
|
|
}
|
|
return p;
|
|
}
|
|
union(a, b) {
|
|
a = String(a || "").trim();
|
|
b = String(b || "").trim();
|
|
if (!a || !b || a === b) return;
|
|
const ra = this.find(a);
|
|
const rb = this.find(b);
|
|
if (!ra || !rb || ra === rb) return;
|
|
|
|
const rka = this.rank.get(ra) || 0;
|
|
const rkb = this.rank.get(rb) || 0;
|
|
|
|
if (rka < rkb) {
|
|
this.parent.set(ra, rb);
|
|
} else if (rkb < rka) {
|
|
this.parent.set(rb, ra);
|
|
} else {
|
|
this.parent.set(rb, ra);
|
|
this.rank.set(ra, rka + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function isUnknownSkuKey(key) {
|
|
return String(key || "").startsWith("u:");
|
|
}
|
|
|
|
function isNumericSku(key) {
|
|
return /^\d+$/.test(String(key || "").trim());
|
|
}
|
|
|
|
function compareSku(a, b) {
|
|
// Stable ordering to choose a canonical representative.
|
|
// Prefer real (non-u:) > unknown (u:). Among reals: numeric ascending if possible, else lex.
|
|
a = String(a || "").trim();
|
|
b = String(b || "").trim();
|
|
if (a === b) return 0;
|
|
|
|
const aUnknown = isUnknownSkuKey(a);
|
|
const bUnknown = isUnknownSkuKey(b);
|
|
if (aUnknown !== bUnknown) return aUnknown ? 1 : -1; // real first
|
|
|
|
const aNum = isNumericSku(a);
|
|
const bNum = isNumericSku(b);
|
|
if (aNum && bNum) {
|
|
// compare as integers (safe: these are small SKU strings)
|
|
const na = Number(a);
|
|
const nb = Number(b);
|
|
if (Number.isFinite(na) && Number.isFinite(nb) && na !== nb) return na < nb ? -1 : 1;
|
|
}
|
|
|
|
// fallback lex
|
|
return a < b ? -1 : 1;
|
|
}
|
|
|
|
function buildGroupsAndCanonicalMap(links) {
|
|
const dsu = new DSU();
|
|
const all = new Set();
|
|
|
|
for (const x of Array.isArray(links) ? links : []) {
|
|
const a = normalizeImplicitSkuKey(x?.fromSku);
|
|
const b = normalizeImplicitSkuKey(x?.toSku);
|
|
if (!a || !b) continue;
|
|
all.add(a);
|
|
all.add(b);
|
|
|
|
// IMPORTANT: union is undirected for grouping (hardened vs cycles)
|
|
dsu.union(a, b);
|
|
}
|
|
|
|
// root -> Set(members)
|
|
const groupsByRoot = new Map();
|
|
for (const s of all) {
|
|
const r = dsu.find(s);
|
|
if (!r) continue;
|
|
let set = groupsByRoot.get(r);
|
|
if (!set) groupsByRoot.set(r, (set = new Set()));
|
|
set.add(s);
|
|
}
|
|
|
|
// Choose a canonical representative per group
|
|
const repByRoot = new Map();
|
|
for (const [root, members] of groupsByRoot.entries()) {
|
|
const arr = Array.from(members);
|
|
arr.sort(compareSku);
|
|
const rep = arr[0] || root;
|
|
repByRoot.set(root, rep);
|
|
}
|
|
|
|
// sku -> canonical rep
|
|
const canonBySku = new Map();
|
|
// canonical rep -> Set(members) (what the rest of the app uses)
|
|
const groupsByCanon = new Map();
|
|
|
|
for (const [root, members] of groupsByRoot.entries()) {
|
|
const rep = repByRoot.get(root) || root;
|
|
let g = groupsByCanon.get(rep);
|
|
if (!g) groupsByCanon.set(rep, (g = new Set([rep])));
|
|
for (const s of members) {
|
|
canonBySku.set(s, rep);
|
|
g.add(s);
|
|
}
|
|
}
|
|
|
|
return { canonBySku, groupsByCanon };
|
|
}
|
|
|
|
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 : [];
|
|
|
|
// keep forwardMap for visibility/debug; grouping uses union-find
|
|
const forwardMap = buildForwardMap(links);
|
|
|
|
const { canonBySku, groupsByCanon } = buildGroupsAndCanonicalMap(links);
|
|
const ignoreSet = buildIgnoreSet(ignores);
|
|
|
|
function canonicalSku(sku) {
|
|
const s = normalizeImplicitSkuKey(sku);
|
|
if (!s) return s;
|
|
return canonBySku.get(s) || s;
|
|
}
|
|
|
|
function groupForCanonical(toSku) {
|
|
const canon = canonicalSku(toSku);
|
|
const g = groupsByCanon.get(canon);
|
|
return g ? new Set(g) : new Set([canon]);
|
|
}
|
|
|
|
function isIgnoredPair(a, b) {
|
|
const k = canonicalPairKey(a, b);
|
|
return k ? ignoreSet.has(k) : false;
|
|
}
|
|
|
|
CACHED = {
|
|
links,
|
|
ignores,
|
|
forwardMap,
|
|
|
|
// "toGroups" retained name for compatibility with existing code
|
|
toGroups: groupsByCanon,
|
|
ignoreSet,
|
|
|
|
canonicalSku,
|
|
groupForCanonical,
|
|
isIgnoredPair,
|
|
canonicalPairKey,
|
|
};
|
|
|
|
return CACHED;
|
|
}
|