- Remove import.meta.dirname from turbopack config (unnecessary filesystem op) - Add /*turbopackIgnore: true*/ to 3 path.join calls in upload-import.ts that were missing the comment, causing Turbopack to trace the whole project unintentionally
308 lines
8.7 KiB
TypeScript
308 lines
8.7 KiB
TypeScript
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(/*turbopackIgnore: true*/ 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(/*turbopackIgnore: true*/ 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(/*turbopackIgnore: true*/ 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(/*turbopackIgnore: true*/ 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,
|
|
};
|
|
}
|