revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s
Local Build and Deploy / deploy (push) Successful in 1m11s
revert style: clean up code with prettier and eslint
This commit is contained in:
1 parent
27de078f54
commit
e85e4d74ea
378 files changed
+20710
-22428
No files matched your search
@@ -1,11 +1,11 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiOk } from "@/lib/api-response";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||
import { existsSync, promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { withAdmin } from '@/lib/api-handler'
|
||||
import { apiError, apiOk } from '@/lib/api-response'
|
||||
import { PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { logAudit } from '@/lib/services/audit'
|
||||
import { getFurniAssetDirs } from '@/lib/services/furni-asset-dirs'
|
||||
import {
|
||||
autoPriceFurni,
|
||||
CATEGORY_PAGE,
|
||||
@@ -16,56 +16,56 @@ import {
|
||||
ensureDirectories,
|
||||
IMPORTED_PAGE_CAPTION_SAVE,
|
||||
importSingleFurni,
|
||||
} from "@/lib/services/furni-import";
|
||||
import { getHabboItFurnidata } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { listFurniture } from "@/lib/services/habbofurni";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { convertSwfToNitro } from "@/lib/services/swf-to-nitro";
|
||||
} from '@/lib/services/furni-import'
|
||||
import { getHabboItFurnidata } from '@/lib/services/habbo-furnidata-cache'
|
||||
import { listFurniture } from '@/lib/services/habbofurni'
|
||||
import { rcon } from '@/lib/services/rcon'
|
||||
import { convertSwfToNitro } from '@/lib/services/swf-to-nitro'
|
||||
|
||||
// Re-export for batch route backward compatibility
|
||||
export { ensureDirectories, importSingleFurni } from "@/lib/services/furni-import";
|
||||
export type { ImportSingleResult } from "@/types/furni";
|
||||
export { ensureDirectories, importSingleFurni } from '@/lib/services/furni-import'
|
||||
export type { ImportSingleResult } from '@/types/furni'
|
||||
|
||||
// ── Delete imported item or clean up SWF files ──────────────────────
|
||||
export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const classname = request.nextUrl.searchParams.get("classname");
|
||||
const classname = request.nextUrl.searchParams.get('classname')
|
||||
|
||||
// ── Delete specific imported item ─────────────────────────────────
|
||||
if (classname) {
|
||||
try {
|
||||
const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname);
|
||||
const { spriteId, deletedFiles, errors } = await deleteImportedItem(classname)
|
||||
|
||||
// Refresh game server caches
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
} catch (err) {
|
||||
errors.push(`RCON: ${(err as Error).message}`);
|
||||
errors.push(`RCON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_delete",
|
||||
target: "ItemsBase",
|
||||
action: 'furni_delete',
|
||||
target: 'ItemsBase',
|
||||
targetId: spriteId,
|
||||
after: { classname, deletedFiles, errors: errors.length > 0 ? errors : undefined },
|
||||
});
|
||||
})
|
||||
|
||||
return apiOk({
|
||||
classname,
|
||||
deletedFiles,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
})
|
||||
} catch (err) {
|
||||
return apiError((err as Error).message, 404);
|
||||
return apiError((err as Error).message, 404)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Clean up SWF files that have been converted to Nitro ──────────
|
||||
const result = await cleanupConvertedSwfs();
|
||||
const result = await cleanupConvertedSwfs()
|
||||
|
||||
if (result.total === 0 && result.deleted === 0) {
|
||||
return apiOk({ deleted: 0, message: "SWF directory does not exist" });
|
||||
return apiOk({ deleted: 0, message: 'SWF directory does not exist' })
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
@@ -73,162 +73,164 @@ export const DELETE = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (requ
|
||||
total: result.total,
|
||||
remaining: result.remaining,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ── Search furni via official Habbo furnidata ───────────────────────
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const { nitroDir } = await getFurniAssetDirs();
|
||||
const { nitroDir } = await getFurniAssetDirs()
|
||||
// Stats endpoint
|
||||
const action = request.nextUrl.searchParams.get("action");
|
||||
if (action === "stats") {
|
||||
const action = request.nextUrl.searchParams.get('action')
|
||||
if (action === 'stats') {
|
||||
const [totalResult, catalogResult, nitroMissingResult] = await Promise.all([
|
||||
prisma.$queryRaw<[{ cnt: bigint }]>`SELECT COUNT(*) as cnt FROM items_base`,
|
||||
prisma.$queryRaw<[{ cnt: bigint }]>`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
|
||||
prisma.$queryRaw<
|
||||
[{ cnt: bigint }]
|
||||
>`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
|
||||
prisma.$queryRaw<
|
||||
[{ cnt: bigint }]
|
||||
>`SELECT COUNT(*) as cnt FROM items_base WHERE item_name NOT IN (SELECT catalog_name FROM catalog_items)`,
|
||||
]);
|
||||
])
|
||||
|
||||
const totalInDb = Number(totalResult[0].cnt);
|
||||
const inCatalog = Number(catalogResult[0].cnt);
|
||||
const notInCatalog = Number(nitroMissingResult[0].cnt);
|
||||
const totalInDb = Number(totalResult[0].cnt)
|
||||
const inCatalog = Number(catalogResult[0].cnt)
|
||||
const notInCatalog = Number(nitroMissingResult[0].cnt)
|
||||
|
||||
// Count missing .nitro files by checking filesystem
|
||||
const importedItems = await prisma.$queryRaw<Array<{ item_name: string }>>`
|
||||
SELECT ib.item_name FROM items_base ib
|
||||
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
|
||||
LIMIT 10000
|
||||
`;
|
||||
`
|
||||
|
||||
let missingNitro = 0;
|
||||
let missingNitro = 0
|
||||
for (const item of importedItems) {
|
||||
const starIdx = item.item_name.indexOf("*");
|
||||
const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name;
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++;
|
||||
const starIdx = item.item_name.indexOf('*')
|
||||
const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
|
||||
if (!existsSync(/*turbopackIgnore: true*/ nitroPath)) missingNitro++
|
||||
}
|
||||
|
||||
return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro });
|
||||
return apiOk({ totalInDb, inCatalog, notInCatalog, missingNitro })
|
||||
}
|
||||
|
||||
// Missing nitro endpoint: returns only imported items missing .nitro files
|
||||
if (action === "missing-nitro") {
|
||||
const pageNum = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
||||
const perPage = 50;
|
||||
if (action === 'missing-nitro') {
|
||||
const pageNum = parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
|
||||
const perPage = 50
|
||||
|
||||
const importedItems = await prisma.$queryRaw<
|
||||
Array<{
|
||||
item_name: string;
|
||||
public_name: string;
|
||||
sprite_id: number;
|
||||
type: string;
|
||||
item_name: string
|
||||
public_name: string
|
||||
sprite_id: number
|
||||
type: string
|
||||
}>
|
||||
>`
|
||||
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
|
||||
INNER JOIN catalog_items ci ON ci.catalog_name = ib.item_name
|
||||
`;
|
||||
`
|
||||
|
||||
// Filter to only those missing .nitro on filesystem
|
||||
const missing = importedItems.filter((item) => {
|
||||
const starIdx = item.item_name.indexOf("*");
|
||||
const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name;
|
||||
const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
return !existsSync(/*turbopackIgnore: true*/ p);
|
||||
});
|
||||
const starIdx = item.item_name.indexOf('*')
|
||||
const base = starIdx !== -1 ? item.item_name.substring(0, starIdx) : item.item_name
|
||||
const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
|
||||
return !existsSync(/*turbopackIgnore: true*/ p)
|
||||
})
|
||||
|
||||
const total = missing.length;
|
||||
const lastPage = Math.max(Math.ceil(total / perPage), 1);
|
||||
const start = (pageNum - 1) * perPage;
|
||||
const pageItems = missing.slice(start, start + perPage);
|
||||
const total = missing.length
|
||||
const lastPage = Math.max(Math.ceil(total / perPage), 1)
|
||||
const start = (pageNum - 1) * perPage
|
||||
const pageItems = missing.slice(start, start + perPage)
|
||||
|
||||
// Enrich with revision from official furnidata
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
const furnidata = await getHabboItFurnidata()
|
||||
const items = pageItems.map((item) => {
|
||||
const fd =
|
||||
furnidata.get(item.item_name) ||
|
||||
furnidata.get(item.item_name.split("*")[0]) ||
|
||||
furnidata.get(`${item.item_name}*0`);
|
||||
furnidata.get(item.item_name.split('*')[0]) ||
|
||||
furnidata.get(`${item.item_name}*0`)
|
||||
return {
|
||||
id: item.sprite_id,
|
||||
classname: item.item_name,
|
||||
name: item.public_name || item.item_name,
|
||||
description: fd?.description || "",
|
||||
type: item.type === "i" ? "wallitem" : "flooritem",
|
||||
description: fd?.description || '',
|
||||
type: item.type === 'i' ? 'wallitem' : 'flooritem',
|
||||
revision: fd?.revision || 0,
|
||||
category: fd?.category || fd?.furniline || "",
|
||||
category: fd?.category || fd?.furniline || '',
|
||||
alreadyImported: true,
|
||||
nitroExists: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
return apiOk({
|
||||
items,
|
||||
meta: { currentPage: pageNum, lastPage, total, perPage },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const search = request.nextUrl.searchParams.get("search") || "";
|
||||
const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10);
|
||||
const search = request.nextUrl.searchParams.get('search') || ''
|
||||
const page = parseInt(request.nextUrl.searchParams.get('page') || '1', 10)
|
||||
|
||||
// Pull every imported classname once so we can both (a) sort non-imported
|
||||
// items to the top *before* pagination and (b) skip the per-page lookup
|
||||
// below. Single-column indexed query — fast even at 10k+ rows.
|
||||
const allImported = await prisma.itemsBase.findMany({
|
||||
select: { itemName: true },
|
||||
});
|
||||
const importedSet = new Set(allImported.map((e) => e.itemName).filter((n): n is string => !!n));
|
||||
})
|
||||
const importedSet = new Set(allImported.map((e) => e.itemName).filter((n): n is string => !!n))
|
||||
|
||||
const result = await listFurniture({
|
||||
search: search || undefined,
|
||||
page,
|
||||
perPage: 50,
|
||||
importedSet,
|
||||
});
|
||||
})
|
||||
|
||||
const enriched = result.items.map((item) => {
|
||||
const imported = importedSet.has(item.classname);
|
||||
let nitroExists = false;
|
||||
const imported = importedSet.has(item.classname)
|
||||
let nitroExists = false
|
||||
if (imported) {
|
||||
const starIdx = item.classname.indexOf("*");
|
||||
const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
|
||||
const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`);
|
||||
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
|
||||
const starIdx = item.classname.indexOf('*')
|
||||
const base = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname
|
||||
const p = path.join(/*turbopackIgnore: true*/ nitroDir, `${base}.nitro`)
|
||||
nitroExists = existsSync(/*turbopackIgnore: true*/ p)
|
||||
}
|
||||
return { ...item, alreadyImported: imported, nitroExists };
|
||||
});
|
||||
return { ...item, alreadyImported: imported, nitroExists }
|
||||
})
|
||||
|
||||
return apiOk({ items: enriched, meta: result.meta });
|
||||
});
|
||||
return apiOk({ items: enriched, meta: result.meta })
|
||||
})
|
||||
|
||||
// ── Import furni (single or batch) ──────────────────────────────────
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const body = await request.json();
|
||||
const body = await request.json()
|
||||
|
||||
await ensureDirectories();
|
||||
await ensureDirectories()
|
||||
|
||||
// ── Batch import ─────────────────────────────────────────────────
|
||||
if (Array.isArray(body.items)) {
|
||||
const results: Array<{
|
||||
classname: string;
|
||||
ok: boolean;
|
||||
itemId?: number;
|
||||
catalogItemId?: number | null;
|
||||
dimensions?: { x: number; y: number; z: number };
|
||||
spriteCount?: number;
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
}> = [];
|
||||
classname: string
|
||||
ok: boolean
|
||||
itemId?: number
|
||||
catalogItemId?: number | null
|
||||
dimensions?: { x: number; y: number; z: number }
|
||||
spriteCount?: number
|
||||
warnings: string[]
|
||||
error?: string
|
||||
}> = []
|
||||
|
||||
for (const item of body.items) {
|
||||
if (!item.classname || !item.name) {
|
||||
results.push({
|
||||
classname: item.classname || "unknown",
|
||||
classname: item.classname || 'unknown',
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: "Missing classname or name",
|
||||
});
|
||||
continue;
|
||||
error: 'Missing classname or name',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -236,30 +238,30 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
id: item.id ?? 0,
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
type: item.type ?? "flooritem",
|
||||
description: item.description ?? '',
|
||||
type: item.type ?? 'flooritem',
|
||||
revision: item.revision ?? 0,
|
||||
category: item.category ?? "unknown",
|
||||
});
|
||||
category: item.category ?? 'unknown',
|
||||
})
|
||||
|
||||
results.push({ classname: item.classname, ...result });
|
||||
results.push({ classname: item.classname, ...result })
|
||||
|
||||
if (result.ok) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
action: 'furni_import',
|
||||
target: 'ItemsBase',
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname: item.classname,
|
||||
name: item.name,
|
||||
type: item.type === "wallitem" ? "i" : "s",
|
||||
type: item.type === 'wallitem' ? 'i' : 's',
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
batch: true,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({
|
||||
@@ -267,66 +269,66 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
ok: false,
|
||||
warnings: [],
|
||||
error: (err as Error).message,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh caches once after batch
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
} catch (err) {
|
||||
console.warn("[import-furni] RCON update failed after batch:", (err as Error).message);
|
||||
console.warn('[import-furni] RCON update failed after batch:', (err as Error).message)
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
const succeeded = results.filter((r) => r.ok).length
|
||||
const failed = results.filter((r) => !r.ok).length
|
||||
|
||||
return apiOk({ batch: true, succeeded, failed, results });
|
||||
return apiOk({ batch: true, succeeded, failed, results })
|
||||
}
|
||||
|
||||
// ── Single import ────────────────────────────────────────────────
|
||||
const { id, classname, name, description, type, revision, category } = body;
|
||||
const { id, classname, name, description, type, revision, category } = body
|
||||
|
||||
if (!classname || !name) return apiError("Missing classname or name", 400);
|
||||
if (!classname || !name) return apiError('Missing classname or name', 400)
|
||||
|
||||
const result = await importSingleFurni({
|
||||
id: id ?? 0,
|
||||
classname,
|
||||
name,
|
||||
description: description ?? "",
|
||||
type: type ?? "flooritem",
|
||||
description: description ?? '',
|
||||
type: type ?? 'flooritem',
|
||||
revision: revision ?? 0,
|
||||
category: category ?? "unknown",
|
||||
});
|
||||
category: category ?? 'unknown',
|
||||
})
|
||||
|
||||
if (!result.ok) return apiError(result.error!, 409);
|
||||
if (!result.ok) return apiError(result.error!, 409)
|
||||
|
||||
// Refresh game server caches
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateItems();
|
||||
await rcon.updateCatalog()
|
||||
await rcon.updateItems()
|
||||
} catch (err) {
|
||||
console.warn("[import-furni] RCON update failed:", (err as Error).message);
|
||||
result.warnings.push("RCON cache refresh failed");
|
||||
console.warn('[import-furni] RCON update failed:', (err as Error).message)
|
||||
result.warnings.push('RCON cache refresh failed')
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_import",
|
||||
target: "ItemsBase",
|
||||
action: 'furni_import',
|
||||
target: 'ItemsBase',
|
||||
targetId: result.itemId!,
|
||||
after: {
|
||||
classname,
|
||||
name,
|
||||
spriteId: id ?? 0,
|
||||
type: type === "wallitem" ? "i" : "s",
|
||||
type: type === 'wallitem' ? 'i' : 's',
|
||||
catalogItemId: result.catalogItemId,
|
||||
dimensions: result.dimensions,
|
||||
spriteCount: result.spriteCount,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return apiOk({
|
||||
id: result.itemId,
|
||||
@@ -334,42 +336,44 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reques
|
||||
dimensions: result.dimensions,
|
||||
spriteCount: result.spriteCount,
|
||||
warnings: result.warnings.length > 0 ? result.warnings : undefined,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ── Re-generate missing .nitro files (PATCH) ─────────────────────────
|
||||
export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request) => {
|
||||
const body = await request.json();
|
||||
const items: Array<{ classname: string; revision: number }> = body.items || [];
|
||||
const body = await request.json()
|
||||
const items: Array<{ classname: string; revision: number }> = body.items || []
|
||||
|
||||
if (items.length === 0) return apiError("No items provided", 400);
|
||||
if (items.length === 0) return apiError('No items provided', 400)
|
||||
|
||||
await ensureDirectories();
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs();
|
||||
await ensureDirectories()
|
||||
const { swfDir, nitroDir } = await getFurniAssetDirs()
|
||||
|
||||
const results: Array<{ classname: string; ok: boolean; warning?: string }> = [];
|
||||
const results: Array<{ classname: string; ok: boolean; warning?: string }> = []
|
||||
|
||||
for (const item of items) {
|
||||
const starIdx = item.classname.indexOf("*");
|
||||
const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname;
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`);
|
||||
const starIdx = item.classname.indexOf('*')
|
||||
const baseClassname = starIdx !== -1 ? item.classname.substring(0, starIdx) : item.classname
|
||||
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, `${baseClassname}.nitro`)
|
||||
|
||||
// Skip if .nitro already exists
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
results.push({ classname: item.classname, ok: true, warning: "Already exists" });
|
||||
continue;
|
||||
results.push({ classname: item.classname, ok: true, warning: 'Already exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve revision: try furnidata first, then spriteId as fallback
|
||||
let rev = item.revision || 0;
|
||||
let rev = item.revision || 0
|
||||
if (rev === 0) {
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
const furnidata = await getHabboItFurnidata()
|
||||
const fd =
|
||||
furnidata.get(item.classname) || furnidata.get(baseClassname) || furnidata.get(`${baseClassname}*0`);
|
||||
if (fd) rev = fd.revision;
|
||||
furnidata.get(item.classname) ||
|
||||
furnidata.get(baseClassname) ||
|
||||
furnidata.get(`${baseClassname}*0`)
|
||||
if (fd) rev = fd.revision
|
||||
}
|
||||
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`);
|
||||
const swfPath = path.join(swfDir, `${baseClassname}.swf`)
|
||||
|
||||
// Download SWF if not present — try multiple revision candidates
|
||||
if (!existsSync(swfPath)) {
|
||||
@@ -377,20 +381,20 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
|
||||
const spriteId = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: { spriteId: true },
|
||||
});
|
||||
const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))];
|
||||
})
|
||||
const revsToTry = [...new Set([rev, spriteId?.spriteId || 0].filter((r) => r > 0))]
|
||||
|
||||
let downloaded = false;
|
||||
let downloaded = false
|
||||
for (const tryRev of revsToTry) {
|
||||
const dl = await downloadFile(
|
||||
`https://images.habbo.com/dcr/hof_furni/${tryRev}/${encodeURIComponent(baseClassname)}.swf`,
|
||||
swfPath,
|
||||
{ validate: "swf" },
|
||||
);
|
||||
{ validate: 'swf' },
|
||||
)
|
||||
if (dl.ok) {
|
||||
rev = tryRev;
|
||||
downloaded = true;
|
||||
break;
|
||||
rev = tryRev
|
||||
downloaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,225 +402,231 @@ export const PATCH = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (reque
|
||||
results.push({
|
||||
classname: item.classname,
|
||||
ok: false,
|
||||
warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(", ") || "none"})`,
|
||||
});
|
||||
continue;
|
||||
warning: `SWF not found on Habbo CDN (tried revisions: ${revsToTry.join(', ') || 'none'})`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Convert SWF → Nitro + update FurnitureData.json
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(swfPath);
|
||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname);
|
||||
await fs.writeFile(nitroPath, conversion.bundle);
|
||||
const swfBuffer = await fs.readFile(swfPath)
|
||||
const conversion = convertSwfToNitro(swfBuffer, baseClassname)
|
||||
await fs.writeFile(nitroPath, conversion.bundle)
|
||||
|
||||
// Add to FurnitureData.json if not already present
|
||||
try {
|
||||
const { buildFurniEntry, appendFurniEntry, readFurniData } =
|
||||
await import("@/lib/services/furni-data");
|
||||
const { buildFurniEntry, appendFurniEntry, readFurniData } = await import(
|
||||
'@/lib/services/furni-data'
|
||||
)
|
||||
const furniData = (await readFurniData()) as {
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> };
|
||||
};
|
||||
roomitemtypes?: { furnitype?: Array<{ classname?: string }> }
|
||||
wallitemtypes?: { furnitype?: Array<{ classname?: string }> }
|
||||
}
|
||||
|
||||
// Check if already in FurnitureData
|
||||
const allEntries = [
|
||||
...(furniData.roomitemtypes?.furnitype || []),
|
||||
...(furniData.wallitemtypes?.furnitype || []),
|
||||
];
|
||||
]
|
||||
const alreadyInFD = allEntries.some(
|
||||
(e) => e.classname === baseClassname || e.classname === item.classname,
|
||||
);
|
||||
)
|
||||
|
||||
if (!alreadyInFD) {
|
||||
const dbItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: item.classname },
|
||||
select: { id: true, spriteId: true, publicName: true, type: true },
|
||||
});
|
||||
const furnidata = await getHabboItFurnidata();
|
||||
const fd = furnidata.get(item.classname) || furnidata.get(baseClassname);
|
||||
const itemType = dbItem?.type === "i" ? "i" : "s";
|
||||
})
|
||||
const furnidata = await getHabboItFurnidata()
|
||||
const fd = furnidata.get(item.classname) || furnidata.get(baseClassname)
|
||||
const itemType = dbItem?.type === 'i' ? 'i' : 's'
|
||||
|
||||
const entry = buildFurniEntry({
|
||||
id: dbItem?.spriteId || 0,
|
||||
classname: baseClassname,
|
||||
revision: rev,
|
||||
category: fd?.category || "",
|
||||
category: fd?.category || '',
|
||||
name: dbItem?.publicName || baseClassname,
|
||||
description: fd?.description || "",
|
||||
description: fd?.description || '',
|
||||
spriteId: dbItem?.spriteId || 0,
|
||||
dims: conversion.dimensions || { x: 1, y: 1, z: 1 },
|
||||
metadata: conversion.metadata || null,
|
||||
habboIt: fd || null,
|
||||
});
|
||||
await appendFurniEntry(entry, itemType);
|
||||
})
|
||||
await appendFurniEntry(entry, itemType)
|
||||
}
|
||||
} catch (fdErr) {
|
||||
console.warn(`[regen] FurnitureData update skipped for ${item.classname}:`, (fdErr as Error).message);
|
||||
console.warn(
|
||||
`[regen] FurnitureData update skipped for ${item.classname}:`,
|
||||
(fdErr as Error).message,
|
||||
)
|
||||
}
|
||||
|
||||
results.push({ classname: item.classname, ok: true });
|
||||
results.push({ classname: item.classname, ok: true })
|
||||
} catch (err) {
|
||||
results.push({ classname: item.classname, ok: false, warning: (err as Error).message });
|
||||
results.push({ classname: item.classname, ok: false, warning: (err as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.ok).length;
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
const succeeded = results.filter((r) => r.ok).length
|
||||
const failed = results.filter((r) => !r.ok).length
|
||||
|
||||
return apiOk({ succeeded, failed, results });
|
||||
});
|
||||
return apiOk({ succeeded, failed, results })
|
||||
})
|
||||
|
||||
// ── Re-classify ALL imported items across all sub-pages + update prices (PUT) ──
|
||||
// Supports ?dryrun=1 to preview changes without applying them.
|
||||
export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => {
|
||||
const isDryRun = request.nextUrl.searchParams.get("dryrun") === "1";
|
||||
const isDryRun = request.nextUrl.searchParams.get('dryrun') === '1'
|
||||
|
||||
// 1. Find the root "imported_furni" page
|
||||
const parentPage = await prisma.catalogPages.findFirst({
|
||||
where: { captionSave: IMPORTED_PAGE_CAPTION_SAVE },
|
||||
select: { id: true },
|
||||
});
|
||||
})
|
||||
if (!parentPage) {
|
||||
return apiOk({
|
||||
dryRun: isDryRun,
|
||||
moved: 0,
|
||||
repriced: 0,
|
||||
message: "No imported_furni page found — nothing to reorganize",
|
||||
});
|
||||
message: 'No imported_furni page found — nothing to reorganize',
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Collect ALL imported page IDs (parent + every imp_* sub-page)
|
||||
const subPages = await prisma.catalogPages.findMany({
|
||||
where: { parentId: parentPage.id },
|
||||
select: { id: true, captionSave: true },
|
||||
});
|
||||
const allImportedPageIds = [String(parentPage.id), ...subPages.map((p) => String(p.id))];
|
||||
})
|
||||
const allImportedPageIds = [String(parentPage.id), ...subPages.map((p) => String(p.id))]
|
||||
|
||||
// 3. Find ALL catalog_items across all imported pages
|
||||
// page_id is VARCHAR in DB so we use string placeholders
|
||||
const inPlaceholders = allImportedPageIds.map(() => "?").join(",");
|
||||
const inPlaceholders = allImportedPageIds.map(() => '?').join(',')
|
||||
const allItems = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
id: number;
|
||||
item_ids: string;
|
||||
catalog_name: string;
|
||||
page_id: string;
|
||||
cost_credits: number;
|
||||
id: number
|
||||
item_ids: string
|
||||
catalog_name: string
|
||||
page_id: string
|
||||
cost_credits: number
|
||||
}>
|
||||
>(
|
||||
`SELECT id, item_ids, catalog_name, page_id, cost_credits FROM catalog_items WHERE page_id IN (${inPlaceholders})`,
|
||||
...allImportedPageIds,
|
||||
);
|
||||
)
|
||||
|
||||
if (allItems.length === 0) {
|
||||
return apiOk({
|
||||
dryRun: isDryRun,
|
||||
moved: 0,
|
||||
repriced: 0,
|
||||
message: "No imported items found — nothing to reorganize",
|
||||
});
|
||||
message: 'No imported items found — nothing to reorganize',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Build classname → type map from items_base
|
||||
const uniqueItemIds = [...new Set(allItems.map((i) => Number(i.item_ids)))].filter((n) => !Number.isNaN(n));
|
||||
const uniqueItemIds = [...new Set(allItems.map((i) => Number(i.item_ids)))].filter(
|
||||
(n) => !Number.isNaN(n),
|
||||
)
|
||||
const dbItems =
|
||||
uniqueItemIds.length > 0
|
||||
? await prisma.itemsBase.findMany({
|
||||
where: { id: { in: uniqueItemIds } },
|
||||
select: { id: true, itemName: true, type: true },
|
||||
})
|
||||
: [];
|
||||
const typeMap = new Map<string, string>();
|
||||
: []
|
||||
const typeMap = new Map<string, string>()
|
||||
for (const di of dbItems) {
|
||||
if (di.itemName) typeMap.set(di.itemName, di.type || "s");
|
||||
if (di.itemName) typeMap.set(di.itemName, di.type || 's')
|
||||
}
|
||||
|
||||
// 5. Pre-load category page map (captionSave → pageId) to avoid N+1 queries
|
||||
const categoryPageMap = new Map<string, number>();
|
||||
const categoryPageMap = new Map<string, number>()
|
||||
for (const sp of subPages) {
|
||||
if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id);
|
||||
if (sp.captionSave) categoryPageMap.set(sp.captionSave, sp.id)
|
||||
}
|
||||
|
||||
// Helper: resolve or create category page using the cached map
|
||||
async function resolvePageId(catKey: string): Promise<string> {
|
||||
const captionSave = `imp_${catKey}`;
|
||||
const cached = categoryPageMap.get(captionSave);
|
||||
if (cached !== undefined) return String(cached);
|
||||
const captionSave = `imp_${catKey}`
|
||||
const cached = categoryPageMap.get(captionSave)
|
||||
if (cached !== undefined) return String(cached)
|
||||
|
||||
if (isDryRun) return "new";
|
||||
if (isDryRun) return 'new'
|
||||
|
||||
// Create the page (only happens once per new category)
|
||||
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other;
|
||||
const parentId = parentPage!.id;
|
||||
const catInfo = CATEGORY_PAGE[catKey] || CATEGORY_PAGE.other
|
||||
const parentId = parentPage!.id
|
||||
await prisma.$executeRaw`
|
||||
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}, ${catInfo.label}, 'default_3x3', ${parentId}, '1', ${catInfo.order}, ${catInfo.icon}, '1', '1', '', '', '', '', '', '', '', '')
|
||||
`;
|
||||
`
|
||||
const created = await prisma.catalogPages.findFirst({
|
||||
where: { captionSave, parentId },
|
||||
select: { id: true },
|
||||
});
|
||||
categoryPageMap.set(captionSave, created!.id);
|
||||
return String(created!.id);
|
||||
})
|
||||
categoryPageMap.set(captionSave, created!.id)
|
||||
return String(created!.id)
|
||||
}
|
||||
|
||||
// 6. Classify all items (pure computation + cached lookups, no per-item DB queries)
|
||||
let moved = 0;
|
||||
let repriced = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
const categoryCounts: Record<string, number> = {};
|
||||
const pagesWithItems = new Set<string>();
|
||||
let moved = 0
|
||||
let repriced = 0
|
||||
let skipped = 0
|
||||
const errors: string[] = []
|
||||
const categoryCounts: Record<string, number> = {}
|
||||
const pagesWithItems = new Set<string>()
|
||||
|
||||
// Collect batched updates: key = "pageId|credits|points|pointsType" → item ids
|
||||
const updateBatches = new Map<string, number[]>();
|
||||
const updateBatches = new Map<string, number[]>()
|
||||
|
||||
// First pass: resolve any new category pages (max ~14 DB inserts)
|
||||
const neededCategories = new Set<string>();
|
||||
const neededCategories = new Set<string>()
|
||||
for (const item of allItems) {
|
||||
const itemType = typeMap.get(item.catalog_name) || "s";
|
||||
neededCategories.add(classifyFurni(item.catalog_name, itemType));
|
||||
const itemType = typeMap.get(item.catalog_name) || 's'
|
||||
neededCategories.add(classifyFurni(item.catalog_name, itemType))
|
||||
}
|
||||
for (const catKey of neededCategories) {
|
||||
try {
|
||||
await resolvePageId(catKey);
|
||||
await resolvePageId(catKey)
|
||||
} catch (err) {
|
||||
errors.push(`create page ${catKey}: ${(err as Error).message}`);
|
||||
errors.push(`create page ${catKey}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: classify items (no DB calls)
|
||||
for (const item of allItems) {
|
||||
const itemType = typeMap.get(item.catalog_name) || "s";
|
||||
const catKey = classifyFurni(item.catalog_name, itemType);
|
||||
categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1;
|
||||
const itemType = typeMap.get(item.catalog_name) || 's'
|
||||
const catKey = classifyFurni(item.catalog_name, itemType)
|
||||
categoryCounts[catKey] = (categoryCounts[catKey] || 0) + 1
|
||||
|
||||
const captionSave = `imp_${catKey}`;
|
||||
const cachedPageId = categoryPageMap.get(captionSave);
|
||||
const correctPageIdStr = cachedPageId !== undefined ? String(cachedPageId) : "new";
|
||||
const captionSave = `imp_${catKey}`
|
||||
const cachedPageId = categoryPageMap.get(captionSave)
|
||||
const correctPageIdStr = cachedPageId !== undefined ? String(cachedPageId) : 'new'
|
||||
|
||||
const price = autoPriceFurni(item.catalog_name);
|
||||
const needsMove = item.page_id !== correctPageIdStr;
|
||||
const needsReprice = item.cost_credits !== price.credits;
|
||||
const price = autoPriceFurni(item.catalog_name)
|
||||
const needsMove = item.page_id !== correctPageIdStr
|
||||
const needsReprice = item.cost_credits !== price.credits
|
||||
|
||||
pagesWithItems.add(correctPageIdStr);
|
||||
pagesWithItems.add(correctPageIdStr)
|
||||
|
||||
if (!needsMove && !needsReprice) {
|
||||
skipped++;
|
||||
continue;
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (needsMove) moved++;
|
||||
if (needsReprice) repriced++;
|
||||
if (needsMove) moved++
|
||||
if (needsReprice) repriced++
|
||||
|
||||
if (!isDryRun) {
|
||||
const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`;
|
||||
const batch = updateBatches.get(batchKey);
|
||||
const batchKey = `${correctPageIdStr}|${price.credits}|${price.points}|${price.pointsType}`
|
||||
const batch = updateBatches.get(batchKey)
|
||||
if (batch) {
|
||||
batch.push(item.id);
|
||||
batch.push(item.id)
|
||||
} else {
|
||||
updateBatches.set(batchKey, [item.id]);
|
||||
updateBatches.set(batchKey, [item.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -624,9 +634,9 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
// 7. Apply batched updates (one UPDATE per category/price combo instead of per item)
|
||||
if (!isDryRun) {
|
||||
for (const [batchKey, ids] of updateBatches) {
|
||||
const [pageId, credits, points, pointsType] = batchKey.split("|");
|
||||
const [pageId, credits, points, pointsType] = batchKey.split('|')
|
||||
try {
|
||||
const idPlaceholders = ids.map(() => "?").join(",");
|
||||
const idPlaceholders = ids.map(() => '?').join(',')
|
||||
await prisma.$queryRawUnsafe(
|
||||
`UPDATE catalog_items SET page_id = ?, cost_credits = ?, cost_points = ?, points_type = ? WHERE id IN (${idPlaceholders})`,
|
||||
pageId,
|
||||
@@ -634,31 +644,31 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
Number(points),
|
||||
Number(pointsType),
|
||||
...ids,
|
||||
);
|
||||
)
|
||||
} catch (err) {
|
||||
errors.push(`batch update: ${(err as Error).message}`);
|
||||
errors.push(`batch update: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Cleanup empty sub-pages (only if NOT dry-run)
|
||||
let pagesDeleted = 0;
|
||||
let pagesDeleted = 0
|
||||
if (!isDryRun) {
|
||||
for (const subPage of subPages) {
|
||||
const subPageIdStr = String(subPage.id);
|
||||
const subPageIdStr = String(subPage.id)
|
||||
if (!pagesWithItems.has(subPageIdStr)) {
|
||||
try {
|
||||
const remaining = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
|
||||
`SELECT COUNT(*) as cnt FROM catalog_items WHERE page_id = ?`,
|
||||
subPageIdStr,
|
||||
);
|
||||
const count = Number(remaining[0]?.cnt ?? 0);
|
||||
)
|
||||
const count = Number(remaining[0]?.cnt ?? 0)
|
||||
if (count === 0) {
|
||||
await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`;
|
||||
pagesDeleted++;
|
||||
await prisma.$executeRaw`DELETE FROM catalog_pages WHERE id = ${subPage.id}`
|
||||
pagesDeleted++
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`cleanup ${subPage.captionSave}: ${(err as Error).message}`);
|
||||
errors.push(`cleanup ${subPage.captionSave}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -667,17 +677,17 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
// 9. Refresh game server caches (only if NOT dry-run and changes were made)
|
||||
if (!isDryRun && (moved > 0 || repriced > 0 || pagesDeleted > 0)) {
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
await rcon.updateCatalog()
|
||||
} catch (err) {
|
||||
errors.push(`RCON: ${(err as Error).message}`);
|
||||
errors.push(`RCON: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "furni_reorganize",
|
||||
target: "CatalogItems",
|
||||
action: 'furni_reorganize',
|
||||
target: 'CatalogItems',
|
||||
targetId: parentPage.id,
|
||||
after: {
|
||||
moved,
|
||||
@@ -687,7 +697,7 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
categoryCounts,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return apiOk({
|
||||
@@ -699,5 +709,5 @@ export const PUT = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async (request
|
||||
total: allItems.length,
|
||||
categoryCounts,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user