#!/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 export catalog JSON (required) * [--icons ] icon_map JSON (classname -> .paa ref) * [--paa-root ] root mirroring ref paths where .paa thumbnails live * [--screenshots ] PNG/WebP per-classname 3D-rendered previews * [--out ] write converted thumbnails + a report here * [--hemtt ] 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 ] 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; 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 "); } 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, 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, lookups: { icons: Record; 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 { 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 }, filePath: string): Promise { 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 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 = {}; 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); });