diff --git a/scripts/README.md b/scripts/README.md
index 2fcb4668..6e542fff 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -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=` | 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=`, `--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.
diff --git a/scripts/catalog-sync.sh b/scripts/catalog-sync.sh
index 1c2a8865..7f2b0cce 100755
--- a/scripts/catalog-sync.sh
+++ b/scripts/catalog-sync.sh
@@ -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
diff --git a/scripts/catalog-sync.test.ts b/scripts/catalog-sync.test.ts
index 5be8e68a..827ac8e1 100644
--- a/scripts/catalog-sync.test.ts
+++ b/scripts/catalog-sync.test.ts
@@ -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 = "";
diff --git a/scripts/catalog-sync.ts b/scripts/catalog-sync.ts
index b616a3a1..b3cd9ab5 100644
--- a/scripts/catalog-sync.ts
+++ b/scripts/catalog-sync.ts
@@ -17,7 +17,7 @@
// --tsv with the above, print `id\tname\tfurnidataUrl` rows
// --sources= 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= 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[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 = {
@@ -90,6 +125,17 @@ export const DEFAULT_CATALOG_SYNC_FLAGS: Omit = {
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 = {
@@ -109,6 +155,24 @@ const BOOLEAN_FLAGS: Record = {
"--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 = {
+ "--limit": "importLimit",
+ "--concurrency": "importConcurrency",
+};
+
+const LIST_FLAGS: Record = {
+ "--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,
+ 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 {
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 {
@@ -224,6 +363,186 @@ export async function writeGeneratedSql(sql: string): Promise {
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 {
+ 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;
+ 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;
+ 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 {
+ 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 {
+ 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 {
+ 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 {
const catalogue = await loadSourceCatalogue();
@@ -268,6 +587,19 @@ export async function commandRun(flags: CatalogSyncFlags): Promise {
(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 {
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();
for (const entry of report.missingFromSources)