mirror of
https://github.com/samsonjs/spirit-tracker.git
synced 2026-03-25 09:25:51 +00:00
238 lines
6.1 KiB
JavaScript
Executable file
238 lines
6.1 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { execFileSync } = require("child_process");
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function listJsonFiles(dir) {
|
|
const out = [];
|
|
try {
|
|
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (!ent.isFile()) continue;
|
|
if (!String(ent.name || "").endsWith(".json")) continue;
|
|
out.push(path.join(dir, ent.name));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readDbCommitsOrNull(repoRoot) {
|
|
const p = path.join(repoRoot, "viz", "data", "db_commits.json");
|
|
try {
|
|
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function gitShowJson(sha, filePath) {
|
|
try {
|
|
const txt = execFileSync("git", ["show", `${sha}:${filePath}`], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"], // silence git fatal spam
|
|
});
|
|
return JSON.parse(txt);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeCspc(v) {
|
|
const m = String(v ?? "").match(/\b(\d{6})\b/);
|
|
return m ? m[1] : "";
|
|
}
|
|
|
|
function fnv1a32(str) {
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < str.length; i++) {
|
|
h ^= str.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
return (h >>> 0).toString(16).padStart(8, "0");
|
|
}
|
|
|
|
function makeSyntheticSku(storeLabel, url) {
|
|
const store = String(storeLabel || "store");
|
|
const u = String(url || "");
|
|
if (!u) return "";
|
|
return `u:${fnv1a32(`${store}|${u}`)}`;
|
|
}
|
|
|
|
function keySkuForItem(it, storeLabel) {
|
|
const real = normalizeCspc(it?.sku);
|
|
if (real) return real;
|
|
return makeSyntheticSku(storeLabel, it?.url);
|
|
}
|
|
|
|
// Returns Map(skuKey -> firstSeenAtISO) for this dbFile (store/category file).
|
|
function computeFirstSeenForDbFile({
|
|
repoRoot,
|
|
relDbFile,
|
|
storeLabel,
|
|
wantSkuKeys,
|
|
commitsArr,
|
|
nowIso,
|
|
}) {
|
|
const out = new Map();
|
|
const want = new Set(wantSkuKeys);
|
|
|
|
// No commit history available -> treat as new today
|
|
if (!Array.isArray(commitsArr) || !commitsArr.length) {
|
|
for (const k of want) out.set(k, nowIso);
|
|
return out;
|
|
}
|
|
|
|
// commitsArr is oldest -> newest (from db_commits.json)
|
|
for (const c of commitsArr) {
|
|
const sha = String(c?.sha || "");
|
|
const ts = String(c?.ts || "");
|
|
if (!sha || !ts) continue;
|
|
|
|
const obj = gitShowJson(sha, relDbFile);
|
|
const items = Array.isArray(obj?.items) ? obj.items : [];
|
|
const sLabel = String(obj?.storeLabel || obj?.store || storeLabel || "");
|
|
|
|
for (const it of items) {
|
|
if (!it) continue;
|
|
if (Boolean(it.removed)) continue; // first time it existed LIVE in this file
|
|
|
|
const k = keySkuForItem(it, sLabel);
|
|
if (!k) continue;
|
|
if (!want.has(k)) continue;
|
|
if (out.has(k)) continue;
|
|
|
|
out.set(k, ts);
|
|
if (out.size >= want.size) break;
|
|
}
|
|
|
|
if (out.size >= want.size) break;
|
|
}
|
|
|
|
// Anything never seen historically -> new today
|
|
for (const k of want) if (!out.has(k)) out.set(k, nowIso);
|
|
|
|
return out;
|
|
}
|
|
|
|
function main() {
|
|
const repoRoot = path.resolve(__dirname, "..");
|
|
const dbDir = path.join(repoRoot, "data", "db");
|
|
const outDir = path.join(repoRoot, "viz", "data");
|
|
const outFile = path.join(outDir, "index.json");
|
|
|
|
ensureDir(outDir);
|
|
|
|
const nowIso = new Date().toISOString();
|
|
const commitsManifest = readDbCommitsOrNull(repoRoot);
|
|
|
|
const items = [];
|
|
let liveCount = 0;
|
|
|
|
for (const file of listJsonFiles(dbDir)) {
|
|
const obj = readJson(file);
|
|
if (!obj) continue;
|
|
|
|
const store = String(obj.store || "");
|
|
const storeLabel = String(obj.storeLabel || store || "");
|
|
const category = String(obj.category || "");
|
|
const categoryLabel = String(obj.categoryLabel || "");
|
|
const source = String(obj.source || "");
|
|
const updatedAt = String(obj.updatedAt || "");
|
|
|
|
const dbFile = path.relative(repoRoot, file).replace(/\\/g, "/"); // e.g. data/db/foo.json
|
|
|
|
const arr = Array.isArray(obj.items) ? obj.items : [];
|
|
|
|
// Build want keys from CURRENT file contents (includes removed rows too)
|
|
const wantSkuKeys = [];
|
|
for (const it of arr) {
|
|
if (!it) continue;
|
|
const k = keySkuForItem(it, storeLabel);
|
|
if (k) wantSkuKeys.push(k);
|
|
}
|
|
|
|
const commitsArr = commitsManifest?.files?.[dbFile] || null;
|
|
const firstSeenByKey = computeFirstSeenForDbFile({
|
|
repoRoot,
|
|
relDbFile: dbFile,
|
|
storeLabel,
|
|
wantSkuKeys,
|
|
commitsArr,
|
|
nowIso,
|
|
});
|
|
|
|
for (const it of arr) {
|
|
if (!it) continue;
|
|
|
|
const removed = Boolean(it.removed);
|
|
if (!removed) liveCount++;
|
|
|
|
const sku = String(it.sku || "").trim();
|
|
const name = String(it.name || "").trim();
|
|
const price = String(it.price || "").trim();
|
|
const url = String(it.url || "").trim();
|
|
const img = String(it.img || it.image || it.thumb || "").trim();
|
|
|
|
const skuKey = keySkuForItem(it, storeLabel);
|
|
const firstSeenAt = skuKey ? String(firstSeenByKey.get(skuKey) || nowIso) : nowIso;
|
|
|
|
items.push({
|
|
sku,
|
|
name,
|
|
price,
|
|
url,
|
|
img,
|
|
removed, // NEW (additive): allows viz to show history / removed-only items
|
|
store,
|
|
storeLabel,
|
|
category,
|
|
categoryLabel,
|
|
source,
|
|
updatedAt,
|
|
firstSeenAt, // NEW: first time this item appeared LIVE in this store/category db file (or now)
|
|
dbFile,
|
|
});
|
|
}
|
|
}
|
|
|
|
items.sort((a, b) => {
|
|
const ak = `${a.sku}|${a.storeLabel}|${a.removed ? 1 : 0}|${a.name}|${a.url}`;
|
|
const bk = `${b.sku}|${b.storeLabel}|${b.removed ? 1 : 0}|${b.name}|${b.url}`;
|
|
return ak.localeCompare(bk);
|
|
});
|
|
|
|
const outObj = {
|
|
generatedAt: nowIso,
|
|
// Additive metadata. Old readers can ignore.
|
|
includesRemoved: true,
|
|
count: items.length,
|
|
countLive: liveCount,
|
|
items,
|
|
};
|
|
|
|
fs.writeFileSync(outFile, JSON.stringify(outObj, null, 2) + "\n", "utf8");
|
|
process.stdout.write(
|
|
`Wrote ${path.relative(repoRoot, outFile)} (${items.length} rows)\n`
|
|
);
|
|
}
|
|
|
|
module.exports = { main };
|
|
|
|
if (require.main === module) {
|
|
main();
|
|
}
|