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:
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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user