fix: mirror repaired icons to gamedata
Repair Icons now resolves all furni asset write targets (webroot plus the live gamedata like /var/www/Gamedata) and writes downloaded or extracted icons to every missing location. Icons already present in one target are copied across instead of re-downloaded, and local .nitro bundles are searched in every target.
This commit is contained in:
1 parent
1f9e8a0eec
commit
cde15ad022
1 file changed
+136
-23
@@ -1,4 +1,5 @@
|
|||||||
import { existsSync, promises as fs } from "node:fs";
|
import { existsSync, promises as fs } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
@@ -8,10 +9,61 @@ import {
|
|||||||
DEFAULT_ICON_SOURCES,
|
DEFAULT_ICON_SOURCES,
|
||||||
listSources,
|
listSources,
|
||||||
} from "@/lib/services/clone-sources";
|
} from "@/lib/services/clone-sources";
|
||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import {
|
||||||
|
type FurniAssetWriteTargets,
|
||||||
|
getFurniAssetWriteTargets,
|
||||||
|
} from "@/lib/services/furni-asset-dirs";
|
||||||
import { downloadFile } from "@/lib/services/import/core/download";
|
import { downloadFile } from "@/lib/services/import/core/download";
|
||||||
import { ensureDirectories } from "./furni-import";
|
import { ensureDirectories } from "./furni-import";
|
||||||
|
|
||||||
|
function uniqueDirs(dirs: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const key = process.platform === "win32" ? dir.toLowerCase() : dir;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(dir);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIconToDirs(
|
||||||
|
icon: Buffer,
|
||||||
|
dirs: string[],
|
||||||
|
fileName: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(dir, fileName), icon);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`write icon to ${dir} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyIconAcrossDirs(
|
||||||
|
srcDir: string,
|
||||||
|
destDirs: string[],
|
||||||
|
fileName: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const src = path.join(srcDir, fileName);
|
||||||
|
for (const dir of destDirs) {
|
||||||
|
if (dir === srcDir) continue;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.copyFile(src, path.join(dir, fileName));
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`copy icon to ${dir} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RepairEvent {
|
export interface RepairEvent {
|
||||||
type: "started" | "progress" | "done" | "batch_complete" | "error";
|
type: "started" | "progress" | "done" | "batch_complete" | "error";
|
||||||
classname?: string;
|
classname?: string;
|
||||||
@@ -43,20 +95,29 @@ export async function repairMissingIcons(
|
|||||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let iconDir: string;
|
let targets: FurniAssetWriteTargets;
|
||||||
let nitroDir: string;
|
|
||||||
try {
|
try {
|
||||||
const dirs = await getFurniAssetDirs();
|
targets = await getFurniAssetWriteTargets();
|
||||||
iconDir = dirs.iconDir;
|
|
||||||
nitroDir = dirs.nitroDir;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
onEvent?.({
|
onEvent?.({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: `getFurniAssetDirs failed: ${(err as Error).message}`,
|
message: `getFurniAssetWriteTargets failed: ${(err as Error).message}`,
|
||||||
});
|
});
|
||||||
return { succeeded: 0, failed: 0, skipped: 0 };
|
return { succeeded: 0, failed: 0, skipped: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every location the app mirrors furni assets into — the webroot (public/)
|
||||||
|
// plus the live gamedata (e.g. /var/www/Gamedata). Repair writes to all of
|
||||||
|
// them so both the CMS and the client see the icon.
|
||||||
|
const iconDirs = uniqueDirs([
|
||||||
|
targets.iconDir,
|
||||||
|
...targets.mirrorDirs.map((d) => d.iconDir),
|
||||||
|
]);
|
||||||
|
const nitroDirs = uniqueDirs([
|
||||||
|
targets.nitroDir,
|
||||||
|
...targets.mirrorDirs.map((d) => d.nitroDir),
|
||||||
|
]);
|
||||||
|
|
||||||
let items: Array<{ id: number; item_name: string }>;
|
let items: Array<{ id: number; item_name: string }>;
|
||||||
try {
|
try {
|
||||||
const [rows] = (await db.execute(sql`
|
const [rows] = (await db.execute(sql`
|
||||||
@@ -76,9 +137,16 @@ export async function repairMissingIcons(
|
|||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const { item_name: classname } = items[i];
|
const { item_name: classname } = items[i];
|
||||||
const iconPath = path.join(iconDir, `${classname}_icon.png`);
|
const fileName = `${classname}_icon.png`;
|
||||||
|
|
||||||
if (existsSync(iconPath)) {
|
// A dir may already have the icon (e.g. the full gamedata) while the
|
||||||
|
// webroot is missing it — treat the item as present when every target has it.
|
||||||
|
const presentDirs = iconDirs.filter((dir) =>
|
||||||
|
existsSync(path.join(dir, fileName)),
|
||||||
|
);
|
||||||
|
const missingDirs = iconDirs.filter((dir) => !presentDirs.includes(dir));
|
||||||
|
|
||||||
|
if (missingDirs.length === 0) {
|
||||||
skipped++;
|
skipped++;
|
||||||
onEvent?.({
|
onEvent?.({
|
||||||
type: "progress",
|
type: "progress",
|
||||||
@@ -93,24 +161,55 @@ export async function repairMissingIcons(
|
|||||||
let ok = false;
|
let ok = false;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
const nitroPath = path.join(nitroDir, `${classname}.nitro`);
|
// 1) Copy across mirrors — the icon already exists in one target (e.g.
|
||||||
if (existsSync(nitroPath)) {
|
// gamedata), so just fill the ones that are missing it.
|
||||||
|
if (presentDirs.length > 0) {
|
||||||
|
try {
|
||||||
|
const copyErrors = await copyIconAcrossDirs(
|
||||||
|
presentDirs[0],
|
||||||
|
missingDirs,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
warnings.push(...copyErrors);
|
||||||
|
ok = copyErrors.length === 0;
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(
|
||||||
|
`copy across icon dirs failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Extract from a local .nitro bundle (checked across every target).
|
||||||
|
if (!ok) {
|
||||||
|
let foundNitro = false;
|
||||||
|
for (const dir of nitroDirs) {
|
||||||
|
const nitroPath = path.join(dir, `${classname}.nitro`);
|
||||||
|
if (!existsSync(nitroPath)) continue;
|
||||||
|
foundNitro = true;
|
||||||
try {
|
try {
|
||||||
const nitroBuf = await fs.readFile(nitroPath);
|
const nitroBuf = await fs.readFile(nitroPath);
|
||||||
const icon = extractFurniIconPng(nitroBuf);
|
const icon = extractFurniIconPng(nitroBuf);
|
||||||
if (icon) {
|
if (icon) {
|
||||||
await fs.writeFile(iconPath, icon);
|
const writeErrors = await writeIconToDirs(
|
||||||
ok = true;
|
icon,
|
||||||
} else {
|
missingDirs,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
warnings.push(...writeErrors);
|
||||||
|
ok = writeErrors.length === 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
warnings.push("no icon asset in .nitro bundle");
|
warnings.push("no icon asset in .nitro bundle");
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(`extract from .nitro failed: ${(err as Error).message}`);
|
warnings.push(
|
||||||
|
`extract from .nitro failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
warnings.push(".nitro file not found on disk");
|
if (!foundNitro) warnings.push(".nitro file not found on disk");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Download from configured or default public sources.
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
let sources: CloneSource[] = [];
|
let sources: CloneSource[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -140,11 +239,20 @@ export async function repairMissingIcons(
|
|||||||
try {
|
try {
|
||||||
const dl = await downloadFile(
|
const dl = await downloadFile(
|
||||||
`${source.iconBaseUrl}/${iconName}`,
|
`${source.iconBaseUrl}/${iconName}`,
|
||||||
iconPath,
|
path.join(targets.iconDir, fileName),
|
||||||
{ maxRetries: 1, validate: "png" },
|
{ maxRetries: 1, validate: "png" },
|
||||||
);
|
);
|
||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
ok = true;
|
const icon = await fs.readFile(
|
||||||
|
path.join(targets.iconDir, fileName),
|
||||||
|
);
|
||||||
|
const writeErrors = await writeIconToDirs(
|
||||||
|
icon,
|
||||||
|
missingDirs,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
warnings.push(...writeErrors);
|
||||||
|
ok = writeErrors.length === 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
warnings.push(`${iconName} not found on ${source.name}`);
|
warnings.push(`${iconName} not found on ${source.name}`);
|
||||||
@@ -159,7 +267,7 @@ export async function repairMissingIcons(
|
|||||||
// Some sources only ship icons embedded in their `.nitro` bundles —
|
// Some sources only ship icons embedded in their `.nitro` bundles —
|
||||||
// grab the bundle and extract the catalog icon from it.
|
// grab the bundle and extract the catalog icon from it.
|
||||||
if (source.nitroBaseUrl) {
|
if (source.nitroBaseUrl) {
|
||||||
const nitroTmp = path.join(nitroDir, `.repair_${classname}.nitro`);
|
const nitroTmp = path.join(os.tmpdir(), `.repair_${classname}.nitro`);
|
||||||
try {
|
try {
|
||||||
const dl = await downloadFile(
|
const dl = await downloadFile(
|
||||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||||
@@ -169,8 +277,13 @@ export async function repairMissingIcons(
|
|||||||
if (dl.ok) {
|
if (dl.ok) {
|
||||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||||
if (icon) {
|
if (icon) {
|
||||||
await fs.writeFile(iconPath, icon);
|
const writeErrors = await writeIconToDirs(
|
||||||
ok = true;
|
icon,
|
||||||
|
missingDirs,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
warnings.push(...writeErrors);
|
||||||
|
ok = writeErrors.length === 0;
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`no icon asset in ${source.name} .nitro bundle`);
|
warnings.push(`no icon asset in ${source.name} .nitro bundle`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user