feat(housekeeping): integrate studio operations
This commit is contained in:
1 parent
020c06f172
commit
2e95a106e0
36 files changed
+2296
-60
No files matched your search
@@ -134,13 +134,16 @@ async function fetchWithFallback(url: string): Promise<string> {
|
||||
export async function fetchSourceFurnidata(
|
||||
url: string,
|
||||
now = Date.now(),
|
||||
signal?: AbortSignal,
|
||||
): Promise<SourceFurni[]> {
|
||||
signal?.throwIfAborted();
|
||||
const hit = cache.get(url);
|
||||
if (hit && now && now - hit.ts < TTL) return hit.list;
|
||||
let body: string;
|
||||
try {
|
||||
const timeout = AbortSignal.timeout(30000);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
||||
headers: browserHeaders(),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -159,7 +162,8 @@ export async function fetchSourceFurnidata(
|
||||
} else {
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
body = await fetchWithFallback(url);
|
||||
}
|
||||
if (body.trimStart().startsWith("<")) {
|
||||
@@ -243,11 +247,13 @@ export async function cloneSingleFurni(params: {
|
||||
source: CloneSource;
|
||||
entry: SourceFurni;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Skip the per-item FurnitureData.json write and return the entry instead,
|
||||
* so batch callers can append everything in one lock + write cycle. */
|
||||
deferFurniData?: boolean;
|
||||
}): Promise<CloneResult> {
|
||||
const { source, entry, onProgress, deferFurniData } = params;
|
||||
const { source, entry, onProgress, signal, deferFurniData } = params;
|
||||
signal?.throwIfAborted();
|
||||
const { classname, itemType } = entry;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -287,6 +293,7 @@ export async function cloneSingleFurni(params: {
|
||||
|
||||
// Download .nitro + icon directly from the source hotel.
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
const nitroPath = getRuntimePath(
|
||||
/*turbopackIgnore: true*/ nitroDir,
|
||||
`${classname}.nitro`,
|
||||
@@ -300,6 +307,7 @@ export async function cloneSingleFurni(params: {
|
||||
nitroPath,
|
||||
{
|
||||
maxRetries: 2,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!dl.ok) {
|
||||
@@ -334,6 +342,7 @@ export async function cloneSingleFurni(params: {
|
||||
{
|
||||
maxRetries: 1,
|
||||
validate: "png",
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!iconDl.ok) {
|
||||
@@ -366,6 +375,7 @@ export async function cloneSingleFurni(params: {
|
||||
}
|
||||
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
const stackHeight =
|
||||
entry.canlayon || entry.cansiton ? 1.0 : entry.canstandon ? 1.0 : 0.0;
|
||||
// allow_stack: derived from stackHeight (mirrors furni-import.ts `dims.z > 0`).
|
||||
@@ -422,6 +432,7 @@ export async function cloneSingleFurni(params: {
|
||||
// AND our offerid. The source's offerid points at the source hotel's sprite
|
||||
// ids, which would silently break the emulator's catalog offer lookup.
|
||||
onProgress?.("writing_furnidata");
|
||||
signal?.throwIfAborted();
|
||||
const furniDataEntry = {
|
||||
...entry,
|
||||
id: newId,
|
||||
|
||||
@@ -89,8 +89,10 @@ export async function importClothingSet(params: {
|
||||
setType: string;
|
||||
setId: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportClothingSetResult> {
|
||||
const { setType, setId, onProgress } = params;
|
||||
const { setType, setId, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [officialFd, officialLibs] = await Promise.all([
|
||||
@@ -136,12 +138,17 @@ export async function importClothingSet(params: {
|
||||
let hardFail = false;
|
||||
|
||||
for (const libId of requiredLibIds) {
|
||||
signal?.throwIfAborted();
|
||||
const officialLib = officialLibs.find((l) => l.id === libId);
|
||||
if (!officialLib) continue;
|
||||
|
||||
if (!localLibIds.has(libId)) {
|
||||
onProgress?.(`importing library ${libId}`);
|
||||
const result = await importSingleFigure({ lib: libId, onProgress });
|
||||
const result = await importSingleFigure({
|
||||
lib: libId,
|
||||
onProgress,
|
||||
signal,
|
||||
});
|
||||
if (!result.ok) {
|
||||
hardFail = true;
|
||||
warnings.push(
|
||||
@@ -169,6 +176,7 @@ export async function importClothingSet(params: {
|
||||
}
|
||||
|
||||
const mappedSet = mapOfficialSet(set);
|
||||
signal?.throwIfAborted();
|
||||
await mergeSet(setType, st.paletteId, mappedSet as never);
|
||||
|
||||
if (palette) {
|
||||
|
||||
@@ -76,8 +76,10 @@ export async function importSingleEffect(params: {
|
||||
type: string;
|
||||
revision: number;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportEffectResult> {
|
||||
const { id, lib, type, revision, onProgress } = params;
|
||||
const { id, lib, type, revision, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensureEffectDirs();
|
||||
|
||||
@@ -100,7 +102,10 @@ export async function importSingleEffect(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -111,6 +116,7 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Convert SWF → Nitro
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
@@ -127,6 +133,7 @@ export async function importSingleEffect(params: {
|
||||
|
||||
// Merge EffectMap.json (last step — no orphan entry if anything above failed)
|
||||
onProgress?.("writing_map");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const entry: EffectMapEntry = { id, lib, type, revision };
|
||||
await mergeEffect(entry);
|
||||
|
||||
@@ -78,8 +78,10 @@ async function nitroPathFor(lib: string): Promise<string> {
|
||||
export async function importSingleFigure(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportFigureResult> {
|
||||
const { lib, onProgress } = params;
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensureFigureDirs();
|
||||
|
||||
@@ -99,7 +101,10 @@ export async function importSingleFigure(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -109,6 +114,7 @@ export async function importSingleFigure(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -403,10 +403,13 @@ async function syncDirMissingFiles(
|
||||
* so all imported furniture stays renderable in-game even when a mirror
|
||||
* write was skipped.
|
||||
*/
|
||||
export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
export async function syncAssetsToGamedataBundle(
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
copiedNitros: string[];
|
||||
copiedIcons: string[];
|
||||
}> {
|
||||
signal?.throwIfAborted();
|
||||
if (process.platform === "win32")
|
||||
return { copiedNitros: [], copiedIcons: [] };
|
||||
|
||||
@@ -423,6 +426,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"bundled/furniture",
|
||||
);
|
||||
if (nitroDir !== nitroTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(nitroDir, nitroTarget, (entry) =>
|
||||
entry.endsWith(".nitro"),
|
||||
);
|
||||
@@ -434,6 +438,7 @@ export async function syncAssetsToGamedataBundle(): Promise<{
|
||||
"icons",
|
||||
);
|
||||
if (iconDir !== iconTarget) {
|
||||
signal?.throwIfAborted();
|
||||
const copied = await syncDirMissingFiles(iconDir, iconTarget, (entry) =>
|
||||
entry.endsWith("_icon.png"),
|
||||
);
|
||||
@@ -508,6 +513,7 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite?: boolean;
|
||||
updateExisting?: boolean;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */
|
||||
sourceSwfBaseUrl?: string;
|
||||
/** Per-source nitro bundle + icon base URL. */
|
||||
@@ -525,10 +531,12 @@ export async function importSingleFurni(params: {
|
||||
skipFurniDataWrite,
|
||||
updateExisting,
|
||||
onProgress,
|
||||
signal,
|
||||
sourceSwfBaseUrl,
|
||||
nitroBaseUrl: sourceNitroBaseUrl,
|
||||
iconBaseUrl: sourceIconBaseUrl,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if already exists by classname OR by spriteId (primary key collision).
|
||||
@@ -604,6 +612,7 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Insert into DB with enriched data ──────────────────────────────
|
||||
onProgress?.("writing_db");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
if (isUpdate) {
|
||||
// NOTE: live Arcturus `items_base` has no description/revision/rare columns —
|
||||
@@ -660,6 +669,7 @@ export async function importSingleFurni(params: {
|
||||
// fall back to base-classname icon (Nitro reuses base for all colors
|
||||
// when no color-specific icon exists).
|
||||
onProgress?.("downloading");
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const iconColorAware = classname.replace(/\*/g, "_");
|
||||
|
||||
@@ -701,8 +711,8 @@ export async function importSingleFurni(params: {
|
||||
// Download icon + SWF concurrently (independent assets). Each tries its
|
||||
// candidate URLs in order until one succeeds.
|
||||
const [iconOkResult, swfOk] = await Promise.all([
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png"),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf"),
|
||||
tryDownloadCandidates(iconUrls, iconPath, "png", signal),
|
||||
tryDownloadCandidates(swfUrls, swfPath, "swf", signal),
|
||||
]);
|
||||
let iconOk = iconOkResult;
|
||||
|
||||
@@ -715,7 +725,10 @@ export async function importSingleFurni(params: {
|
||||
!existsSync(/*turbopackIgnore: true*/ nitroPath)
|
||||
) {
|
||||
const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`;
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" });
|
||||
const dl = await downloadFile(nitroUrl, nitroPath, {
|
||||
validate: "png",
|
||||
signal,
|
||||
});
|
||||
if (dl.ok) nitroDownloadOk = true;
|
||||
}
|
||||
|
||||
@@ -744,6 +757,7 @@ export async function importSingleFurni(params: {
|
||||
|
||||
// ── Convert SWF to Nitro ──────────────────────────────────────────
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
let conversionResult: ConversionResult | null = null;
|
||||
|
||||
if (existsSync(/*turbopackIgnore: true*/ nitroPath)) {
|
||||
|
||||
@@ -635,14 +635,21 @@ export interface FixAllResult {
|
||||
|
||||
/** Run every repair in a sensible order, then return before/after health. */
|
||||
export async function fixEverything(
|
||||
opts: { dedupePages?: boolean } = {},
|
||||
opts: { dedupePages?: boolean; signal?: AbortSignal } = {},
|
||||
): Promise<FixAllResult> {
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthBefore = await getFurniHealth();
|
||||
opts.signal?.throwIfAborted();
|
||||
const sprite = await fixSpriteIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const offers = await fixCatalogOffers();
|
||||
opts.signal?.throwIfAborted();
|
||||
const reconcile = await reconcileIds();
|
||||
opts.signal?.throwIfAborted();
|
||||
const dedup = await removeDuplicates(opts.dedupePages ?? true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const align = await forceItemsBaseIdsToFurnidata(true);
|
||||
opts.signal?.throwIfAborted();
|
||||
const healthAfter = await getFurniHealth();
|
||||
return { healthBefore, sprite, offers, reconcile, dedup, align, healthAfter };
|
||||
}
|
||||
@@ -17,7 +17,9 @@ export async function writeBadgeToExternalTexts(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const gamedataRoot = await getGamedataRoot();
|
||||
if (!gamedataRoot) {
|
||||
return {
|
||||
@@ -35,6 +37,7 @@ export async function writeBadgeToExternalTexts(
|
||||
const raw = await fs.readFile(extPath, "utf-8");
|
||||
texts = JSON.parse(raw) as Record<string, string>;
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
// File not present yet (fresh deployment) — start with an empty object.
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(
|
||||
@@ -49,6 +52,7 @@ export async function writeBadgeToExternalTexts(
|
||||
}
|
||||
texts = {};
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (texts[nameKey] !== undefined) {
|
||||
return {
|
||||
@@ -63,9 +67,11 @@ export async function writeBadgeToExternalTexts(
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(extPath), { recursive: true });
|
||||
signal?.throwIfAborted();
|
||||
await fs.writeFile(extPath, JSON.stringify(texts, null, 4), "utf-8");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
console.warn(
|
||||
"[import-badges] Failed to update ExternalTexts.json:",
|
||||
(err as Error).message,
|
||||
@@ -83,7 +89,9 @@ export async function upsertWebsiteBadge(
|
||||
code: string,
|
||||
name: string,
|
||||
description: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const now = new Date();
|
||||
await db
|
||||
.insert(WebsiteBadges)
|
||||
@@ -107,21 +115,28 @@ export async function upsertWebsiteBadge(
|
||||
* Single import path: ExternalTexts.json + WebsiteBadges.
|
||||
* Callers should gate with ASSETS_IMPORT.
|
||||
*/
|
||||
export async function importBadgeSynced(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}): Promise<ImportBadgeResult> {
|
||||
export async function importBadgeSynced(
|
||||
input: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ImportBadgeResult> {
|
||||
signal?.throwIfAborted();
|
||||
const texts = await writeBadgeToExternalTexts(
|
||||
input.code,
|
||||
input.name,
|
||||
input.description,
|
||||
signal,
|
||||
);
|
||||
if (!texts.ok) return texts;
|
||||
|
||||
try {
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description);
|
||||
} catch {
|
||||
signal?.throwIfAborted();
|
||||
await upsertWebsiteBadge(input.code, input.name, input.description, signal);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
return { ok: false, error: "Failed to import badge", status: 500 };
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ export async function tryDownloadCandidates(
|
||||
urls: string[],
|
||||
destPath: string,
|
||||
kind: "swf" | "png",
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
for (const url of urls) {
|
||||
const res = await downloadFile(url, destPath, { validate: kind });
|
||||
signal?.throwIfAborted();
|
||||
const res = await downloadFile(url, destPath, { validate: kind, signal });
|
||||
if (res.ok) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -33,18 +35,26 @@ export async function tryDownloadCandidates(
|
||||
export async function downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
options?: { maxRetries?: number; validate?: "swf" | "png" },
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
validate?: "swf" | "png";
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<{ ok: boolean; size: number }> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
options?.signal?.throwIfAborted();
|
||||
if (attempt > 0) {
|
||||
await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1)));
|
||||
}
|
||||
const timeout = AbortSignal.timeout(15000);
|
||||
const res = await fetch(url, {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
signal: options?.signal
|
||||
? AbortSignal.any([options.signal, timeout])
|
||||
: timeout,
|
||||
headers: browserHeaders("image", {
|
||||
Accept: "image/png,image/*,*/*;q=0.8",
|
||||
}),
|
||||
@@ -82,6 +92,7 @@ export async function downloadFile(
|
||||
await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer);
|
||||
return { ok: true, size: buffer.length };
|
||||
} catch (err) {
|
||||
if (options?.signal?.aborted) throw err;
|
||||
if (attempt === maxRetries) {
|
||||
console.warn(
|
||||
`[import-download] Download error ${url}:`,
|
||||
|
||||
@@ -45,8 +45,10 @@ function nitroPathFor(lib: string): string {
|
||||
export async function importSinglePet(params: {
|
||||
lib: string;
|
||||
onProgress?: (status: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ImportPetResult> {
|
||||
const { lib, onProgress } = params;
|
||||
const { lib, onProgress, signal } = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
await ensurePetDirs();
|
||||
|
||||
@@ -66,7 +68,10 @@ export async function importSinglePet(params: {
|
||||
error: `Could not resolve SWF URL: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
const dl = await downloadFile(swfUrl, swfPath, { validate: "swf" });
|
||||
const dl = await downloadFile(swfUrl, swfPath, {
|
||||
validate: "swf",
|
||||
signal,
|
||||
});
|
||||
if (!dl.ok)
|
||||
return {
|
||||
ok: false,
|
||||
@@ -76,6 +81,7 @@ export async function importSinglePet(params: {
|
||||
};
|
||||
|
||||
onProgress?.("converting");
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const swfBuffer = await fs.readFile(/*turbopackIgnore: true*/ swfPath);
|
||||
const result = convertSwfToNitro(swfBuffer, lib);
|
||||
|
||||
@@ -104,7 +104,9 @@ export interface RepairEvent {
|
||||
|
||||
export async function repairMissingIcons(
|
||||
onEvent?: (evt: RepairEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -204,6 +206,7 @@ export async function repairMissingIcons(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// .png icon files to repair. Skip them.
|
||||
if (dbSkipSet.has(classname)) {
|
||||
@@ -312,7 +315,7 @@ export async function repairMissingIcons(
|
||||
targets.iconDir,
|
||||
fileName,
|
||||
),
|
||||
{ maxRetries: 1, validate: "png" },
|
||||
{ maxRetries: 1, validate: "png", signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = await fs.readFile(
|
||||
@@ -350,7 +353,7 @@ export async function repairMissingIcons(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${classname}.nitro`,
|
||||
nitroTmp,
|
||||
{ maxRetries: 1 },
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
const icon = extractFurniIconPng(await fs.readFile(nitroTmp));
|
||||
@@ -390,6 +393,7 @@ export async function repairMissingIcons(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
|
||||
@@ -106,7 +106,9 @@ export interface RepairNitroEvent {
|
||||
|
||||
export async function repairMissingNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<{ succeeded: number; failed: number; skipped: number }> {
|
||||
options?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -201,6 +203,7 @@ export async function repairMissingNitros(
|
||||
onEvent?.({ type: "started", total });
|
||||
|
||||
const processItem = async (classname: string) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
// Special items (badges, pets, effects, bots, sticky notes) don't have
|
||||
// furni .nitro bundles to repair. Skip them.
|
||||
if (skipSet.has(classname)) {
|
||||
@@ -276,7 +279,7 @@ export async function repairMissingNitros(
|
||||
const dl = await downloadFile(
|
||||
`${source.nitroBaseUrl}/${nitroName}`,
|
||||
tmpPath,
|
||||
{ maxRetries: 1 },
|
||||
{ maxRetries: 1, signal: options?.signal },
|
||||
);
|
||||
if (dl.ok) {
|
||||
// Validate it is a real Nitro bundle before writing it
|
||||
@@ -315,6 +318,7 @@ export async function repairMissingNitros(
|
||||
};
|
||||
|
||||
await runPool(items, REPAIR_CONCURRENCY, async (item, i) => {
|
||||
options?.signal?.throwIfAborted();
|
||||
const classname = item.item_name;
|
||||
const result = await processItem(classname);
|
||||
if (result.status === "skipped") {
|
||||
@@ -384,13 +388,14 @@ export async function repairMissingNitros(
|
||||
*/
|
||||
export async function addSize32AllNitros(
|
||||
onEvent?: (evt: RepairNitroEvent) => void,
|
||||
opts?: { concurrency?: number },
|
||||
opts?: { concurrency?: number; signal?: AbortSignal },
|
||||
): Promise<{
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
existing: number;
|
||||
}> {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
@@ -442,6 +447,7 @@ export async function addSize32AllNitros(
|
||||
[...fileNames],
|
||||
opts?.concurrency ?? 12,
|
||||
async (fileName, i) => {
|
||||
opts?.signal?.throwIfAborted();
|
||||
let buf: Buffer | null = null;
|
||||
for (const dir of nitroDirs) {
|
||||
try {
|
||||
|
||||
@@ -234,6 +234,7 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer: Buffer;
|
||||
iconBuffer?: Buffer | null;
|
||||
generateSql?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<UploadResult> {
|
||||
const {
|
||||
classname,
|
||||
@@ -250,7 +251,9 @@ export async function uploadSingleFurni(params: {
|
||||
nitroBuffer,
|
||||
iconBuffer,
|
||||
generateSql = false,
|
||||
signal,
|
||||
} = params;
|
||||
signal?.throwIfAborted();
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!/^[\w\-.*]+$/.test(classname)) {
|
||||
@@ -280,6 +283,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
signal?.throwIfAborted();
|
||||
const assetTargets = await getFurniAssetWriteTargets();
|
||||
const { iconDir, nitroDir } = assetTargets;
|
||||
|
||||
@@ -297,6 +301,7 @@ export async function uploadSingleFurni(params: {
|
||||
};
|
||||
}
|
||||
await fs.writeFile(nitroPath, nitroBuffer);
|
||||
signal?.throwIfAborted();
|
||||
|
||||
let iconFileName: string | null = null;
|
||||
let iconPath: string | null = null;
|
||||
@@ -307,6 +312,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
const mirroredPaths: string[] = [];
|
||||
signal?.throwIfAborted();
|
||||
await mirrorUploadedAsset(
|
||||
nitroPath,
|
||||
nitroFileName,
|
||||
@@ -346,6 +352,7 @@ export async function uploadSingleFurni(params: {
|
||||
const interactionModesCount = autoInteraction.interactionModesCount;
|
||||
|
||||
let newId: number;
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
newId = await allocateItemsBaseId(async (nextId) => {
|
||||
await db.execute(sql`
|
||||
@@ -372,6 +379,7 @@ export async function uploadSingleFurni(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const furniEntry = buildFurniEntry({
|
||||
id: newId,
|
||||
classname,
|
||||
@@ -390,6 +398,7 @@ export async function uploadSingleFurni(params: {
|
||||
|
||||
let catalogItemId: number | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
// Skip when the item already has a catalog row — re-uploads must never
|
||||
// create a second row for the same item_ids.
|
||||
const [existingCatalog] = (await db.execute(sql`
|
||||
|
||||
Reference in new issue
Block a user