feat: add .nitro upload + sync-all + repair-icons features
Deploy / release (push) Skipped
Deploy / deploy (push) Failing after 32s

- Upload .nitro bundles directly with full DB, catalog, and furnidata integration
- Generate SQL migration files on upload (optional)
- Auto-sync missing furniture from all configured clone sources (SSE batch)
- Repair missing icons by extracting from local .nitro or downloading from sources
- All behind ASSETS_IMPORT permission
This commit is contained in:
openhands committed 2026-07-21 14:53:26 +02:00
1 parent c9144dc293
commit 804daeffca
13 files changed
+1484 -1

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import { listSources } from "@/lib/services/clone-sources";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { downloadFile } from "@/lib/services/import/core/download";
import { ensureDirectories } from "./furni-import";
export interface RepairEvent {
type: "item_progress" | "batch_complete" | "error";
classname?: string;
status?: string;
index?: number;
message?: string;
warnings?: string[];
succeeded?: number;
failed?: number;
duration?: number;
}
export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void,
): Promise<{ succeeded: number; failed: number }> {
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const items = await prisma.$queryRaw<
Array<{ id: number; item_name: string }>
>`SELECT id, item_name FROM items_base ORDER BY id`;
if (items.length === 0) {
onEvent?.({
type: "batch_complete",
succeeded: 0,
failed: 0,
duration: 0,
});
return { succeeded: 0, failed: 0 };
}
let succeeded = 0;
let failed = 0;
for (let i = 0; i < items.length; i++) {
const { item_name: classname } = items[i];
const iconPath = path.join(iconDir, `${classname}_icon.png`);
if (existsSync(iconPath)) continue;
onEvent?.({
type: "item_progress",
classname,
status: "started",
index: i,
});
let ok = false;
const warnings: string[] = [];
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
if (existsSync(nitroPath)) {
try {
const nitroBuf = await fs.readFile(nitroPath);
const icon = extractFurniIconPng(nitroBuf);
if (icon) {
await fs.writeFile(iconPath, icon);
ok = true;
} else {
warnings.push("no icon asset in .nitro bundle");
}
} catch (err) {
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
}
} else {
warnings.push(".nitro file not found on disk");
}
if (!ok) {
const sources = await listSources();
for (const source of sources) {
try {
const dl = await downloadFile(
`${source.iconBaseUrl}/${classname}_icon.png`,
iconPath,
{ maxRetries: 1, validate: "png" },
);
if (dl.ok) {
ok = true;
break;
}
} catch {
warnings.push(`download from ${source.name} failed`);
}
}
}
if (ok) {
succeeded++;
onEvent?.({
type: "item_progress",
classname,
status: "done",
index: i,
warnings: warnings.length ? warnings : undefined,
});
} else {
failed++;
onEvent?.({
type: "item_progress",
classname,
status: "failed",
index: i,
message: warnings[0] || "could not obtain icon",
});
}
}
onEvent?.({
type: "batch_complete",
succeeded,
failed,
});
return { succeeded, failed };
}
+307
View File
@@ -0,0 +1,307 @@
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { prisma } from "@/lib/prisma";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
import { appendFurniEntry, buildFurniEntry } from "@/lib/services/furni-data";
import {
allocateCatalogItemId,
autoPriceFurni,
ensureDirectories,
getOrCreateCategoryPage,
} from "@/lib/services/furni-import";
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
export interface UploadResult {
ok: boolean;
itemId?: number;
catalogItemId?: number | null;
classname: string;
warnings: string[];
error?: string;
sqlFile?: string;
}
export interface UploadParams {
classname: string;
name: string;
description?: string;
itemType: "s" | "i";
xdim?: number;
ydim?: number;
canstandon?: boolean;
cansiton?: boolean;
canlayon?: boolean;
interactionType?: string;
customparams?: string;
}
const MIGRATIONS_DIR = path.resolve(process.cwd(), "prisma/migrations");
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
async function allocateItemsBaseId<T>(
insertFn: (nextId: number) => Promise<T>,
): Promise<T> {
const prev = itemsBaseIdAllocChain;
let settle!: () => void;
itemsBaseIdAllocChain = new Promise<void>((r) => {
settle = r;
});
await prev.catch(() => {});
try {
const idRow = await prisma.$queryRaw<Array<{ next: number }>>`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base`;
const nextId = Number(idRow[0]?.next ?? 1);
return await insertFn(nextId);
} finally {
settle();
}
}
function escapeSql(val: string | number): string {
if (typeof val === "number") return String(val);
return `'${val.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
}
async function findNextMigrationNumber(): Promise<number> {
try {
const entries = await fs.readdir(MIGRATIONS_DIR);
let max = 0;
for (const entry of entries) {
const match = entry.match(/^(\d+)_/);
if (match) {
const num = Number.parseInt(match[1], 10);
if (num > max) max = num;
}
}
return max + 1;
} catch {
return 1;
}
}
async function writeSqlMigration(params: {
classname: string;
name: string;
itemType: string;
xdim: number;
ydim: number;
stackHeight: number;
allowStack: string;
cansiton: boolean;
canlayon: boolean;
canstandon: boolean;
interactionType: string;
customparams: string;
price: { credits: number; points: number; pointsType: number };
}): Promise<string> {
const num = await findNextMigrationNumber();
const padded = String(num).padStart(4, "0");
const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_");
const fileName = `${padded}_import_furni_${className}.sql`;
const filePath = path.join(MIGRATIONS_DIR, fileName);
const lines: string[] = [
`-- Migration: ${fileName}`,
`-- Description: Import furniture "${params.classname}"`,
`-- Generated: ${new Date().toISOString()}`,
"",
`INSERT INTO items_base`,
` (id, sprite_id, public_name, item_name, type, width, length, stack_height,`,
` allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)`,
`VALUES`,
` (@next_id, @next_id, ${escapeSql(params.name)}, ${escapeSql(params.classname)},`,
` ${escapeSql(params.itemType)}, ${params.xdim}, ${params.ydim}, ${params.stackHeight},`,
` ${params.allowStack}, ${params.cansiton ? "1" : "0"}, ${params.canlayon ? "1" : "0"}, ${params.canstandon ? "1" : "0"},`,
` ${escapeSql(params.interactionType)}, ${escapeSql(params.customparams)});`,
"",
`SET @next_id = LAST_INSERT_ID();`,
"",
`INSERT INTO catalog_items`,
` (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)`,
`VALUES`,
` (NULL, ${escapeSql("imp_other")}, @next_id, ${escapeSql(params.classname)},`,
` ${params.price.credits}, ${params.price.points}, ${params.price.pointsType}, 1, 1, '-1', '');`,
"",
];
const sql = lines.join("\n");
await fs.mkdir(MIGRATIONS_DIR, { recursive: true });
await fs.writeFile(filePath, sql, "utf-8");
return fileName;
}
export async function uploadSingleFurni(params: {
classname: string;
name: string;
description?: string;
itemType: "s" | "i";
xdim?: number;
ydim?: number;
canstandon?: boolean;
cansiton?: boolean;
canlayon?: boolean;
interactionType?: string;
customparams?: string;
nitroBuffer: Buffer;
iconBuffer?: Buffer | null;
generateSql?: boolean;
}): Promise<UploadResult> {
const {
classname,
name,
description = "",
itemType,
xdim: userXdim,
ydim: userYdim,
canstandon = false,
cansiton = false,
canlayon = false,
interactionType: userInteractionType = "default",
customparams = "",
nitroBuffer,
iconBuffer,
generateSql = false,
} = params;
const warnings: string[] = [];
if (!/^[\w\-.*]+$/.test(classname)) {
return { ok: false, classname, warnings, error: "invalid classname" };
}
const existing = await prisma.$queryRaw<Array<{ id: number }>>`
SELECT id FROM items_base WHERE item_name = ${classname} LIMIT 1`;
if (existing.length > 0) {
return {
ok: false,
classname,
warnings,
error: "Item already exists in database",
};
}
let nitroMeta: Record<string, unknown> | null = null;
try {
const parsed = parseNitroBundle(nitroBuffer);
nitroMeta = parsed.json;
} catch {
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
}
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const nitroFileName = `${classname}.nitro`;
const nitroPath = path.join(nitroDir, nitroFileName);
if (existsSync(nitroPath)) {
return {
ok: false,
classname,
warnings,
error: ".nitro file already exists on disk",
};
}
await fs.writeFile(nitroPath, nitroBuffer);
let iconFileName: string | null = null;
let iconPath: string | null = null;
if (iconBuffer && iconBuffer.length > 0) {
iconFileName = `${classname}_icon.png`;
iconPath = path.join(iconDir, iconFileName);
await fs.writeFile(iconPath, iconBuffer);
}
const xdim = userXdim ?? Number(nitroMeta?.xdim ?? 1);
const ydim = userYdim ?? Number(nitroMeta?.ydim ?? 1);
const stackHeight = canlayon || cansiton ? 1.0 : canstandon ? 1.0 : 0.0;
const allowStack = stackHeight > 0 ? "1" : "0";
const price = autoPriceFurni(classname);
let newId: number;
try {
newId = await allocateItemsBaseId(async (nextId) => {
await prisma.$executeRaw`
INSERT INTO items_base
(id, sprite_id, public_name, item_name, type, width, length, stack_height,
allow_stack, allow_sit, allow_lay, allow_walk, interaction_type, customparams)
VALUES
(${nextId}, ${nextId}, ${name}, ${classname}, ${itemType}, ${xdim}, ${ydim}, ${stackHeight},
${allowStack}, ${cansiton ? "1" : "0"}, ${canlayon ? "1" : "0"}, ${canstandon ? "1" : "0"}, ${userInteractionType}, ${customparams})`;
return nextId;
});
} catch (err) {
await fs.unlink(nitroPath).catch(() => {});
if (iconPath) await fs.unlink(iconPath).catch(() => {});
return {
ok: false,
classname,
warnings,
error: `items_base insert failed: ${(err as Error).message}`,
};
}
try {
const furniEntry = buildFurniEntry({
id: newId,
classname,
revision: 0,
category: "unknown",
name,
description,
spriteId: newId,
dims: { x: xdim, y: ydim, z: stackHeight },
metadata: null,
});
await appendFurniEntry(furniEntry, itemType);
} catch (err) {
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
}
let catalogItemId: number | null = null;
try {
const pageId = await getOrCreateCategoryPage(classname, itemType);
catalogItemId = await allocateCatalogItemId(async (nextCatalogId) => {
await prisma.$executeRaw`
INSERT INTO catalog_items (id, page_id, item_ids, catalog_name, cost_credits, cost_points, points_type, amount, order_number, offer_id, extradata)
VALUES (${nextCatalogId}, ${String(pageId)}, ${String(newId)}, ${classname}, ${price.credits}, ${price.points}, ${price.pointsType}, 1, 1, '-1', '')`;
return nextCatalogId;
});
} catch (err) {
warnings.push(`catalog entry failed: ${(err as Error).message}`);
}
let sqlFile: string | undefined;
if (generateSql) {
try {
sqlFile = await writeSqlMigration({
classname,
name,
itemType,
xdim,
ydim,
stackHeight,
allowStack,
cansiton,
canlayon,
canstandon,
interactionType: userInteractionType,
customparams,
price,
});
warnings.push(`SQL migration written: ${sqlFile}`);
} catch (err) {
warnings.push(`Failed to write SQL migration: ${(err as Error).message}`);
}
}
return {
ok: true,
itemId: newId,
catalogItemId,
classname,
warnings,
sqlFile,
};
}