1
0
Fork 0
polaris-task-force/src/tools/import/importArmaLibrary.ts

251 lines
10 KiB
TypeScript

#!/usr/bin/env bun
/**
* Import an Arma inventory export into the Polaris Task Force Payload CMS.
*
* Consumes a catalog JSON produced by the in-game ptf_export addon (records
* carrying classname, display name, type, description, picture) plus an
* optional icon_map, and upserts rows into the `assets` / `vehicles`
* collections. Thumbnails are materialized from .paa inventory icons found
* under --paa-root (see extractArmaIcons.ts) and converted to PNG uploads via
* `hemtt utils paa convert`; vehicle 3D-rendered screenshots can be supplied
* via --screenshots (matched by classname).
*
* CLI:
* --catalog <file> export catalog JSON (required)
* [--icons <file>] icon_map JSON (classname -> .paa ref)
* [--paa-root <dir>] root mirroring ref paths where .paa thumbnails live
* [--screenshots <dir>] PNG/WebP per-classname 3D-rendered previews
* [--out <dir>] write converted thumbnails + a report here
* [--hemtt <path>] hemtt binary (default: resolve from PATH)
* [--approve] apply to the DB (default: proposal only)
* [--dry-run] plan + print, never touch the DB or filesystem
* [--limit <n>] import at most n records
*/
import { spawnSync } from "child_process";
import { existsSync, mkdirSync, readFileSync } from "fs";
import { dirname, join } from "path";
import { getPayload } from "payload";
import config from "@payload-config";
interface CliOpts {
catalog?: string[];
icons?: string[];
"paa-root"?: string[];
screenshots?: string[];
out?: string[];
hemtt?: string[];
approve?: boolean;
"dry-run"?: boolean;
limit?: string[];
}
function parseCli(argv: string[]): CliOpts {
const opts: CliOpts = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--approve") { opts.approve = true; continue; }
if (a === "--dry-run") { opts["dry-run"] = true; continue; }
if (a.startsWith("--") && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
const key = a.slice(2) as Exclude<keyof CliOpts, "approve" | "dry-run">;
const value = argv[++i];
(opts[key] ??= []).push(value);
}
}
return opts;
}
function resolveHemtt(explicit?: string): string {
if (explicit && existsSync(explicit)) return explicit;
const probe = spawnSync("which", ["hemtt"], { encoding: "utf8" });
if (probe.status === 0 && probe.stdout.trim()) return probe.stdout.trim();
throw new Error("hemtt not found; pass --hemtt <path>");
}
const KNOWN_ASSET_TYPES = new Set([
"weapon", "magazine", "ammo", "backpack", "vest", "helmet", "uniform",
"item", "attachment", "optics", "muzzle", "grip", "rail", "explosive",
"grenade", "medical", "tool",
]);
function classifyAssetType(clazz: string, type: string): string {
const t = (type || "").toLowerCase();
if (KNOWN_ASSET_TYPES.has(t)) return t;
const c = clazz.toLowerCase();
if (c.includes("mag") || c.startsWith("30rnd") || c.startsWith("20rnd")) return "magazine";
if (/^(arifle|smg|hgun|srifle|lm|mmg|rifle|launch)/.test(c)) return "weapon";
if (/^(30|40|45|9|7|5|6|12|mag_|rhs_mag)/.test(c)) return "magazine";
return "item";
}
function isVehicle(clazz: string, type: string): boolean {
if (type && /vehicle|car|tank|uav|ship|heli|truck/.test(type.toLowerCase())) return true;
const c = clazz.toLowerCase();
return /^(b_|o_|i_|c_)(mr|tru|car|heli|tank|uav|boat|apc|lt|soldier)/.test(c) &&
!/backpack|vest|helmet/.test(c);
}
function lookupIcon(icons: Record<string, unknown>, clazz: string): string {
if (icons && typeof icons === "object") {
for (const [k, v] of Object.entries(icons)) {
if (k.toLowerCase() === clazz.toLowerCase() && typeof v === "string") return v;
}
}
return "";
}
function resolvePaaPath(ref: string, paaRoot: string): string {
if (!ref) return "";
const clean = ref.replace(/^[\\/]+/, "").replace(/\\/g, "/");
const cand = join(paaRoot, clean);
return existsSync(cand) ? cand : "";
}
function convertPaaToPng(hemtt: string, paaPath: string, outDir: string): string | undefined {
const base = basename(paaPath).replace(/\.paa$/i, "") + ".png";
const dest = join(outDir, base);
if (existsSync(dest)) return dest;
mkdirSync(outDir, { recursive: true });
const r = spawnSync(hemtt, ["utils", "paa", "convert", paaPath, dest], { encoding: "utf8" });
return r.status === 0 && existsSync(dest) ? dest : undefined;
}
function basename(p: string): string {
const seg = p.split(/[\\/]/);
return seg[seg.length - 1];
}
interface ImportPlan {
kind: "asset" | "vehicle";
clazz: string;
displayName: string;
description: string;
assetType: string;
pictureRef: string;
paaPath: string;
pngPath: string;
screenshotPath: string;
}
function planImport(
rec: Record<string, unknown>,
lookups: { icons: Record<string, unknown>; paaRoot: string; screenshots: string; outDir: string; hemtt: string },
): ImportPlan | undefined {
const clazz = String(rec.classname ?? rec.class ?? "").trim();
const type = String(rec.type ?? rec.category ?? "").trim();
if (!clazz) return undefined;
const vehicle = isVehicle(clazz, type);
const assetType = vehicle ? "vehicle" : classifyAssetType(clazz, type);
const displayName = String(rec.displayName ?? rec.name ?? clazz).trim() || clazz;
const description = String(rec.description ?? "").trim();
const pictureRef = String(rec.picture ?? "").trim();
const iconRef = lookups.icons ? lookupIcon(lookups.icons, clazz) : "";
const ref = iconRef || pictureRef;
const paaPath = ref ? resolvePaaPath(ref, lookups.paaRoot) : "";
const pngPath = paaPath
? (convertPaaToPng(lookups.hemtt, paaPath, join(lookups.outDir)) ?? "")
: "";
const screenshotPath = lookups.screenshots
? (["png", "webp", "jpg"].map((e) => join(lookups.screenshots, clazz + "." + e)).find(existsSync) ?? "")
: "";
return { kind: vehicle ? "vehicle" : "asset", clazz, displayName, description, assetType, pictureRef: ref, paaPath, pngPath, screenshotPath };
}
async function resolveFuelConfig(payload: { find: (args: any) => Promise<{ docs: any[] }> }): Promise<{ id: number }> {
const r = await payload.find({ collection: "resources", limit: 50, where: { name: { equals: "Fuel" } } });
const fuel = r.docs.find((d: any) => String(d.name).toLowerCase() === "fuel");
if (!fuel) throw new Error("No resource named 'Fuel' found — cannot set vehicle fuel fields");
return { id: fuel.id };
}
async function writeEntry(
payload: any,
plan: ImportPlan,
opts: { outDir: string },
fuelId: number,
): Promise<string> {
const common = {
class_name: plan.clazz,
display_name: plan.displayName,
description: plan.description || null,
};
const mediaId = plan.pngPath || plan.screenshotPath ? await uploadMedia(payload, plan.pngPath || plan.screenshotPath).catch(() => undefined) : undefined;
if (plan.kind === "vehicle") {
return payload.create({
collection: "vehicles",
data: {
...common,
vehicleName: plan.displayName,
...(mediaId ? { vehicle_metadata_thumbnail: mediaId } : {}),
fuel: { fuelType: fuelId, fuelCapacity: 100, fuelConsumptionRate: 1 },
},
}).then((d: any) => `vehicle:${d.id}`);
}
return payload.create({
collection: "assets",
data: {
...common,
assetType: plan.assetType,
...(plan.pngPath ? { "Asset Metadata": { mod: "Arma 3", thumbnail: mediaId, gameDescription: plan.description || null } } : {}),
},
}).then((d: any) => `asset:${d.id}`);
}
async function uploadMedia(payload: { create: (args: { collection: string; data: any; file: any }) => Promise<any> }, filePath: string): Promise<number> {
const { readFileSync } = await import("fs");
const fileName = basename(filePath);
const buffer = readFileSync(filePath);
const res = await payload.create({
collection: "media",
data: { alt: fileName, filename: fileName },
file: { data: buffer, mimetype: "image/png", name: fileName, size: buffer.length },
});
return Number(res.id);
}
async function main() {
const opts = parseCli(process.argv.slice(2));
const hemtt = resolveHemtt(opts.hemtt?.[0]);
const catalogPath = opts.catalog?.[0];
if (!catalogPath || !existsSync(catalogPath)) throw new Error("--catalog <file> is required and must exist");
const dryRun = !!opts["dry-run"];
const approve = !!opts.approve || dryRun;
const outDir = opts.out?.[0] ?? dirname(catalogPath);
const paaRoot = opts["paa-root"]?.[0] ?? "";
const screenshots = opts.screenshots?.[0] ?? "";
if (!dryRun && !approve) throw new Error("--approve or --dry-run required to run without a real write");
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
const records = Array.isArray(catalog) ? catalog : [catalog];
let icons: Record<string, unknown> = {};
if (opts.icons?.[0] && existsSync(opts.icons[0])) {
icons = JSON.parse(readFileSync(opts.icons[0], "utf8"));
}
const limit = opts.limit ? parseInt(opts.limit[0], 10) : records.length;
const lookups = { icons, paaRoot, screenshots, outDir, hemtt };
console.log(`Import plan (dry-run=${dryRun}, approve=${approve}):`);
const plans: ImportPlan[] = [];
for (const rec of records.slice(0, limit)) {
const plan = planImport(rec, lookups);
if (!plan) { console.log(` SKIP ${String((rec as any).classname ?? "")} (not an importable record)`); continue; }
plans.push(plan);
const img = plan.pngPath ? "[img: item-png]" : plan.screenshotPath ? "[img: screenshot]" : "[no img]";
console.log(` -> ${plan.kind}\t${plan.clazz}\t${plan.displayName}\t${img}`);
}
if (dryRun) { console.log(`\nDry run: ${plans.length} would import, 0 applied.`); return; }
const payload = await getPayload({ config });
const fuel = await resolveFuelConfig(payload);
let ok = 0, err = 0;
for (const plan of plans) {
try {
const id = await writeEntry(payload, plan, { outDir }, fuel.id);
console.log(`Applied ${id}`);
ok++;
} catch (e) {
console.error(`ERR ${plan.clazz}: ${(e as Error).message}`);
err++;
}
}
console.log(`Done: ${ok} ok, ${err} errors.`);
}
main().catch((e) => { console.error(e); process.exit(1); });