feat(catalog-sync): add import, reconcile, sprite-id and offer steps
CI / check (push) Successful in 37s
CI / tests-unit (push) Successful in 2m3s
CI / tests-ui (push) Successful in 2m52s
CI / tests-integration (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / tests-imaging-browsers (push) Failing after 3m23s
CI / deploy (push) Successful in 3m3s
CI / tests-quality (push) Successful in 6m8s

The CLI could audit and repair what already existed, but adding furniture
present at a source was still only possible from the admin UI item by item. The
repo already ships every piece of that pipeline, so wire them together as
further steps of the same run:

- `--import-new` imports furniture the compared source has and this hotel lacks.
  It reuses `getClonableClassnames` for the diff and `cloneSingleFurni` per item,
  which downloads the icon and the bundle (sources serve `.nitro`, stored as
  `.hab`), appends the furnidata entry with `deferFurniData` and creates the
  catalog page plus the offer itself. Deferred entries are written in one
  `appendFurniEntriesBulk` call, so 26000 items cost one JSON write, not one per
  item. `--only`, `--limit` and `--concurrency` narrow/tune it, failures are
  isolated per item and never abort the run.
- `--reconcile`, `--fix-sprite-ids` and `--rebuild-offers` run the existing
  `reconcileFurniDataWithItemsBase`, `verifyAndFixSpriteIds` and
  `rebuildCatalogOfferIds` passes — the post-import consistency work.
- `--align-ids` and `--rename-bundles` delegate to the existing standalone
  scripts; both only report until `--apply` is given, matching their own
  contracts.
- The menu grows options 6-8 plus a dry run, each with its own confirmation.

`--everything` turns on every repair + import step but deliberately leaves the
destructive ones off. A live dry run against Wibbo listed 26593 items on the
source that this hotel does not have, and Habbo (COM) reported 2871.
This commit is contained in:
openhands committed 2026-10-15 20:12:13 +02:00
1 parent a4bcd40e7b
commit f56b345598
4 files changed
+563 -15

No files matched your search

+39 -3
View File
@@ -66,13 +66,49 @@ The menu asks which sources to compare (Habbo (COM) is the preset) and what may
be written. With no arguments it needs a terminal; with any flag it skips the
menu, so it is usable from cron.
Every step the engine exposes is available as a flag:
| Flag | What it does |
| :--- | :--- |
| `--sources=<a,b>` | Compare against these sources (id or display name) |
| `--icons` | Re-download missing furniture icons |
| `--nitros` | Re-download missing bundles (stored as `.hab`) |
| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) |
| `--structure` | Repair orphaned / duplicate catalog rows |
| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts |
| `--sql` / `--apply-sql` | Generate / apply the catalog SQL |
| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file |
| `--import-new` | Import furniture the source has and this hotel lacks |
| `--only=<a,b>`, `--limit=N`, `--concurrency=N` | Narrow and tune the import |
| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing |
| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift |
| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors |
| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) |
| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) |
| `--everything` | Every repair + import step, but still none of the destructive ones |
`--import-new` is the step that actually adds furniture: `cloneSingleFurni`
downloads the icon and the bundle (sources serve `.nitro`, the local copy is
stored as `.hab`), appends the `FurnitureData.json` entry **and** creates the
catalog page plus the offer, exactly like the app's own import. It skips
anything already present, so it is safe to re-run.
Safety notes:
- **An audit writes nothing** — no repair flag means read-only.
- `--apply-sql` is the only flag that changes the catalog, and the generated SQL
is always written to `storage/catalog-sync/*.sql` (gitignored) so it can be
reviewed or replayed by hand.
- `--dry-run` together with `--import-new` lists what would be imported and
imports nothing; use it first on a source before importing thousands of items.
- Generating the catalog SQL creates the `catalog_pages` sub-pages it references
(same as the app's import), so a "generate only" run is not entirely
read-only — only the `catalog_items` rows wait for `--apply-sql`. The SQL also
lands in `storage/catalog-sync/*.sql` (gitignored) so it can be reviewed or
replayed by hand.
- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises
the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`).
Both only report without it.
- The engine is the same one the admin *Catalog audit* page streams
(`runCatalogAudit`), so a terminal run and an in-admin run agree.
- `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when
error-severity issues remain, which is what a scheduled check should use.
- Items that no compared source can provide stay missing: the run reports them
instead of inventing an icon or a bundle.
+38 -3
View File
@@ -99,9 +99,15 @@ What should be done? (comparing with: $SOURCE_LABEL)
1) Audit only — nothing is written
2) Repair furniture icons + nitro bundles
3) 2 + FurnitureData.json + catalog structure
4) 3 + generate the catalog SQL (saved, not applied)
5) 4 + APPLY the catalog SQL to the catalog
q) Cancel
4) 3 + generate the catalog SQL (saved, not applied)
5) 4 + APPLY the catalog SQL to the catalog
6) 5 + import the furniture the source has and this hotel lacks
(downloads icon + bundle, adds FurnitureData.json, creates the catalog
page and the offer for every new item)
7) 6 + reconcile FurnitureData.json / sprite ids / offer ids
8) 7 + align items_base ids and rename legacy .nitro bundles (heaviest)
d) Dry run — only report what 6 would import
q) Cancel
EOF
read -r -p "Action [1]: " ACTION_CHOICE
@@ -132,6 +138,35 @@ case "$ACTION_CHOICE" in
ACTION_FLAGS=(--all --sql --apply-sql)
CONFIRM="This writes to the catalog. Continue? [y/N]: "
;;
6)
ACTIONS=(audit "everything + catalog SQL + import new furniture")
ACTION_FLAGS=(--all --sql --apply-sql --import-new)
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
;;
7)
ACTIONS=(audit "everything + import + reconcile")
ACTION_FLAGS=(
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
--rebuild-offers
)
CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: "
;;
8)
ACTIONS=(audit "everything + import + reconcile + id alignment + rename")
ACTION_FLAGS=(
--all --sql --apply-sql --import-new --reconcile --fix-sprite-ids
--rebuild-offers --align-ids --rename-bundles --apply
)
read -r -p " This rewrites items_base ids and renames bundle files. Type RUN to confirm: " REPLY
if [ "$REPLY" != "RUN" ]; then
echo "Cancelled."
exit 0
fi
;;
d | D)
ACTIONS=(audit "report only — nothing is imported")
ACTION_FLAGS=(--import-new --dry-run)
;;
q | Q)
echo "Cancelled."
exit 0
+114
View File
@@ -6,9 +6,13 @@ import {
DEFAULT_CATALOG_SYNC_FLAGS,
describeActions,
parseCatalogSyncArgs,
selectImportCandidates,
writeGeneratedSql,
} from "./catalog-sync";
const entry = (classname: string) =>
({ classname, name: classname, itemType: "s" }) as never;
describe("catalog-sync argument parsing", () => {
it("defaults to a read-only audit with no filters", () => {
expect(parseCatalogSyncArgs([])).toEqual({
@@ -83,6 +87,116 @@ describe("catalog-sync argument parsing", () => {
});
});
describe("catalog-sync import selection", () => {
it("skips furniture this hotel already has", () => {
const candidates = selectImportCandidates(
[entry("chair"), entry("table"), entry("") as never],
new Set(["chair"]),
);
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
});
it("is case-insensitive against the present set", () => {
const candidates = selectImportCandidates(
[entry("Chair"), entry("table")],
new Set(["chair"]),
);
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
});
it("honours the --only filter, also case-insensitively", () => {
const candidates = selectImportCandidates(
[entry("Chair"), entry("table"), entry("lamp")],
new Set(),
{ only: ["chair"] },
);
expect(candidates.map((c) => c.classname)).toEqual(["Chair"]);
});
it("caps the list with --limit", () => {
const candidates = selectImportCandidates(
[entry("a"), entry("b"), entry("c")],
new Set(),
{ limit: 2 },
);
expect(candidates.map((c) => c.classname)).toEqual(["a", "b"]);
});
it("returns everything when no limit is given", () => {
expect(
selectImportCandidates([entry("a"), entry("b")], new Set(), {
limit: 0,
}).length,
).toBe(2);
});
});
describe("catalog-sync step composition", () => {
it("--everything turns on every step except the writing ones", () => {
const flags = parseCatalogSyncArgs(["--everything"]);
expect(describeActions(flags)).toBe(
[
"audit",
"icons",
"nitros",
"furnidata",
"structure",
"id-check",
"import",
"reconcile",
"sprite-ids",
"offers",
].join(" + "),
);
// Destructive steps stay opt-in even inside --everything.
expect(flags.applySql).toBe(false);
expect(flags.apply).toBe(false);
expect(flags.alignIds).toBe(false);
expect(flags.renameBundles).toBe(false);
});
it("marks an import as a dry run", () => {
expect(
describeActions(parseCatalogSyncArgs(["--import-new", "--dry-run"])),
).toContain("import (dry)");
});
it("marks the child steps as reporting unless --apply is given", () => {
expect(
describeActions(
parseCatalogSyncArgs(["--align-ids", "--rename-bundles"]),
),
).toContain("align-ids");
expect(
describeActions(
parseCatalogSyncArgs(["--align-ids", "--rename-bundles", "--apply"]),
),
).toContain("rename=apply-bundles");
});
it("parses the import tuning flags", () => {
const flags = parseCatalogSyncArgs([
"--import-new",
"--limit=50",
"--concurrency=8",
"--only=chair,table",
]);
expect(flags.importLimit).toBe(50);
expect(flags.importConcurrency).toBe(8);
expect(flags.importOnly).toEqual(["chair", "table"]);
});
it("rejects a non-numeric --limit", () => {
expect(() => parseCatalogSyncArgs(["--limit=abc"])).toThrow(/integer/);
});
it("rejects --dry-run without an import to preview", () => {
expect(() => parseCatalogSyncArgs(["--dry-run"])).toThrow(
/together with --import-new/,
);
});
});
describe("generated catalog SQL output", () => {
let cwd = "";
+372 -9
View File
@@ -17,7 +17,7 @@
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
// --sources=<a,b> compare against these sources (id or name, comma-separated)
// --icons re-download missing furniture icons from the sources
// --nitros re-download missing .nitro / swf bundles
// --nitros re-download missing .nitro / swf bundles (stored as .hab)
// --furnidata repair FurnitureData.json (add missing, drop duplicates)
// --structure repair orphaned / duplicate catalog rows
// --check-ids check FurnitureData.json for spriteId conflicts
@@ -25,7 +25,18 @@
// --apply-sql apply the generated SQL (implies --sql)
// --organize-sql order the generated SQL by catalog page (implies --sql)
// --all --icons --nitros --furnidata --structure --check-ids
// --import-new import furniture the source has and this hotel lacks
// --only=<a,b> restrict the import to these classnames
// --limit=N stop the import after N items
// --concurrency=N parallel imports (default 3)
// --reconcile align FurnitureData.json with items_base after importing
// --fix-sprite-ids correct items_base.sprite_id drift
// --rebuild-offers rebuild catalog offer_id mirrors
// --align-ids move items_base ids onto their furnidata ids (report;
// add --apply to let the script execute it)
// --rename-bundles rename legacy .nitro bundles to .hab (report; --apply runs it)
// --json print one machine-readable JSON report
// --dry-run with --import-new: list what would be imported, import nothing
// --fail-on-errors exit 3 when error-severity issues remain
//
// Audit is read-only by default: nothing is written until one of the repair
@@ -35,18 +46,21 @@
// `--organize-sql` to also emit those page INSERTs into the .sql file.
import "./load-env";
import { spawnSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname";
import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit";
import type { SourceFurni } from "@/lib/services/clone-import";
import type { CloneSource } from "@/lib/services/clone-sources";
/** Where generated catalog SQL is written (`storage/` is gitignored). */
function sqlOutputDir(): string {
return join(process.cwd(), "storage", "catalog-sync");
}
export type SourceRef = { id: string; name: string; furnidataUrl: string };
export type SourceRef = CloneSource;
export type AuditIssue = NonNullable<AuditEvent["issues"]>[number];
export type MissingFromSource = NonNullable<
@@ -59,6 +73,16 @@ export interface AuditReport {
issues: AuditIssue[];
missingFromSources: MissingFromSource[];
sqlFile?: string;
imported?: ImportOutcome;
}
export interface ImportOutcome {
source: string;
candidates: number;
imported: number;
skipped: number;
failed: number;
failures: Array<{ classname: string; error: string }>;
}
export interface CatalogSyncFlags {
@@ -75,6 +99,17 @@ export interface CatalogSyncFlags {
organizeSql: boolean;
json: boolean;
failOnErrors: boolean;
importNew: boolean;
dryRun: boolean;
importLimit: number;
importConcurrency: number;
importOnly: string[];
reconcile: boolean;
fixSpriteIds: boolean;
rebuildOffers: boolean;
alignIds: boolean;
renameBundles: boolean;
apply: boolean;
}
export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
@@ -90,6 +125,17 @@ export const DEFAULT_CATALOG_SYNC_FLAGS: Omit<CatalogSyncFlags, "sourceIds"> = {
organizeSql: false,
json: false,
failOnErrors: false,
importNew: false,
dryRun: false,
importLimit: 0,
importConcurrency: 3,
importOnly: [],
reconcile: false,
fixSpriteIds: false,
rebuildOffers: false,
alignIds: false,
renameBundles: false,
apply: false,
};
const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
@@ -109,6 +155,24 @@ const BOOLEAN_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--organize-sql": "organizeSql",
"--json": "json",
"--fail-on-errors": "failOnErrors",
"--import-new": "importNew",
"--dry-run": "dryRun",
"--reconcile": "reconcile",
"--fix-sprite-ids": "fixSpriteIds",
"--rebuild-offers": "rebuildOffers",
"--align-ids": "alignIds",
"--rename-bundles": "renameBundles",
// Authorises the two heavyweight child steps that rewrite stored data.
"--apply": "apply",
};
const NUMBER_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--limit": "importLimit",
"--concurrency": "importConcurrency",
};
const LIST_FLAGS: Record<string, keyof CatalogSyncFlags> = {
"--only": "importOnly",
};
export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
@@ -129,6 +193,19 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
continue;
}
if (arg === "--everything") {
flags.repairIcons = true;
flags.repairNitros = true;
flags.repairFurniData = true;
flags.repairStructure = true;
flags.checkFurniDataIds = true;
flags.importNew = true;
flags.reconcile = true;
flags.fixSpriteIds = true;
flags.rebuildOffers = true;
continue;
}
const separator = arg.indexOf("=");
const name = separator >= 0 ? arg.slice(0, separator) : arg;
@@ -152,6 +229,28 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
continue;
}
if (NUMBER_FLAGS[name]) {
if (separator < 0)
throw new Error(`${name} needs a value, e.g. ${name}=10`);
const parsed = Number(arg.slice(separator + 1));
if (!Number.isInteger(parsed) || parsed < 0)
throw new Error(`${name} needs a non-negative integer`);
flags[NUMBER_FLAGS[name]] = parsed as never;
continue;
}
if (LIST_FLAGS[name]) {
if (separator < 0)
throw new Error(`${name} needs a value, e.g. ${name}=chair,table`);
const value = arg.slice(separator + 1);
for (const part of value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean))
flags.importOnly.push(part);
continue;
}
throw new Error(
`Unknown flag: ${arg} (run with --help for the supported flags)`,
);
@@ -160,9 +259,47 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags {
// Applying SQL implies generating it, and organising it implies generating.
if (flags.applySql) flags.generateSql = true;
if (flags.organizeSql) flags.generateSql = true;
// A dry run without an import step has nothing to preview.
if (flags.dryRun && !flags.importNew)
throw new Error("--dry-run only makes sense together with --import-new");
if (flags.importNew && flags.applySql) {
// Both write; that is allowed but it is worth making the operator think.
logNote?.("--import-new and --apply-sql will both write to the catalog");
}
return flags;
}
let logNote: ((line: string) => void) | undefined;
/**
* Pick the furniture to import from a source's furnidata.
*
* Pure so the filtering rules (skip what we already have, `--only` filter,
* `--limit` cap) can be tested without a database.
*/
export function selectImportCandidates(
entries: SourceFurni[],
present: Set<string>,
options: { only?: string[]; limit?: number } = {},
): SourceFurni[] {
const normalize = (value: string) => value.trim().toLowerCase();
const only = options.only?.length
? new Set(options.only.map(normalize))
: null;
const candidates = entries.filter((entry) => {
if (!entry.classname) return false;
if (present.has(normalize(entry.classname))) return false;
if (only && !only.has(normalize(entry.classname))) return false;
return true;
});
const limit = options.limit ?? 0;
return limit > 0 ? candidates.slice(0, limit) : candidates;
}
/** Human-readable list of what the run will do. */
export function describeActions(flags: CatalogSyncFlags): string {
const actions = ["audit"];
@@ -171,6 +308,13 @@ export function describeActions(flags: CatalogSyncFlags): string {
if (flags.repairFurniData) actions.push("furnidata");
if (flags.repairStructure) actions.push("structure");
if (flags.checkFurniDataIds) actions.push("id-check");
if (flags.importNew) actions.push(flags.dryRun ? "import (dry)" : "import");
if (flags.reconcile) actions.push("reconcile");
if (flags.fixSpriteIds) actions.push("sprite-ids");
if (flags.rebuildOffers) actions.push("offers");
if (flags.alignIds) actions.push(`align-ids${flags.apply ? "=apply" : ""}`);
if (flags.renameBundles)
actions.push(`rename${flags.apply ? "=apply" : "-dry"}-bundles`);
if (flags.applySql) actions.push("apply-sql");
else if (flags.generateSql) actions.push("generate-sql");
if (flags.organizeSql) actions.push("organised");
@@ -188,13 +332,8 @@ function isEntryPoint(): boolean {
async function loadSourceCatalogue(): Promise<SourceRef[]> {
const { listSources } = await import("@/lib/services/clone-sources");
const sources = await listSources();
return sources
.filter((source) => source.furnidataUrl)
.map((source) => ({
id: source.id,
name: source.name,
furnidataUrl: source.furnidataUrl,
}));
// Full source objects: the import step needs the icon/nitro base URLs too.
return sources.filter((source) => source.furnidataUrl);
}
async function commandListSources(flags: CatalogSyncFlags): Promise<number> {
@@ -224,6 +363,186 @@ export async function writeGeneratedSql(sql: string): Promise<string> {
return file.replace(`${process.cwd()}/`, "");
}
/**
* Run one of the repo's existing standalone maintenance scripts as a child
* process, with the same Node conditions this CLI needs.
*/
function runChildScript(
script: string,
args: string[],
log: (line: string) => void,
): number {
const nodeArgs = [
"--conditions=react-server",
"--import",
"tsx",
script,
...args,
];
log(`· running scripts/${script} ${args.join(" ")}`.trimEnd());
const result = spawnSync(process.execPath, nodeArgs, {
cwd: process.cwd(),
stdio: "inherit",
env: process.env,
});
if (result.error) {
console.error(`! ${script} failed to start: ${result.error.message}`);
return 1;
}
return result.status ?? 1;
}
/**
* Import furniture that exists at the compared source but not in this hotel.
*
* `cloneSingleFurni` downloads the bundle + icon, stores the bundle as
* `LOCAL_BUNDLE_EXT`, appends the furnidata entry and creates the catalog page
* plus the offer itself — so an import is not usable for furniture you want to
* keep out of the catalog.
*/
async function stepImportNew(
flags: CatalogSyncFlags,
sources: SourceRef[],
log: (line: string) => void,
): Promise<ImportOutcome | undefined> {
const { getClonableClassnames, fetchSourceFurnidata } = await import(
"@/lib/services/clone-import"
);
const { cloneSingleFurni } = await import("@/lib/services/clone-import");
let planned = 0;
let imported = 0;
let skipped = 0;
let failed = 0;
const failures: Array<{ classname: string; error: string }> = [];
const deferredEntries: Array<{
entry: Record<string, unknown>;
itemType: string;
}> = [];
let lastSource = "";
for (const source of sources) {
if (flags.importLimit > 0 && planned >= flags.importLimit) break;
const remaining = flags.importLimit
? flags.importLimit - planned
: Number.POSITIVE_INFINITY;
log(`· listing furniture missing at ${source.name}…`);
const clonable = new Set(await getClonableClassnames(source));
const entries = await fetchSourceFurnidata(source.furnidataUrl);
const candidates = selectImportCandidates(entries, clonable, {
only: flags.importOnly,
limit: Number.isFinite(remaining) ? remaining : 0,
});
if (candidates.length === 0) {
log(` nothing new to import from ${source.name}`);
continue;
}
lastSource = source.name;
if (flags.dryRun) {
log(
` ${candidates.length} would be imported from ${source.name}${flags.importOnly.length ? " (filtered by --only)" : ""}`,
);
for (const entry of candidates.slice(0, 20))
log(` ${entry.classname} — ${entry.name}`);
if (candidates.length > 20)
log(` … and ${candidates.length - 20} more`);
planned += candidates.length;
continue;
}
planned += candidates.length;
const started = Date.now();
let done = 0;
let index = 0;
const worker = async () => {
while (index < candidates.length) {
const entry = candidates[index++];
const result = await cloneSingleFurni({
source,
entry,
deferFurniData: true,
});
done += 1;
if (result.ok) imported += 1;
else if (result.skipped) skipped += 1;
else {
failed += 1;
failures.push({
classname: entry.classname,
error: result.error ?? "unknown error",
});
}
if (result.furniDataEntry)
deferredEntries.push(
result.furniDataEntry as {
entry: Record<string, unknown>;
itemType: string;
},
);
if (done % 10 === 0 || done === candidates.length) {
const rate = Math.max(done / ((Date.now() - started) / 1000), 1);
const etaMin = Math.round((candidates.length - done) / rate / 60);
log(
` ${source.name}: ${done}/${candidates.length} ${rate.toFixed(1)}/s, ETA ~${etaMin}m`,
);
}
}
};
const workers = Math.max(
1,
Math.min(flags.importConcurrency, candidates.length),
);
await Promise.all(Array.from({ length: workers }, worker));
}
if (!flags.dryRun && deferredEntries.length > 0) {
const { appendFurniEntriesBulk } = await import(
"@/lib/services/furni-data"
);
await appendFurniEntriesBulk(deferredEntries);
log(`· FurnitureData.json: +${deferredEntries.length} entries (one write)`);
}
return {
source: lastSource,
candidates: planned,
imported,
skipped,
failed,
failures: failures.slice(0, 25),
};
}
/** Keep FurnitureData.json consistent with items_base after the import. */
async function stepReconcile(log: (line: string) => void): Promise<void> {
const { reconcileFurniDataWithItemsBase } = await import(
"@/lib/services/furni-data"
);
const result = await reconcileFurniDataWithItemsBase();
log(
`· reconcile: ${result.fixedIds} sprite id(s), ${result.fixedOfferIds} offer id(s), ${result.normalizedIds} normalised, ${result.fixedConflicts} conflict(s) resolved, ${result.missing} still missing`,
);
}
/** items_base.sprite_id must equal its id wherever furnidata carries the item. */
async function stepFixSpriteIds(log: (line: string) => void): Promise<void> {
const { verifyAndFixSpriteIds } = await import("@/lib/services/furni-data");
const result = await verifyAndFixSpriteIds();
log(`· sprite ids: ${result.checked} checked, ${result.fixed} corrected`);
}
/** offer_id mirrors the row id, never a literal -1. */
async function stepRebuildOffers(log: (line: string) => void): Promise<void> {
const { rebuildCatalogOfferIds } = await import("@/lib/services/furni-data");
const result = await rebuildCatalogOfferIds();
log(`· offer ids: ${JSON.stringify(result)}`);
}
export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
const catalogue = await loadSourceCatalogue();
@@ -268,6 +587,19 @@ export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
(flags.applySql ? " [SQL WILL BE APPLIED]" : ""),
);
// 1) Move items_base ids onto their furnidata ids before anything else, so
// every later step sees stable ids. The script only reports without
// `--apply`.
if (flags.alignIds) {
runChildScript(
"scripts/align-db-ids-with-furnidata.ts",
flags.apply ? ["--apply"] : [],
log,
);
}
// 2) Audit + repair of what is already here.
await runCatalogAudit(
(event) => {
switch (event.type) {
@@ -331,6 +663,24 @@ export async function commandRun(flags: CatalogSyncFlags): Promise<number> {
if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql);
// 3) Import what the source has and this hotel does not.
if (flags.importNew) {
if (selected.length === 0) log("· no source selected, nothing to import");
else report.imported = await stepImportNew(flags, selected, log);
}
// 4) Post-import consistency, then the legacy bundle rename.
if (flags.reconcile) await stepReconcile(log);
if (flags.fixSpriteIds) await stepFixSpriteIds(log);
if (flags.rebuildOffers) await stepRebuildOffers(log);
if (flags.renameBundles) {
runChildScript(
"scripts/migrate-nitro-to-hab.ts",
flags.apply ? ["--yes"] : ["--dry-run"],
log,
);
}
if (flags.json) {
console.log(
JSON.stringify(
@@ -381,6 +731,19 @@ function printSummary(report: AuditReport, started: number): void {
` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`,
);
if (report.imported) {
const outcome = report.imported;
console.log(
` imported from source ${outcome.imported} new (${outcome.skipped} already present, ${outcome.failed} failed)`,
);
for (const failure of outcome.failures)
console.log(` ${failure.classname}: ${failure.error}`);
if (outcome.failures.length > 0)
console.log(
` import failures are per-item: a failed download never aborts the run.`,
);
}
if (report.missingFromSources.length > 0) {
const bySource = new Map<string, number>();
for (const entry of report.missingFromSources)