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() {
|
async function deleteItem() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
@@ -844,6 +875,22 @@ export function StudioClient({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<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 && (
|
{selectedNonImported > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
import {
|
import {
|
||||||
readFurniData,
|
readFurniData,
|
||||||
withFurniDataLock,
|
withFurniDataLock,
|
||||||
writeFurniData,
|
writeFurniData,
|
||||||
} from "@/lib/services/furni-data";
|
} from "@/lib/services/furni-data";
|
||||||
import {
|
import {
|
||||||
|
allocateCatalogItemId,
|
||||||
autoPriceFurni,
|
autoPriceFurni,
|
||||||
CATEGORY_PAGE,
|
CATEGORY_PAGE,
|
||||||
classifyFurni,
|
classifyFurni,
|
||||||
getOrCreateCategoryPage,
|
getOrCreateCategoryPage,
|
||||||
getOrCreateImportedParentPage,
|
getOrCreateImportedParentPage,
|
||||||
|
resetCatalogPageCache,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
|
import {
|
||||||
|
assetNameCandidates,
|
||||||
|
classifyCatalogItem,
|
||||||
|
NO_CATALOG_KINDS,
|
||||||
|
} from "@/lib/services/item-kind";
|
||||||
|
|
||||||
export interface MissingCatalogEntry {
|
export interface MissingCatalogEntry {
|
||||||
classname: string;
|
classname: string;
|
||||||
@@ -447,6 +456,229 @@ export async function applyCatalogSql(
|
|||||||
return { applied, failed };
|
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 {
|
export interface OrphanedCatalogResult {
|
||||||
removed: number;
|
removed: number;
|
||||||
cleaned: number;
|
cleaned: number;
|
||||||
|
|||||||
Reference in new issue
Block a user