feat: add .nitro upload + sync-all + repair-icons features
- 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:
1 parent
c9144dc293
commit
804daeffca
13 files changed
+1484
-1
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user