mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-04-27 15:07:43 +00:00
more sku work
This commit is contained in:
parent
86e79a1914
commit
8ff73428a2
8 changed files with 546 additions and 174 deletions
19
.github/workflows/pages.yaml
vendored
19
.github/workflows/pages.yaml
vendored
|
|
@ -30,6 +30,25 @@ jobs:
|
||||||
- name: Configure Pages
|
- name: Configure Pages
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
|
# Make sku_links.json available to the static site without committing a second copy.
|
||||||
|
- name: Stage sku_links.json into site artifact
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p viz/data
|
||||||
|
if [[ -f data/sku_links.json ]]; then
|
||||||
|
cp -f data/sku_links.json viz/data/sku_links.json
|
||||||
|
else
|
||||||
|
# keep site functional even if file is missing
|
||||||
|
cat > viz/data/sku_links.json <<'EOF'
|
||||||
|
{
|
||||||
|
"generatedAt": "",
|
||||||
|
"links": [],
|
||||||
|
"ignores": []
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload site artifact
|
- name: Upload site artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
180
viz/app/api.js
180
viz/app/api.js
|
|
@ -1,74 +1,114 @@
|
||||||
export async function fetchJson(url) {
|
export async function fetchJson(url) {
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchText(url) {
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||||
|
return await res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferGithubOwnerRepo() {
|
||||||
|
const host = location.hostname || "";
|
||||||
|
const m = host.match(/^([a-z0-9-]+)\.github\.io$/i);
|
||||||
|
if (m) {
|
||||||
|
const owner = m[1];
|
||||||
|
const parts = (location.pathname || "/").split("/").filter(Boolean);
|
||||||
|
const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`;
|
||||||
|
return { owner, repo };
|
||||||
|
}
|
||||||
|
return { owner: "brennanwilkes", repo: "spirit-tracker" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalWriteMode() {
|
||||||
|
const h = String(location.hostname || "").toLowerCase();
|
||||||
|
return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */
|
||||||
|
|
||||||
|
export async function apiReadSkuMetaFromLocalServer() {
|
||||||
|
const r = await fetch("/__stviz/sku-links", { cache: "no-store" });
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
const j = await r.json();
|
||||||
|
return {
|
||||||
|
links: Array.isArray(j?.links) ? j.links : [],
|
||||||
|
ignores: Array.isArray(j?.ignores) ? j.ignores : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiWriteSkuLink(fromSku, toSku) {
|
||||||
|
const res = await fetch("/__stviz/sku-links", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ fromSku, toSku }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiWriteSkuIgnore(skuA, skuB) {
|
||||||
|
const res = await fetch("/__stviz/sku-ignores", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ skuA, skuB }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort read of sku meta:
|
||||||
|
* - On GitHub Pages: expects file at viz/data/sku_links.json
|
||||||
|
* - On local server: reads via /__stviz/sku-links (disk)
|
||||||
|
*/
|
||||||
|
export async function loadSkuMetaBestEffort() {
|
||||||
|
// 1) GitHub Pages / static deploy inside viz/
|
||||||
|
try {
|
||||||
|
const j = await fetchJson("./data/sku_links.json");
|
||||||
|
return {
|
||||||
|
links: Array.isArray(j?.links) ? j.links : [],
|
||||||
|
ignores: Array.isArray(j?.ignores) ? j.ignores : [],
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 2) alternate static path (in case you later serve viz under a subpath)
|
||||||
|
try {
|
||||||
|
const j = await fetchJson("/data/sku_links.json");
|
||||||
|
return {
|
||||||
|
links: Array.isArray(j?.links) ? j.links : [],
|
||||||
|
ignores: Array.isArray(j?.ignores) ? j.ignores : [],
|
||||||
|
};
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 3) Local server API (disk)
|
||||||
|
try {
|
||||||
|
return await apiReadSkuMetaFromLocalServer();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return { links: [], ignores: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- GitHub history helpers ---- */
|
||||||
|
|
||||||
|
export async function githubListCommits({ owner, repo, branch, path }) {
|
||||||
|
const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`;
|
||||||
|
const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`;
|
||||||
|
const page1 = await fetchJson(u1);
|
||||||
|
|
||||||
|
if (Array.isArray(page1) && page1.length === 100) {
|
||||||
|
const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`;
|
||||||
|
const page2 = await fetchJson(u2);
|
||||||
|
return [...page1, ...(Array.isArray(page2) ? page2 : [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchText(url) {
|
return Array.isArray(page1) ? page1 : [];
|
||||||
const res = await fetch(url, { cache: "no-store" });
|
}
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
|
||||||
return await res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferGithubOwnerRepo() {
|
|
||||||
const host = location.hostname || "";
|
|
||||||
const m = host.match(/^([a-z0-9-]+)\.github\.io$/i);
|
|
||||||
if (m) {
|
|
||||||
const owner = m[1];
|
|
||||||
const parts = (location.pathname || "/").split("/").filter(Boolean);
|
|
||||||
const repo = parts.length >= 1 ? parts[0] : `${owner}.github.io`;
|
|
||||||
return { owner, repo };
|
|
||||||
}
|
|
||||||
return { owner: "brennanwilkes", repo: "spirit-tracker" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocalWriteMode() {
|
|
||||||
const h = String(location.hostname || "").toLowerCase();
|
|
||||||
return (location.protocol === "http:" || location.protocol === "https:") && (h === "127.0.0.1" || h === "localhost");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Local disk-backed SKU link API (only on viz/serve.js) ---- */
|
|
||||||
|
|
||||||
export async function loadSkuLinksBestEffort() {
|
|
||||||
try {
|
|
||||||
const r = await fetch("/__stviz/sku-links", { cache: "no-store" });
|
|
||||||
if (!r.ok) return [];
|
|
||||||
const j = await r.json();
|
|
||||||
return Array.isArray(j?.links) ? j.links : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiWriteSkuLink(fromSku, toSku) {
|
|
||||||
const res = await fetch("/__stviz/sku-links", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ fromSku, toSku }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
return await res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- GitHub history helpers ---- */
|
|
||||||
|
|
||||||
export async function githubListCommits({ owner, repo, branch, path }) {
|
|
||||||
const base = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits`;
|
|
||||||
const u1 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=1`;
|
|
||||||
const page1 = await fetchJson(u1);
|
|
||||||
|
|
||||||
if (Array.isArray(page1) && page1.length === 100) {
|
|
||||||
const u2 = `${base}?sha=${encodeURIComponent(branch)}&path=${encodeURIComponent(path)}&per_page=100&page=2`;
|
|
||||||
const page2 = await fetchJson(u2);
|
|
||||||
return [...page1, ...(Array.isArray(page2) ? page2 : [])];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(page1) ? page1 : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function githubFetchFileAtSha({ owner, repo, sha, path }) {
|
|
||||||
const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(sha)}/${path}`;
|
|
||||||
const txt = await fetchText(raw);
|
|
||||||
return JSON.parse(txt);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export async function githubFetchFileAtSha({ owner, repo, sha, path }) {
|
||||||
|
const raw = `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(sha)}/${path}`;
|
||||||
|
const txt = await fetchText(raw);
|
||||||
|
return JSON.parse(txt);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
import { normImg } from "./dom.js";
|
import { normImg } from "./dom.js";
|
||||||
import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js";
|
import { parsePriceToNumber, keySkuForRow, normSearchText } from "./sku.js";
|
||||||
|
|
||||||
// Build one row per SKU + combined searchable text across all listings of that SKU
|
// Build one row per *canonical* SKU (after applying sku map) + combined searchable text
|
||||||
export function aggregateBySku(listings) {
|
export function aggregateBySku(listings, canonicalizeSkuFn) {
|
||||||
|
const canon = typeof canonicalizeSkuFn === "function" ? canonicalizeSkuFn : (x) => x;
|
||||||
|
|
||||||
const bySku = new Map();
|
const bySku = new Map();
|
||||||
|
|
||||||
for (const r of listings) {
|
for (const r of listings) {
|
||||||
const sku = keySkuForRow(r);
|
const rawSku = keySkuForRow(r);
|
||||||
|
const sku = canon(rawSku);
|
||||||
|
|
||||||
const name = String(r?.name || "");
|
const name = String(r?.name || "");
|
||||||
const url = String(r?.url || "");
|
const url = String(r?.url || "");
|
||||||
const storeLabel = String(r?.storeLabel || r?.store || "");
|
const storeLabel = String(r?.storeLabel || r?.store || "");
|
||||||
|
|
||||||
const img = normImg(r?.img || r?.image || r?.thumb || "");
|
const img = normImg(r?.img || r?.image || r?.thumb || "");
|
||||||
|
|
||||||
const pNum = parsePriceToNumber(r?.price);
|
const pNum = parsePriceToNumber(r?.price);
|
||||||
const pStr = String(r?.price || "");
|
const pStr = String(r?.price || "");
|
||||||
|
|
||||||
let agg = bySku.get(sku);
|
let agg = bySku.get(sku);
|
||||||
if (!agg) {
|
if (!agg) {
|
||||||
agg = {
|
agg = {
|
||||||
sku,
|
sku, // canonical sku
|
||||||
name: name || "",
|
name: name || "",
|
||||||
img: "",
|
img: "",
|
||||||
cheapestPriceStr: pStr || "",
|
cheapestPriceStr: pStr || "",
|
||||||
|
|
@ -52,7 +56,7 @@ export function aggregateBySku(listings) {
|
||||||
if (name) agg._imgByName.set(name, img);
|
if (name) agg._imgByName.set(name, img);
|
||||||
}
|
}
|
||||||
|
|
||||||
// cheapest
|
// cheapest (across all merged rows)
|
||||||
if (pNum !== null) {
|
if (pNum !== null) {
|
||||||
if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) {
|
if (agg.cheapestPriceNum === null || pNum < agg.cheapestPriceNum) {
|
||||||
agg.cheapestPriceNum = pNum;
|
agg.cheapestPriceNum = pNum;
|
||||||
|
|
@ -61,8 +65,9 @@ export function aggregateBySku(listings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// search parts
|
// search parts: include canonical + raw sku so searching either works
|
||||||
agg._searchParts.push(sku);
|
agg._searchParts.push(sku);
|
||||||
|
if (rawSku && rawSku !== sku) agg._searchParts.push(rawSku);
|
||||||
if (name) agg._searchParts.push(name);
|
if (name) agg._searchParts.push(name);
|
||||||
if (url) agg._searchParts.push(url);
|
if (url) agg._searchParts.push(url);
|
||||||
if (storeLabel) agg._searchParts.push(storeLabel);
|
if (storeLabel) agg._searchParts.push(storeLabel);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { esc, renderThumbHtml, dateOnly } from "./dom.js";
|
||||||
import { parsePriceToNumber, keySkuForRow, displaySku } from "./sku.js";
|
import { parsePriceToNumber, keySkuForRow, displaySku } from "./sku.js";
|
||||||
import { loadIndex } from "./state.js";
|
import { loadIndex } from "./state.js";
|
||||||
import { inferGithubOwnerRepo, githubListCommits, githubFetchFileAtSha, fetchJson } from "./api.js";
|
import { inferGithubOwnerRepo, githubListCommits, githubFetchFileAtSha, fetchJson } from "./api.js";
|
||||||
|
import { loadSkuRules } from "./mapping.js";
|
||||||
|
|
||||||
/* ---------------- Chart lifecycle ---------------- */
|
/* ---------------- Chart lifecycle ---------------- */
|
||||||
|
|
||||||
|
|
@ -16,22 +17,44 @@ export function destroyChart() {
|
||||||
|
|
||||||
/* ---------------- History helpers ---------------- */
|
/* ---------------- History helpers ---------------- */
|
||||||
|
|
||||||
function findItemBySkuInDb(obj, skuKey, storeLabel) {
|
function findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel) {
|
||||||
const items = Array.isArray(obj?.items) ? obj.items : [];
|
const items = Array.isArray(obj?.items) ? obj.items : [];
|
||||||
|
let best = null;
|
||||||
|
|
||||||
|
// Build quick lookup for real sku entries (cheap)
|
||||||
|
const want = new Set();
|
||||||
|
for (const s of skuKeys) {
|
||||||
|
const x = String(s || "").trim();
|
||||||
|
if (x) want.add(x);
|
||||||
|
}
|
||||||
|
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (!it || it.removed) continue;
|
if (!it || it.removed) continue;
|
||||||
|
|
||||||
const real = String(it.sku || "").trim();
|
const real = String(it.sku || "").trim();
|
||||||
if (real && real === skuKey) return it;
|
if (real && want.has(real)) {
|
||||||
|
const p = parsePriceToNumber(it.price);
|
||||||
|
if (p !== null) best = best === null ? p : Math.min(best, p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// synthetic match for blank sku items: hash storeLabel|url
|
// synthetic match (only relevant if a caller passes u: keys)
|
||||||
if (!real && String(skuKey || "").startsWith("u:")) {
|
if (!real) {
|
||||||
const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" };
|
// if any skuKey is synthetic, match by hashing storeLabel|url
|
||||||
const k = keySkuForRow(row);
|
for (const skuKey of skuKeys) {
|
||||||
if (k === skuKey) return it;
|
const k = String(skuKey || "");
|
||||||
|
if (!k.startsWith("u:")) continue;
|
||||||
|
const row = { sku: "", url: String(it.url || ""), storeLabel: storeLabel || "", store: "" };
|
||||||
|
const kk = keySkuForRow(row);
|
||||||
|
if (kk === k) {
|
||||||
|
const p = parsePriceToNumber(it.price);
|
||||||
|
if (p !== null) best = best === null ? p : Math.min(best, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeSuggestedY(values) {
|
function computeSuggestedY(values) {
|
||||||
|
|
@ -62,7 +85,7 @@ function collapseCommitsToDaily(commits) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKeySeries(sku, dbFile, cacheBust) {
|
function cacheKeySeries(sku, dbFile, cacheBust) {
|
||||||
return `stviz:v2:series:${cacheBust}:${sku}:${dbFile}`;
|
return `stviz:v3:series:${cacheBust}:${sku}:${dbFile}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSeriesCache(sku, dbFile, cacheBust) {
|
function loadSeriesCache(sku, dbFile, cacheBust) {
|
||||||
|
|
@ -100,9 +123,11 @@ async function loadDbCommitsManifest() {
|
||||||
|
|
||||||
/* ---------------- Page ---------------- */
|
/* ---------------- Page ---------------- */
|
||||||
|
|
||||||
export async function renderItem($app, sku) {
|
export async function renderItem($app, skuInput) {
|
||||||
destroyChart();
|
destroyChart();
|
||||||
console.log("[renderItem] skuKey=", sku);
|
|
||||||
|
const rules = await loadSkuRules();
|
||||||
|
const sku = rules.canonicalSku(String(skuInput || ""));
|
||||||
|
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -140,8 +165,11 @@ export async function renderItem($app, sku) {
|
||||||
|
|
||||||
const idx = await loadIndex();
|
const idx = await loadIndex();
|
||||||
const all = Array.isArray(idx.items) ? idx.items : [];
|
const all = Array.isArray(idx.items) ? idx.items : [];
|
||||||
const want = String(sku || "");
|
|
||||||
const cur = all.filter((x) => keySkuForRow(x) === want);
|
// include toSku + all fromSkus mapped to it
|
||||||
|
const skuGroup = rules.groupForCanonical(sku);
|
||||||
|
|
||||||
|
const cur = all.filter((x) => skuGroup.has(String(keySkuForRow(x) || "")));
|
||||||
|
|
||||||
if (!cur.length) {
|
if (!cur.length) {
|
||||||
$title.textContent = "Item not found in current index";
|
$title.textContent = "Item not found in current index";
|
||||||
|
|
@ -150,6 +178,7 @@ export async function renderItem($app, sku) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick bestName by most common across merged rows
|
||||||
const nameCounts = new Map();
|
const nameCounts = new Map();
|
||||||
for (const r of cur) {
|
for (const r of cur) {
|
||||||
const n = String(r.name || "");
|
const n = String(r.name || "");
|
||||||
|
|
@ -167,12 +196,26 @@ export async function renderItem($app, sku) {
|
||||||
}
|
}
|
||||||
$title.textContent = bestName;
|
$title.textContent = bestName;
|
||||||
|
|
||||||
// pick image that matches bestName (fallback any)
|
// choose thumbnail from cheapest listing across merged rows (fallback: first that matches name)
|
||||||
let bestImg = "";
|
let bestImg = "";
|
||||||
|
let bestPrice = null;
|
||||||
|
|
||||||
for (const r of cur) {
|
for (const r of cur) {
|
||||||
if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) {
|
const p = parsePriceToNumber(r.price);
|
||||||
bestImg = String(r.img).trim();
|
const img = String(r?.img || "").trim();
|
||||||
break;
|
if (p !== null && img) {
|
||||||
|
if (bestPrice === null || p < bestPrice) {
|
||||||
|
bestPrice = p;
|
||||||
|
bestImg = img;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bestImg) {
|
||||||
|
for (const r of cur) {
|
||||||
|
if (String(r?.name || "") === String(bestName || "") && String(r?.img || "").trim()) {
|
||||||
|
bestImg = String(r.img).trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!bestImg) {
|
if (!bestImg) {
|
||||||
|
|
@ -183,8 +226,10 @@ export async function renderItem($app, sku) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `<div class="thumbPlaceholder"></div>`;
|
$thumbBox.innerHTML = bestImg ? renderThumbHtml(bestImg, "detailThumb") : `<div class="thumbPlaceholder"></div>`;
|
||||||
|
|
||||||
|
// show store links from merged rows (may include multiple per store; OK)
|
||||||
$links.innerHTML = cur
|
$links.innerHTML = cur
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || "")))
|
.sort((a, b) => String(a.storeLabel || "").localeCompare(String(b.storeLabel || "")))
|
||||||
|
|
@ -196,8 +241,14 @@ export async function renderItem($app, sku) {
|
||||||
const repo = gh.repo;
|
const repo = gh.repo;
|
||||||
const branch = "data";
|
const branch = "data";
|
||||||
|
|
||||||
|
// dbFile -> rows (because merged skus can exist in same dbFile)
|
||||||
const byDbFile = new Map();
|
const byDbFile = new Map();
|
||||||
for (const r of cur) if (r.dbFile && !byDbFile.has(r.dbFile)) byDbFile.set(r.dbFile, r);
|
for (const r of cur) {
|
||||||
|
if (!r.dbFile) continue;
|
||||||
|
const k = String(r.dbFile);
|
||||||
|
if (!byDbFile.has(k)) byDbFile.set(k, []);
|
||||||
|
byDbFile.get(k).push(r);
|
||||||
|
}
|
||||||
const dbFiles = [...byDbFile.keys()].sort();
|
const dbFiles = [...byDbFile.keys()].sort();
|
||||||
|
|
||||||
$status.textContent = `Loading history for ${dbFiles.length} store file(s)…`;
|
$status.textContent = `Loading history for ${dbFiles.length} store file(s)…`;
|
||||||
|
|
@ -210,9 +261,11 @@ export async function renderItem($app, sku) {
|
||||||
const cacheBust = String(idx.generatedAt || new Date().toISOString());
|
const cacheBust = String(idx.generatedAt || new Date().toISOString());
|
||||||
const today = dateOnly(idx.generatedAt || new Date().toISOString());
|
const today = dateOnly(idx.generatedAt || new Date().toISOString());
|
||||||
|
|
||||||
|
const skuKeys = [...skuGroup];
|
||||||
|
|
||||||
for (const dbFile of dbFiles) {
|
for (const dbFile of dbFiles) {
|
||||||
const row = byDbFile.get(dbFile);
|
const rows = byDbFile.get(dbFile) || [];
|
||||||
const storeLabel = String(row.storeLabel || row.store || dbFile);
|
const storeLabel = String(rows[0]?.storeLabel || rows[0]?.store || dbFile);
|
||||||
|
|
||||||
const cached = loadSeriesCache(sku, dbFile, cacheBust);
|
const cached = loadSeriesCache(sku, dbFile, cacheBust);
|
||||||
if (cached && Array.isArray(cached.points) && cached.points.length) {
|
if (cached && Array.isArray(cached.points) && cached.points.length) {
|
||||||
|
|
@ -275,23 +328,25 @@ export async function renderItem($app, sku) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = findItemBySkuInDb(obj, sku, storeLabel);
|
const pNum = findMinPriceForSkuGroupInDb(obj, skuKeys, storeLabel);
|
||||||
const pNum = it ? parsePriceToNumber(it.price) : null;
|
|
||||||
|
|
||||||
points.set(d, pNum);
|
points.set(d, pNum);
|
||||||
if (pNum !== null) values.push(pNum);
|
if (pNum !== null) values.push(pNum);
|
||||||
allDatesSet.add(d);
|
allDatesSet.add(d);
|
||||||
|
|
||||||
compactPoints.push({ date: d, price: pNum });
|
compactPoints.push({ date: d, price: pNum });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always add "today" from current index row
|
// Always add "today" from current index (min across merged rows in this store/dbFile)
|
||||||
const curP = parsePriceToNumber(row.price);
|
let curMin = null;
|
||||||
if (curP !== null) {
|
for (const r of rows) {
|
||||||
points.set(today, curP);
|
const p = parsePriceToNumber(r.price);
|
||||||
values.push(curP);
|
if (p !== null) curMin = curMin === null ? p : Math.min(curMin, p);
|
||||||
|
}
|
||||||
|
if (curMin !== null) {
|
||||||
|
points.set(today, curMin);
|
||||||
|
values.push(curMin);
|
||||||
allDatesSet.add(today);
|
allDatesSet.add(today);
|
||||||
compactPoints.push({ date: today, price: curP });
|
compactPoints.push({ date: today, price: curMin });
|
||||||
}
|
}
|
||||||
|
|
||||||
saveSeriesCache(sku, dbFile, cacheBust, compactPoints);
|
saveSeriesCache(sku, dbFile, cacheBust, compactPoints);
|
||||||
|
|
@ -316,7 +371,6 @@ export async function renderItem($app, sku) {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ctx = $canvas.getContext("2d");
|
const ctx = $canvas.getContext("2d");
|
||||||
// Chart is global from the UMD script include
|
|
||||||
CHART = new Chart(ctx, {
|
CHART = new Chart(ctx, {
|
||||||
type: "line",
|
type: "line",
|
||||||
data: { labels, datasets },
|
data: { labels, datasets },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { esc, renderThumbHtml } from "./dom.js";
|
||||||
import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js";
|
import { tokenizeQuery, matchesAllTokens, isUnknownSkuKey, displaySku, keySkuForRow, normSearchText } from "./sku.js";
|
||||||
import { loadIndex } from "./state.js";
|
import { loadIndex } from "./state.js";
|
||||||
import { aggregateBySku } from "./catalog.js";
|
import { aggregateBySku } from "./catalog.js";
|
||||||
import { isLocalWriteMode, loadSkuLinksBestEffort, apiWriteSkuLink } from "./api.js";
|
import { isLocalWriteMode, loadSkuMetaBestEffort, apiWriteSkuLink, apiWriteSkuIgnore } from "./api.js";
|
||||||
|
import { loadSkuRules } from "./mapping.js";
|
||||||
|
|
||||||
/* ---------------- Similarity helpers ---------------- */
|
/* ---------------- Similarity helpers ---------------- */
|
||||||
|
|
||||||
|
|
@ -79,6 +80,29 @@ function buildMappedSkuSet(links) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openLinkHtml(url) {
|
||||||
|
const u = String(url || "").trim();
|
||||||
|
if (!u) return "";
|
||||||
|
return `<a class="badge" href="${esc(u)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">open</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBCStoreLabel(label) {
|
||||||
|
const s = String(label || "").toLowerCase();
|
||||||
|
return s.includes("bcl") || s.includes("strath");
|
||||||
|
}
|
||||||
|
|
||||||
|
// infer BC-ness by checking any row for that skuKey in current index
|
||||||
|
function skuIsBC(allRows, skuKey) {
|
||||||
|
for (const r of allRows) {
|
||||||
|
if (keySkuForRow(r) !== skuKey) continue;
|
||||||
|
const lab = String(r.storeLabel || r.store || "");
|
||||||
|
if (isBCStoreLabel(lab)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Suggestion helpers ---------------- */
|
||||||
|
|
||||||
function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
function topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus) {
|
||||||
const scored = [];
|
const scored = [];
|
||||||
for (const it of allAgg) {
|
for (const it of allAgg) {
|
||||||
|
|
@ -96,11 +120,13 @@ 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) {
|
function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus, isIgnoredPairFn) {
|
||||||
if (!pinned || !pinned.name) return topSuggestions(allAgg, limit, otherPinnedSku, mappedSkus);
|
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 scored = [];
|
const scored = [];
|
||||||
|
|
||||||
for (const it of allAgg) {
|
for (const it of allAgg) {
|
||||||
if (!it) continue;
|
if (!it) continue;
|
||||||
if (isUnknownSkuKey(it.sku)) continue;
|
if (isUnknownSkuKey(it.sku)) continue;
|
||||||
|
|
@ -108,6 +134,8 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) {
|
||||||
if (it.sku === pinned.sku) continue;
|
if (it.sku === pinned.sku) continue;
|
||||||
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
if (otherPinnedSku && String(it.sku) === String(otherPinnedSku)) continue;
|
||||||
|
|
||||||
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(pinnedSku, String(it.sku || ""))) continue;
|
||||||
|
|
||||||
const s = similarityScore(base, it.name || "");
|
const s = similarityScore(base, it.name || "");
|
||||||
if (s > 0) scored.push({ it, s });
|
if (s > 0) scored.push({ it, s });
|
||||||
}
|
}
|
||||||
|
|
@ -115,8 +143,8 @@ function recommendSimilar(allAgg, pinned, limit, otherPinnedSku, mappedSkus) {
|
||||||
return scored.slice(0, limit).map((x) => x.it);
|
return scored.slice(0, limit).map((x) => x.it);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAST initial pairing (approx)
|
// FAST initial pairing (approx) with ignore-pair exclusion
|
||||||
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) {
|
function computeInitialPairsFast(allAgg, mappedSkus, limitPairs, isIgnoredPairFn) {
|
||||||
const items = allAgg.filter((it) => {
|
const items = allAgg.filter((it) => {
|
||||||
if (!it) return false;
|
if (!it) return false;
|
||||||
if (isUnknownSkuKey(it.sku)) return false;
|
if (isUnknownSkuKey(it.sku)) return false;
|
||||||
|
|
@ -162,6 +190,9 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) {
|
||||||
if (!bSku || bSku === aSku) continue;
|
if (!bSku || bSku === aSku) continue;
|
||||||
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
if (mappedSkus && mappedSkus.has(bSku)) continue;
|
||||||
if (isUnknownSkuKey(bSku)) continue;
|
if (isUnknownSkuKey(bSku)) continue;
|
||||||
|
|
||||||
|
if (typeof isIgnoredPairFn === "function" && isIgnoredPairFn(aSku, bSku)) continue;
|
||||||
|
|
||||||
cand.set(bSku, b);
|
cand.set(bSku, b);
|
||||||
}
|
}
|
||||||
if (cand.size >= MAX_CAND_TOTAL) break;
|
if (cand.size >= MAX_CAND_TOTAL) break;
|
||||||
|
|
@ -216,31 +247,11 @@ function computeInitialPairsFast(allAgg, mappedSkus, limitPairs) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLinkHtml(url) {
|
|
||||||
const u = String(url || "").trim();
|
|
||||||
if (!u) return "";
|
|
||||||
return `<a class="badge" href="${esc(u)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">open</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBCStoreLabel(label) {
|
|
||||||
const s = String(label || "").toLowerCase();
|
|
||||||
return s.includes("bcl") || s.includes("strath");
|
|
||||||
}
|
|
||||||
|
|
||||||
// infer BC-ness by checking any row for that skuKey in current index
|
|
||||||
function skuIsBC(allRows, skuKey) {
|
|
||||||
for (const r of allRows) {
|
|
||||||
if (keySkuForRow(r) !== skuKey) continue;
|
|
||||||
const lab = String(r.storeLabel || r.store || "");
|
|
||||||
if (isBCStoreLabel(lab)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- Page ---------------- */
|
/* ---------------- Page ---------------- */
|
||||||
|
|
||||||
export async function renderSkuLinker($app) {
|
export async function renderSkuLinker($app) {
|
||||||
const localWrite = isLocalWriteMode();
|
const localWrite = isLocalWriteMode();
|
||||||
|
const rules = await loadSkuRules();
|
||||||
|
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
<div class="container" style="max-width:1200px;">
|
<div class="container" style="max-width:1200px;">
|
||||||
|
|
@ -253,7 +264,7 @@ export async function renderSkuLinker($app) {
|
||||||
|
|
||||||
<div class="card" style="padding:14px;">
|
<div class="card" style="padding:14px;">
|
||||||
<div class="small" style="margin-bottom:10px;">
|
<div class="small" style="margin-bottom:10px;">
|
||||||
Unknown SKUs are hidden. Existing mapped SKUs are excluded. With both pinned, LINK SKU writes to sku_links.json (local only).
|
Unknown SKUs are hidden. Existing mapped SKUs are excluded. LINK SKU writes map; IGNORE PAIR writes a "do-not-suggest" pair (local only).
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:16px;">
|
<div style="display:flex; gap:16px;">
|
||||||
|
|
@ -273,6 +284,7 @@ export async function renderSkuLinker($app) {
|
||||||
|
|
||||||
<div class="card linkBar" style="padding:10px;">
|
<div class="card linkBar" style="padding:10px;">
|
||||||
<button id="linkBtn" class="btn" style="width:100%;" disabled>LINK SKU</button>
|
<button id="linkBtn" class="btn" style="width:100%;" disabled>LINK SKU</button>
|
||||||
|
<button id="ignoreBtn" class="btn" style="width:100%; margin-top:8px;" disabled>IGNORE PAIR</button>
|
||||||
<div id="status" class="small" style="margin-top:8px;"></div>
|
<div id="status" class="small" style="margin-top:8px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -285,6 +297,7 @@ export async function renderSkuLinker($app) {
|
||||||
const $listL = document.getElementById("listL");
|
const $listL = document.getElementById("listL");
|
||||||
const $listR = document.getElementById("listR");
|
const $listR = document.getElementById("listR");
|
||||||
const $linkBtn = document.getElementById("linkBtn");
|
const $linkBtn = document.getElementById("linkBtn");
|
||||||
|
const $ignoreBtn = document.getElementById("ignoreBtn");
|
||||||
const $status = document.getElementById("status");
|
const $status = document.getElementById("status");
|
||||||
|
|
||||||
$listL.innerHTML = `<div class="small">Loading index…</div>`;
|
$listL.innerHTML = `<div class="small">Loading index…</div>`;
|
||||||
|
|
@ -293,12 +306,18 @@ export async function renderSkuLinker($app) {
|
||||||
const idx = await loadIndex();
|
const idx = await loadIndex();
|
||||||
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
const allRows = Array.isArray(idx.items) ? idx.items : [];
|
||||||
|
|
||||||
const allAgg = aggregateBySku(allRows).filter((it) => !isUnknownSkuKey(it.sku));
|
// candidates for this page (hide unknown u: entirely)
|
||||||
|
const allAgg = aggregateBySku(allRows, (x) => x).filter((it) => !isUnknownSkuKey(it.sku));
|
||||||
|
|
||||||
const existingLinks = await loadSkuLinksBestEffort();
|
const meta = await loadSkuMetaBestEffort();
|
||||||
const mappedSkus = buildMappedSkuSet(existingLinks);
|
const mappedSkus = buildMappedSkuSet(meta.links || []);
|
||||||
|
const ignoreSet = rules.ignoreSet; // already canonicalized as "a|b"
|
||||||
|
|
||||||
const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28);
|
function isIgnoredPair(a, b) {
|
||||||
|
return rules.isIgnoredPair(String(a || ""), String(b || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialPairs = computeInitialPairsFast(allAgg, mappedSkus, 28, isIgnoredPair);
|
||||||
|
|
||||||
let pinnedL = null;
|
let pinnedL = null;
|
||||||
let pinnedR = null;
|
let pinnedR = null;
|
||||||
|
|
@ -336,12 +355,19 @@ export async function renderSkuLinker($app) {
|
||||||
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
const otherSku = otherPinned ? String(otherPinned.sku || "") : "";
|
||||||
|
|
||||||
if (tokens.length) {
|
if (tokens.length) {
|
||||||
return allAgg
|
const out = allAgg
|
||||||
.filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens))
|
.filter((it) => it && it.sku !== otherSku && !mappedSkus.has(String(it.sku)) && matchesAllTokens(it.searchText, tokens))
|
||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
|
|
||||||
|
// if the other side is pinned, also exclude ignored pairs
|
||||||
|
if (otherPinned) {
|
||||||
|
const oSku = String(otherPinned.sku || "");
|
||||||
|
return out.filter((it) => !isIgnoredPair(oSku, String(it.sku || "")));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otherPinned) return recommendSimilar(allAgg, otherPinned, 60, otherSku, mappedSkus);
|
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);
|
||||||
|
|
@ -396,35 +422,50 @@ export async function renderSkuLinker($app) {
|
||||||
attachHandlers($list, side);
|
attachHandlers($list, side);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButton() {
|
function updateButtons() {
|
||||||
if (!localWrite) {
|
if (!localWrite) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
|
$ignoreBtn.disabled = true;
|
||||||
$status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
$status.textContent = "Write disabled on GitHub Pages. Use: node viz/serve.js and open 127.0.0.1.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(pinnedL && pinnedR)) {
|
if (!(pinnedL && pinnedR)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking.";
|
$ignoreBtn.disabled = true;
|
||||||
|
if (!$status.textContent) $status.textContent = "Pin one item on each side to enable linking / ignoring.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (String(pinnedL.sku || "") === String(pinnedR.sku || "")) {
|
|
||||||
|
const a = String(pinnedL.sku || "");
|
||||||
|
const b = String(pinnedR.sku || "");
|
||||||
|
|
||||||
|
if (a === b) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.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 (mappedSkus.has(String(pinnedL.sku)) || mappedSkus.has(String(pinnedR.sku))) {
|
|
||||||
|
if (mappedSkus.has(a) || mappedSkus.has(b)) {
|
||||||
$linkBtn.disabled = true;
|
$linkBtn.disabled = true;
|
||||||
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
$ignoreBtn.disabled = false; // still allow ignoring even if mapped? you can decide; default allow
|
||||||
return;
|
} else {
|
||||||
|
$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 = "";
|
||||||
}
|
}
|
||||||
$linkBtn.disabled = false;
|
|
||||||
if ($status.textContent === "Pin one item on each side to enable linking.") $status.textContent = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
renderSide("L");
|
renderSide("L");
|
||||||
renderSide("R");
|
renderSide("R");
|
||||||
updateButton();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
let tL = null, tR = null;
|
let tL = null, tR = null;
|
||||||
|
|
@ -461,8 +502,12 @@ export async function renderSkuLinker($app) {
|
||||||
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
$status.textContent = "Not allowed: one of these SKUs is already mapped.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isIgnoredPair(a, b)) {
|
||||||
|
$status.textContent = "This pair is already ignored.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Direction: if either is BC-based, FROM is BC sku.
|
// Direction: if either is BC-based (BCL/Strath appears), FROM is BC sku.
|
||||||
const aBC = skuIsBC(allRows, a);
|
const aBC = skuIsBC(allRows, a);
|
||||||
const bBC = skuIsBC(allRows, b);
|
const bBC = skuIsBC(allRows, b);
|
||||||
|
|
||||||
|
|
@ -490,5 +535,39 @@ export async function renderSkuLinker($app) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$ignoreBtn.addEventListener("click", async () => {
|
||||||
|
if (!(pinnedL && pinnedR) || !localWrite) return;
|
||||||
|
|
||||||
|
const a = String(pinnedL.sku || "");
|
||||||
|
const b = String(pinnedR.sku || "");
|
||||||
|
|
||||||
|
if (!a || !b || isUnknownSkuKey(a) || isUnknownSkuKey(b)) {
|
||||||
|
$status.textContent = "Not allowed: unknown SKUs cannot be ignored.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (a === b) {
|
||||||
|
$status.textContent = "Not allowed: both sides cannot be the same SKU.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isIgnoredPair(a, b)) {
|
||||||
|
$status.textContent = "This pair is already ignored.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status.textContent = `Ignoring: ${displaySku(a)} × ${displaySku(b)} …`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await apiWriteSkuIgnore(a, b);
|
||||||
|
// update in-memory ignore set
|
||||||
|
ignoreSet.add(rules.canonicalPairKey(a, b));
|
||||||
|
$status.textContent = `Ignored: ${displaySku(a)} × ${displaySku(b)} (ignores=${out.count}).`;
|
||||||
|
pinnedL = null;
|
||||||
|
pinnedR = null;
|
||||||
|
updateAll();
|
||||||
|
} catch (e) {
|
||||||
|
$status.textContent = `Ignore failed: ${String(e && e.message ? e.message : e)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
updateAll();
|
updateAll();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
124
viz/app/mapping.js
Normal file
124
viz/app/mapping.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { loadSkuMetaBestEffort } from "./api.js";
|
||||||
|
|
||||||
|
let CACHED = null;
|
||||||
|
|
||||||
|
function canonicalPairKey(a, b) {
|
||||||
|
const x = String(a || "");
|
||||||
|
const y = String(b || "");
|
||||||
|
if (!x || !y) return "";
|
||||||
|
return x < y ? `${x}|${y}` : `${y}|${x}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForwardMap(links) {
|
||||||
|
const m = new Map();
|
||||||
|
for (const x of Array.isArray(links) ? links : []) {
|
||||||
|
const fromSku = String(x?.fromSku || "").trim();
|
||||||
|
const toSku = String(x?.toSku || "").trim();
|
||||||
|
if (fromSku && toSku && fromSku !== toSku) m.set(fromSku, toSku);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSkuWithMap(sku, forwardMap) {
|
||||||
|
const s0 = String(sku || "").trim();
|
||||||
|
if (!s0) return s0;
|
||||||
|
|
||||||
|
// Only resolve real SKUs; leave synthetic u: alone
|
||||||
|
if (s0.startsWith("u:")) return s0;
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
let cur = s0;
|
||||||
|
while (forwardMap.has(cur)) {
|
||||||
|
if (seen.has(cur)) break; // cycle guard
|
||||||
|
seen.add(cur);
|
||||||
|
cur = String(forwardMap.get(cur) || "").trim() || cur;
|
||||||
|
}
|
||||||
|
return cur || s0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToGroups(links, forwardMap) {
|
||||||
|
// group: canonical toSku -> Set(all skus mapping to it, transitively) incl toSku itself
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
// seed: include all explicit endpoints
|
||||||
|
for (const x of Array.isArray(links) ? links : []) {
|
||||||
|
const fromSku = String(x?.fromSku || "").trim();
|
||||||
|
const toSku = String(x?.toSku || "").trim();
|
||||||
|
if (!fromSku || !toSku) continue;
|
||||||
|
|
||||||
|
const canonTo = resolveSkuWithMap(toSku, forwardMap);
|
||||||
|
if (!groups.has(canonTo)) groups.set(canonTo, new Set([canonTo]));
|
||||||
|
groups.get(canonTo).add(fromSku);
|
||||||
|
groups.get(canonTo).add(toSku);
|
||||||
|
}
|
||||||
|
|
||||||
|
// close transitively: any sku that resolves to canonTo belongs in its group
|
||||||
|
// (cheap pass: expand by resolving all known skus in current link set)
|
||||||
|
const allSkus = new Set();
|
||||||
|
for (const x of Array.isArray(links) ? links : []) {
|
||||||
|
const a = String(x?.fromSku || "").trim();
|
||||||
|
const b = String(x?.toSku || "").trim();
|
||||||
|
if (a) allSkus.add(a);
|
||||||
|
if (b) allSkus.add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of allSkus) {
|
||||||
|
const canon = resolveSkuWithMap(s, forwardMap);
|
||||||
|
if (!groups.has(canon)) groups.set(canon, new Set([canon]));
|
||||||
|
groups.get(canon).add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSkuRules() {
|
||||||
|
if (CACHED) return CACHED;
|
||||||
|
|
||||||
|
const meta = await loadSkuMetaBestEffort();
|
||||||
|
const links = Array.isArray(meta?.links) ? meta.links : [];
|
||||||
|
const ignores = Array.isArray(meta?.ignores) ? meta.ignores : [];
|
||||||
|
|
||||||
|
const forwardMap = buildForwardMap(links);
|
||||||
|
const toGroups = buildToGroups(links, forwardMap);
|
||||||
|
const ignoreSet = buildIgnoreSet(ignores);
|
||||||
|
|
||||||
|
function canonicalSku(sku) {
|
||||||
|
return resolveSkuWithMap(sku, forwardMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupForCanonical(toSku) {
|
||||||
|
const canon = canonicalSku(toSku);
|
||||||
|
const g = toGroups.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,
|
||||||
|
ignoreSet,
|
||||||
|
canonicalSku,
|
||||||
|
groupForCanonical,
|
||||||
|
isIgnoredPair,
|
||||||
|
canonicalPairKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
return CACHED;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { esc, renderThumbHtml, prettyTs } from "./dom.js";
|
||||||
import { tokenizeQuery, matchesAllTokens, displaySku } from "./sku.js";
|
import { tokenizeQuery, matchesAllTokens, displaySku } from "./sku.js";
|
||||||
import { loadIndex, loadRecent, loadSavedQuery, saveQuery } from "./state.js";
|
import { loadIndex, loadRecent, loadSavedQuery, saveQuery } from "./state.js";
|
||||||
import { aggregateBySku } from "./catalog.js";
|
import { aggregateBySku } from "./catalog.js";
|
||||||
|
import { loadSkuRules } from "./mapping.js";
|
||||||
|
|
||||||
export function renderSearch($app) {
|
export function renderSearch($app) {
|
||||||
$app.innerHTML = `
|
$app.innerHTML = `
|
||||||
|
|
@ -79,13 +80,15 @@ export function renderSearch($app) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRecent(recent) {
|
function renderRecent(recent, canonicalSkuFn) {
|
||||||
const items = Array.isArray(recent?.items) ? recent.items : [];
|
const items = Array.isArray(recent?.items) ? recent.items : [];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
$results.innerHTML = `<div class="small">Type to search…</div>`;
|
$results.innerHTML = `<div class="small">Type to search…</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canon = typeof canonicalSkuFn === "function" ? canonicalSkuFn : (x) => x;
|
||||||
|
|
||||||
const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3;
|
const days = Number.isFinite(Number(recent?.windowDays)) ? Number(recent.windowDays) : 3;
|
||||||
const limited = items.slice(0, 140);
|
const limited = items.slice(0, 140);
|
||||||
|
|
||||||
|
|
@ -115,7 +118,9 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
const when = r.ts ? prettyTs(r.ts) : r.date || "";
|
const when = r.ts ? prettyTs(r.ts) : r.date || "";
|
||||||
|
|
||||||
const sku = String(r.sku || "");
|
const rawSku = String(r.sku || "");
|
||||||
|
const sku = canon(rawSku);
|
||||||
|
|
||||||
const img = aggBySku.get(sku)?.img || "";
|
const img = aggBySku.get(sku)?.img || "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|
@ -162,11 +167,7 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
const tokens = tokenizeQuery($q.value);
|
const tokens = tokenizeQuery($q.value);
|
||||||
if (!tokens.length) {
|
if (!tokens.length) {
|
||||||
loadRecent()
|
// recent gets rendered later after rules load
|
||||||
.then(renderRecent)
|
|
||||||
.catch(() => {
|
|
||||||
$results.innerHTML = `<div class="small">Type to search…</div>`;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,18 +177,20 @@ export function renderSearch($app) {
|
||||||
|
|
||||||
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
$results.innerHTML = `<div class="small">Loading index…</div>`;
|
||||||
|
|
||||||
loadIndex()
|
Promise.all([loadIndex(), loadSkuRules()])
|
||||||
.then((idx) => {
|
.then(([idx, rules]) => {
|
||||||
const listings = Array.isArray(idx.items) ? idx.items : [];
|
const listings = Array.isArray(idx.items) ? idx.items : [];
|
||||||
allAgg = aggregateBySku(listings);
|
allAgg = aggregateBySku(listings, rules.canonicalSku);
|
||||||
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
|
aggBySku = new Map(allAgg.map((x) => [String(x.sku || ""), x]));
|
||||||
indexReady = true;
|
indexReady = true;
|
||||||
$q.focus();
|
$q.focus();
|
||||||
applySearch();
|
|
||||||
return loadRecent();
|
const tokens = tokenizeQuery($q.value);
|
||||||
})
|
if (tokens.length) {
|
||||||
.then((recent) => {
|
applySearch();
|
||||||
if (!tokenizeQuery($q.value).length) renderRecent(recent);
|
} else {
|
||||||
|
return loadRecent().then((recent) => renderRecent(recent, rules.canonicalSku));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
$results.innerHTML = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
|
$results.innerHTML = `<div class="small">Failed to load: ${esc(e.message)}</div>`;
|
||||||
|
|
@ -197,6 +200,17 @@ export function renderSearch($app) {
|
||||||
$q.addEventListener("input", () => {
|
$q.addEventListener("input", () => {
|
||||||
saveQuery($q.value);
|
saveQuery($q.value);
|
||||||
if (t) clearTimeout(t);
|
if (t) clearTimeout(t);
|
||||||
t = setTimeout(applySearch, 50);
|
t = setTimeout(() => {
|
||||||
|
const tokens = tokenizeQuery($q.value);
|
||||||
|
if (!tokens.length) {
|
||||||
|
loadSkuRules()
|
||||||
|
.then((rules) => loadRecent().then((recent) => renderRecent(recent, rules.canonicalSku)))
|
||||||
|
.catch(() => {
|
||||||
|
$results.innerHTML = `<div class="small">Type to search…</div>`;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applySearch();
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
viz/serve.js
55
viz/serve.js
|
|
@ -30,16 +30,20 @@ function safePath(urlPath) {
|
||||||
// Project-level file (shared by viz + report tooling)
|
// Project-level file (shared by viz + report tooling)
|
||||||
const LINKS_FILE = path.join(projectRoot, "data", "sku_links.json");
|
const LINKS_FILE = path.join(projectRoot, "data", "sku_links.json");
|
||||||
|
|
||||||
function readLinks() {
|
function readMeta() {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(LINKS_FILE, "utf8");
|
const raw = fs.readFileSync(LINKS_FILE, "utf8");
|
||||||
const obj = JSON.parse(raw);
|
const obj = JSON.parse(raw);
|
||||||
if (obj && Array.isArray(obj.links)) return obj;
|
|
||||||
|
const links = obj && Array.isArray(obj.links) ? obj.links : [];
|
||||||
|
const ignores = obj && Array.isArray(obj.ignores) ? obj.ignores : [];
|
||||||
|
|
||||||
|
return { generatedAt: obj?.generatedAt || new Date().toISOString(), links, ignores };
|
||||||
} catch {}
|
} catch {}
|
||||||
return { generatedAt: new Date().toISOString(), links: [] };
|
return { generatedAt: new Date().toISOString(), links: [], ignores: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLinks(obj) {
|
function writeMeta(obj) {
|
||||||
obj.generatedAt = new Date().toISOString();
|
obj.generatedAt = new Date().toISOString();
|
||||||
fs.mkdirSync(path.dirname(LINKS_FILE), { recursive: true });
|
fs.mkdirSync(path.dirname(LINKS_FILE), { recursive: true });
|
||||||
fs.writeFileSync(LINKS_FILE, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
fs.writeFileSync(LINKS_FILE, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
||||||
|
|
@ -59,11 +63,12 @@ const server = http.createServer((req, res) => {
|
||||||
const u = req.url || "/";
|
const u = req.url || "/";
|
||||||
const url = new URL(u, "http://127.0.0.1");
|
const url = new URL(u, "http://127.0.0.1");
|
||||||
|
|
||||||
// Local API: append / read sku links file on disk (only exists when using this local server)
|
// Local API: read/write sku links + ignore pairs on disk (only exists when using this local server)
|
||||||
|
|
||||||
if (url.pathname === "/__stviz/sku-links") {
|
if (url.pathname === "/__stviz/sku-links") {
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
const obj = readLinks();
|
const obj = readMeta();
|
||||||
return sendJson(res, 200, { ok: true, count: obj.links.length, links: obj.links });
|
return sendJson(res, 200, { ok: true, count: obj.links.length, links: obj.links, ignores: obj.ignores });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
|
|
@ -76,9 +81,9 @@ const server = http.createServer((req, res) => {
|
||||||
const toSku = String(inp.toSku || "").trim();
|
const toSku = String(inp.toSku || "").trim();
|
||||||
if (!fromSku || !toSku) return sendJson(res, 400, { ok: false, error: "fromSku/toSku required" });
|
if (!fromSku || !toSku) return sendJson(res, 400, { ok: false, error: "fromSku/toSku required" });
|
||||||
|
|
||||||
const obj = readLinks();
|
const obj = readMeta();
|
||||||
obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() });
|
obj.links.push({ fromSku, toSku, createdAt: new Date().toISOString() });
|
||||||
writeLinks(obj);
|
writeMeta(obj);
|
||||||
|
|
||||||
return sendJson(res, 200, { ok: true, count: obj.links.length, file: "data/sku_links.json" });
|
return sendJson(res, 200, { ok: true, count: obj.links.length, file: "data/sku_links.json" });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -91,6 +96,38 @@ const server = http.createServer((req, res) => {
|
||||||
return send(res, 405, "Method Not Allowed");
|
return send(res, 405, "Method Not Allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/__stviz/sku-ignores") {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const obj = readMeta();
|
||||||
|
return sendJson(res, 200, { ok: true, count: obj.ignores.length, ignores: obj.ignores });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST") {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (c) => (body += c));
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const inp = JSON.parse(body || "{}");
|
||||||
|
const skuA = String(inp.skuA || "").trim();
|
||||||
|
const skuB = String(inp.skuB || "").trim();
|
||||||
|
if (!skuA || !skuB) return sendJson(res, 400, { ok: false, error: "skuA/skuB required" });
|
||||||
|
if (skuA === skuB) return sendJson(res, 400, { ok: false, error: "skuA and skuB must differ" });
|
||||||
|
|
||||||
|
const obj = readMeta();
|
||||||
|
obj.ignores.push({ skuA, skuB, createdAt: new Date().toISOString() });
|
||||||
|
writeMeta(obj);
|
||||||
|
|
||||||
|
return sendJson(res, 200, { ok: true, count: obj.ignores.length, file: "data/sku_links.json" });
|
||||||
|
} catch (e) {
|
||||||
|
return sendJson(res, 400, { ok: false, error: String(e && e.message ? e.message : e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(res, 405, "Method Not Allowed");
|
||||||
|
}
|
||||||
|
|
||||||
// Static
|
// Static
|
||||||
let file = safePath(u === "/" ? "/index.html" : u);
|
let file = safePath(u === "/" ? "/index.html" : u);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue