feat: add automatic catalog organization for all imported furniture

Add a fully automatic "Organize all" action in the Studio that organizes
the entire database into catalog pages:

- Create a dedicated sub-page per furniture line (e.g. weebz, habbox) from
  FurnitureData.json, falling back to the auto-detected category.
- Add missing purchasable items to the catalog on their line/category page.
- Re-home and re-price existing catalog entries onto their correct page.
- Pages are created automatically when missing; no manual selection needed.

Includes the organizeCatalogAll service, a POST /api/admin/import/furni/organize
endpoint, and the Studio button.
This commit is contained in:
openhands committed 2026-08-22 11:27:22 +02:00
1 parent 0b0cf4f37f
commit bb7ec3db02
3 files changed
+321

No files matched your search

@@ -0,0 +1,42 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { organizeCatalogAll } from "@/lib/services/catalog-repair";
import { rcon } from "@/lib/services/rcon";
// ── Fully automatic catalog organization of the whole database ──────
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => {
try {
const result = await organizeCatalogAll();
// Refresh game server caches so the reorganized catalog shows up.
try {
await rcon.updateCatalog();
} catch (err) {
console.warn(
"[organize-furni] RCON catalog refresh failed:",
(err as Error).message,
);
}
logAudit({
userId: ctx.session.user.id,
action: "furni_organize",
target: "CatalogItems",
targetId: 0,
after: {
pagesCreated: result.pagesCreated,
catalogAdded: result.catalogAdded,
catalogRehomed: result.catalogRehomed,
},
});
return apiOk({ ...result });
} catch (err) {
return apiError((err as Error).message, 500);
}
},
);
@@ -514,6 +514,37 @@ export function StudioClient({
}
};
const [organizing, setOrganizing] = useState(false);
async function organizeAll() {
if (organizing) return;
setOrganizing(true);
try {
const res = await adminFetch("/api/admin/import/furni/organize", {
method: "POST",
});
const data = await res.json();
if (res.ok) {
const parts = [
`Catalogus georganiseerd`,
data.pagesCreated > 0
? `${data.pagesCreated} pagina's aangemaakt`
: null,
data.catalogAdded > 0 ? `${data.catalogAdded} nieuw` : null,
data.catalogRehomed > 0 ? `${data.catalogRehomed} bijgewerkt` : null,
].filter(Boolean);
toast.success(parts.join(" · "));
fetchStats();
} else {
toast.error(data.error || "Organiseren mislukt");
}
} catch {
toast.error("Netwerkfout");
} finally {
setOrganizing(false);
}
}
async function deleteItem() {
if (!deleting) return;
try {
@@ -844,6 +875,22 @@ export function StudioClient({
<div className="flex-1" />
<Button
size="sm"
variant="outline"
className="gap-1.5"
disabled={organizing || !!batchProgress}
onClick={organizeAll}
title="Alle meubels automatisch in catalogus-pagina's ordenen (pagina's worden aangemaakt indien nodig)"
>
{organizing ? (
<Loader2 size={14} className="animate-spin" />
) : (
<WandSparkles size={14} />
)}
Organiseer alles
</Button>
{selectedNonImported > 0 && (
<Button
size="sm"
+232
View File
@@ -1,17 +1,26 @@
import { readdir } from "node:fs/promises";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import {
readFurniData,
withFurniDataLock,
writeFurniData,
} from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
autoPriceFurni,
CATEGORY_PAGE,
classifyFurni,
getOrCreateCategoryPage,
getOrCreateImportedParentPage,
resetCatalogPageCache,
} from "@/lib/services/furni-import";
import {
assetNameCandidates,
classifyCatalogItem,
NO_CATALOG_KINDS,
} from "@/lib/services/item-kind";
export interface MissingCatalogEntry {
classname: string;
@@ -447,6 +456,229 @@ export async function applyCatalogSql(
return { applied, failed };
}
export interface OrganizeCatalogResult {
/** Number of catalog sub-pages created under "Imported Furniture". */
pagesCreated: number;
/** Number of items that got a brand new catalog_items entry. */
catalogAdded: number;
/** Number of existing catalog entries re-homed/re-priced onto their correct page. */
catalogRehomed: number;
}
/**
* Fully automatic one-shot organization of the entire `items_base` table into
* the catalog. For every furniture item it:
* 1. ensures the "Imported Furniture" parent page and the correct target
* sub-page exist (creating it if missing). The target is the item's
* furniture line (e.g. "weebz", "habbox") when known, otherwise its
* auto-detected category (e.g. "Room & Building", "Rares"),
* 2. creates a catalog_items entry if the item isn't in the catalog yet
* (placed on its line/category sub-page with the auto price),
* 3. re-homes and re-prices any existing catalog entry so it sits on its
* correct sub-page with the canonical auto-detected price.
*
* No manual selection required — runs over the entire database.
*/
export async function organizeCatalogAll(): Promise<OrganizeCatalogResult> {
// 1) Parent page (sub-pages are created on demand below).
const parentPageId = await getOrCreateImportedParentPage();
// Furniture line (brand/series) per classname, sourced from FurnitureData.json
// since `items_base` does not store it. Used to give each line its own page.
const furniData = (await readFurniData()) as Record<
string,
{ furnitype?: Array<{ classname?: string; furniline?: string }> }
>;
const furnilineByClass = new Map<string, string>();
for (const section of ["roomitemtypes", "wallitemtypes"] as const) {
for (const e of furniData[section]?.furnitype ?? []) {
const cn = typeof e.classname === "string" ? e.classname : "";
const fl = typeof e.furniline === "string" ? e.furniline.trim() : "";
if (!cn || !fl) continue;
furnilineByClass.set(cn, fl);
const base = cn.split("*")[0];
if (base && base !== cn) furnilineByClass.set(base, fl);
}
}
const resolveFurniline = (classname: string): string => {
const direct = furnilineByClass.get(classname);
if (direct) return direct;
const base = classname.split("*")[0];
return (base && furnilineByClass.get(base)) || "";
};
const slug = (value: string): string =>
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 40);
// Ensure a sub-page exists under the imported parent and return its id.
const ensureSubPage = async (
captionSave: string,
caption: string,
): Promise<number> => {
const [existing] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown];
if (existing?.[0]?.id) return existing[0].id;
await db.execute(sql`
INSERT INTO catalog_pages (caption_save, caption, page_layout, parent_id, min_rank, order_num, icon_image, enabled, visible, includes, page_headline, page_teaser, page_special, page_text1, page_text2, page_text_details, page_text_teaser)
VALUES (${captionSave}, ${caption}, 'default_3x3', ${parentPageId}, '1', 50, 263, '1', '1', '', '', '', '', '', '', '', '')
`);
const [created] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown];
return created?.[0]?.id ?? existing?.[0]?.id ?? 0;
};
// Sub-pages that already exist, so we can count the ones we create.
const [existingPages] = (await db.execute(sql`
SELECT caption_save FROM catalog_pages
WHERE parent_id = ${parentPageId} AND caption_save LIKE 'imp\\_%'
`)) as unknown as [Array<{ caption_save: string }>, unknown];
const existingCaptions = new Set(existingPages.map((r) => r.caption_save));
const createdCaptions = new Set<string>();
let pagesCreated = 0;
// 2) Inventory of items_base + current catalog state.
const [items] = (await db.execute(sql`
SELECT id, item_name, public_name, type, interaction_type
FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string | null;
}>,
unknown,
];
const [catalogRows] = (await db.execute(sql`
SELECT id, item_ids, page_id, cost_credits FROM catalog_items
`)) as unknown as [
Array<{
id: number;
item_ids: string;
page_id: number | string;
cost_credits: number;
}>,
unknown,
];
const idsInCatalog = new Set<number>();
const catalogPageByItem = new Map<number, number>();
const catalogCreditsByItem = new Map<number, number>();
for (const row of catalogRows) {
const ids = (row.item_ids ?? "")
.split(";")
.map((s) => Number(s.trim()))
.filter(Boolean);
for (const id of ids) {
idsInCatalog.add(id);
if (!catalogPageByItem.has(id)) {
catalogPageByItem.set(id, Number(row.page_id));
catalogCreditsByItem.set(id, Number(row.cost_credits));
}
}
}
const [validPages] = (await db.execute(sql`
SELECT id FROM catalog_pages
`)) as unknown as [Array<{ id: number }>, unknown];
const validPageIds = new Set(validPages.map((r) => Number(r.id)));
// Nitro presence (pets are purchasable without a furni .nitro bundle).
const { nitroDir } = await getFurniAssetDirs();
const nitroFiles = await readdir(nitroDir).catch(() => [] as string[]);
const nitroSet = new Set(nitroFiles);
let catalogAdded = 0;
let catalogRehomed = 0;
for (const item of items) {
const kind = classifyCatalogItem(item);
if (NO_CATALOG_KINDS.has(kind)) continue;
const itemId = Number(item.id);
const inCatalog = idsInCatalog.has(itemId);
// Items not yet in the catalog are only added when they are actually
// purchasable furniture (have a .nitro or are a pet).
if (!inCatalog) {
const hasNitro = assetNameCandidates(item.item_name).nitro.some((n) =>
nitroSet.has(n),
);
const purchasable = kind === "pet" || hasNitro;
if (!purchasable) continue;
}
// Target page: the item's furniture line (e.g. "weebz", "habbox") when
// known, otherwise its auto-detected category. The line page is created
// if it does not exist yet.
let captionSave: string;
let caption: string;
const furniline = resolveFurniline(item.item_name);
if (furniline && slug(furniline)) {
captionSave = `imp_line_${slug(furniline)}`;
caption = furniline;
} else {
const catKey = classifyFurni(item.item_name, item.type);
captionSave = `imp_${catKey}`;
caption = CATEGORY_PAGE[catKey]?.label ?? CATEGORY_PAGE.other.label;
}
if (
!existingCaptions.has(captionSave) &&
!createdCaptions.has(captionSave)
) {
createdCaptions.add(captionSave);
pagesCreated++;
}
const pageId = await ensureSubPage(captionSave, caption);
const price = autoPriceFurni(item.item_name);
if (!inCatalog) {
await allocateCatalogItemId(async (nextId) => {
await db.execute(sql`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextId}, ${pageId}, ${itemId}, ${item.item_name}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, ${itemId}, '')
`);
});
catalogAdded++;
continue;
}
// Re-classify onto the correct sub-page and re-apply the auto price if
// either drifted away from the canonical values.
const currentPage = catalogPageByItem.get(itemId);
const currentCredits = catalogCreditsByItem.get(itemId);
const needsMove =
currentPage === undefined ||
!validPageIds.has(currentPage) ||
currentPage !== pageId;
const needsReprice = currentCredits !== price.credits;
if (needsMove || needsReprice) {
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId},
cost_credits = ${price.credits},
cost_points = ${price.points},
points_type = ${price.pointsType}
WHERE item_ids = ${String(itemId)}
`);
catalogRehomed++;
}
}
resetCatalogPageCache();
return { pagesCreated, catalogAdded, catalogRehomed };
}
export interface OrphanedCatalogResult {
removed: number;
cleaned: number;