From 13bd3695cb5caa64150bf0436a5ebdd6e34c376c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 11 Sep 2026 10:22:06 +0200 Subject: [PATCH] feat(studio): recover matching Nitro sources and persist import phases --- docs/cms-upgrade-2026-09.md | 10 ++ e2e/ui/fixtures/furniture-jobs-harness.tsx | 4 + e2e/ui/furniture-jobs.spec.ts | 45 +++++ .../admin/studio/furniture-jobs-query.ts | 12 +- .../admin/studio/furniture-jobs.tsx | 24 +++ src/components/admin/studio/import-review.tsx | 4 +- src/lib/furni/import-job.ts | 18 ++ src/lib/services/clone-import.ts | 8 +- src/lib/services/furni-attachment.ts | 4 +- src/lib/services/furni-import-repair.test.ts | 29 +++- src/lib/services/furni-import.ts | 20 ++- src/lib/services/furni-job-store.test.ts | 33 ++++ src/lib/services/furni-job-store.ts | 11 +- src/lib/services/furni-job-worker.test.ts | 15 +- src/lib/services/furni-job-worker.ts | 38 +++- src/lib/services/furni-sync-item.ts | 13 +- .../furniture-source-recovery.test.ts | 101 +++++++++++ src/lib/services/furniture-source-recovery.ts | 162 ++++++++++++++++++ src/lib/services/swf/nitro-builder.ts | 18 +- .../services/swf/nitro-bundle-limits.test.ts | 12 ++ src/messages/ar.json | 14 ++ src/messages/bg.json | 14 ++ src/messages/cs.json | 14 ++ src/messages/da.json | 14 ++ src/messages/de.json | 14 ++ src/messages/el.json | 14 ++ src/messages/en.json | 14 ++ src/messages/es.json | 14 ++ src/messages/fi.json | 14 ++ src/messages/fr.json | 14 ++ src/messages/hr.json | 14 ++ src/messages/hu.json | 14 ++ src/messages/it.json | 14 ++ src/messages/ja.json | 14 ++ src/messages/nl.json | 14 ++ src/messages/no.json | 14 ++ src/messages/pl.json | 14 ++ src/messages/pt.json | 14 ++ src/messages/ro.json | 14 ++ src/messages/ru.json | 14 ++ src/messages/sk.json | 14 ++ src/messages/sr.json | 14 ++ src/messages/sv.json | 14 ++ src/messages/tr.json | 14 ++ src/messages/uk.json | 14 ++ 45 files changed, 903 insertions(+), 28 deletions(-) create mode 100644 src/lib/services/furniture-source-recovery.test.ts create mode 100644 src/lib/services/furniture-source-recovery.ts create mode 100644 src/lib/services/swf/nitro-bundle-limits.test.ts diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md index 3cdc53b1..82dda276 100644 --- a/docs/cms-upgrade-2026-09.md +++ b/docs/cms-upgrade-2026-09.md @@ -136,3 +136,13 @@ Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biom Additive migrations `0029_admin_table_views.sql` and `0030_profile_privacy.sql` run through the existing deployment migration runner. They create CMS-owned tables and do not change emulator user settings. Keep the existing shared storage volume and background jobs worker for durable imports. Browser verification used real components with controlled data fixtures at 1280 and 390 pixels, including failure and partial-result cases. Production compilation and full lint were checked locally. No production content was created during those checks; real authenticated content and external source availability remain environment-dependent. + +## Original furniture bundle recovery and progress + +Catalog Studio queued imports and repairs now search other enabled Nitro sources when their initial downloads/conversion produce no local bundle. Recovery checks an exact classname, floor/wall type and positive revision against the source furnidata, then validates the bundle filename, internal name and PNG texture before writing it. It does not copy the alternative source's prices, IDs or descriptive metadata. + +The recovery pass checks at most eight eligible sources, excludes the selected source, and has a 20-second network budget with four-second request limits. Catalog downloads are capped at 20 MiB; bundle downloads and attachment decompression are capped at 50 MiB. A bounded catalog cache avoids downloading full furnidata for every item. Blocked or incompatible sources can still require the original bundle to be attached manually. + +Import history displays the current phase and elapsed time, the last phase on failure/interruption, and the source/revision of a recovered bundle. Phases are persisted under the existing worker lease; a retry clears old phase/provenance fields. Synchronization jobs also report their existing importer phases, but their clone-specific asset strategy is unchanged. + +No additional secrets or environment variables are needed. Source definitions remain managed through the existing source configuration. diff --git a/e2e/ui/fixtures/furniture-jobs-harness.tsx b/e2e/ui/fixtures/furniture-jobs-harness.tsx index 40b581ad..9a750678 100644 --- a/e2e/ui/fixtures/furniture-jobs-harness.tsx +++ b/e2e/ui/fixtures/furniture-jobs-harness.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { FurnitureJobHistory } from "@/components/admin/studio/furniture-jobs"; import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs"; export function FurnitureJobsHarness() { @@ -32,6 +33,9 @@ export function FurnitureJobsHarness() { > Previous page + {window.location.search.includes("progress") && ( + + )} {history.page} {completions} {String(history.loading)} diff --git a/e2e/ui/furniture-jobs.spec.ts b/e2e/ui/furniture-jobs.spec.ts index 00899947..29d19b38 100644 --- a/e2e/ui/furniture-jobs.spec.ts +++ b/e2e/ui/furniture-jobs.spec.ts @@ -98,3 +98,48 @@ test("history polling pauses while hidden and refreshes on visibility return", a }); await expect.poll(() => requests).toBe(hiddenCount + 1); }); + +test("history shows the current phase and validated recovery source", async ({ + page, +}) => { + await page.route("**/api/admin/studio/import-jobs?*", (route) => + route.fulfill({ + json: { + ok: true, + jobs: [ + { + ...furnitureJobFixture, + state: "running", + items: [ + { + ...furnitureJobFixture.items[0], + state: "running", + phase: "writing_db", + phaseStartedAt: new Date().toISOString(), + recoveredSource: { + sourceId: "configured", + sourceName: "Configured source", + revision: 42, + }, + }, + ], + }, + ], + nextCursor: null, + }, + }), + ); + await page.goto("/admin/jobs-harness?progress"); + await page.locator("details details summary").click(); + await expect( + page.getByText("Current phase: Saving SQL and catalog"), + ).toBeVisible(); + await expect( + page.getByText(/Bundle recovered from Configured source/), + ).toBeVisible(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + ).toBe(false); +}); diff --git a/src/components/admin/studio/furniture-jobs-query.ts b/src/components/admin/studio/furniture-jobs-query.ts index be23276b..027e05da 100644 --- a/src/components/admin/studio/furniture-jobs-query.ts +++ b/src/components/admin/studio/furniture-jobs-query.ts @@ -20,7 +20,7 @@ import { uuid as zUuid, } from "zod/mini"; import { adminFetch } from "@/lib/admin-fetch"; -import type { ImportJob } from "@/lib/furni/import-job"; +import { IMPORT_PHASES, type ImportJob } from "@/lib/furni/import-job"; const endpoint = "/api/admin/studio/import-jobs"; const historyTimeoutMs = 15_000; @@ -55,6 +55,16 @@ const jobSchema = zLooseObject({ "cancelled", "interrupted", ]), + phase: zOptional(zEnum(IMPORT_PHASES)), + phaseStartedAt: zOptional(zIso.datetime({ offset: true })), + sourceAttempt: zOptional(zString()), + recoveredSource: zOptional( + zObject({ + sourceId: zString(), + sourceName: zString(), + revision: zNumber(), + }), + ), error: zOptional(zString()), warnings: zOptional(zArray(zString())), itemId: zOptional(zNumber()), diff --git a/src/components/admin/studio/furniture-jobs.tsx b/src/components/admin/studio/furniture-jobs.tsx index 0439a2d7..d5cc4880 100644 --- a/src/components/admin/studio/furniture-jobs.tsx +++ b/src/components/admin/studio/furniture-jobs.tsx @@ -15,6 +15,7 @@ export function FurnitureJobHistory({ }) { const t = useTranslations("pages.admin.importHistory"); const format = useFormatter(); + const progressT = useTranslations("pages.admin.importProgress"); const [limits, setLimits] = useState>({}); return (
{item.classname} · {t(item.state)} {item.itemId ? ` · ID ${item.itemId}` : ""} + {item.phase && + (item.state === "running" || + item.state === "failed" || + item.state === "interrupted") && ( +

+ {progressT( + item.state === "running" ? "current" : "last", + { phase: progressT(item.phase) }, + )} + {item.state === "running" && + item.phaseStartedAt && + ` · ${progressT("elapsed", { seconds: Math.max(0, Math.floor((Date.now() - Date.parse(item.phaseStartedAt)) / 1000)) })}`} + {item.sourceAttempt && ` · ${item.sourceAttempt}`} +

+ )} + {item.recoveredSource && ( +

+ {progressT("recovered", { + source: item.recoveredSource.sourceName, + revision: item.recoveredSource.revision, + })} +

+ )} {item.error && (

{item.error} diff --git a/src/components/admin/studio/import-review.tsx b/src/components/admin/studio/import-review.tsx index c1361636..385f1997 100644 --- a/src/components/admin/studio/import-review.tsx +++ b/src/components/admin/studio/import-review.tsx @@ -66,6 +66,7 @@ export function ImportReview({ setUploading(null); } } + const progressT = useTranslations("pages.admin.importProgress"); const inspection = useFurnitureInspection( items.map((item) => item.classname), ); @@ -166,8 +167,7 @@ export function ImportReview({ Source files not found by preliminary check: {check.classname}

- You can continue. The importer will try the original furniture - files and verify them before creating its SQL entry. + {progressT("recoveryHint")}

))} diff --git a/src/lib/furni/import-job.ts b/src/lib/furni/import-job.ts index 1494bab3..8b5c2414 100644 --- a/src/lib/furni/import-job.ts +++ b/src/lib/furni/import-job.ts @@ -1,4 +1,14 @@ import type { SourceFurni } from "@/lib/services/clone-import"; +export const IMPORT_PHASES = [ + "validating", + "downloading", + "converting", + "checking_sources", + "writing_db", + "writing_furnidata", + "translating", +] as const; +export type ImportPhase = (typeof IMPORT_PHASES)[number]; export interface ImportJobItem { id: number; classname: string; @@ -34,6 +44,14 @@ export interface ImportJob { | "failed" | "cancelled" | "interrupted"; + phase?: ImportPhase; + phaseStartedAt?: string; + sourceAttempt?: string; + recoveredSource?: { + sourceId: string; + sourceName: string; + revision: number; + }; error?: string; warnings?: string[]; itemId?: number; diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index 4f8ba421..e163b533 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -247,7 +247,7 @@ export function __resetItemsBaseAllocatorForTests(): void { export async function cloneSingleFurni(params: { source: CloneSource; entry: SourceFurni; - onProgress?: (status: string) => void; + onProgress?: (status: string) => void | Promise; /** 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; @@ -291,7 +291,7 @@ export async function cloneSingleFurni(params: { const { iconDir, nitroDir } = await getFurniAssetDirs(); // Download .nitro + icon directly from the source hotel. - onProgress?.("downloading"); + await onProgress?.("downloading"); const nitroPath = getRuntimePath( /*turbopackIgnore: true*/ nitroDir, `${classname}.nitro`, @@ -370,7 +370,7 @@ export async function cloneSingleFurni(params: { } } - onProgress?.("writing_db"); + await onProgress?.("writing_db"); 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`). @@ -426,7 +426,7 @@ export async function cloneSingleFurni(params: { // FurnitureData entry — reuse the source's furnitype object, but with our id // 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"); + await onProgress?.("writing_furnidata"); const furniDataEntry = { ...entry, id: newId, diff --git a/src/lib/services/furni-attachment.ts b/src/lib/services/furni-attachment.ts index 11bb7e31..837f6be8 100644 --- a/src/lib/services/furni-attachment.ts +++ b/src/lib/services/furni-attachment.ts @@ -6,7 +6,9 @@ import { importRoot, validJobId } from "./furni-job-store"; export function validateFurnitureAttachment(buffer: Buffer, classname: string) { if (buffer.length > 50 * 1024 * 1024) throw Error("Maximum file size is 50 MB"); - const parsed = parseNitroBundle(buffer); + const parsed = parseNitroBundle(buffer, { + maxOutputLength: 50 * 1024 * 1024, + }); const base = classname.split("*")[0]; if ( parsed.jsonFileName !== `${base}.json` || diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index 5f6064b7..5c24a05d 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -4,6 +4,8 @@ import { MySqlDialect } from "drizzle-orm/mysql-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + nitroExists: true, + write: vi.fn(), official: vi.fn(), download: vi.fn(), execute: vi.fn(), @@ -24,9 +26,10 @@ vi.mock("@/lib/db", () => ({ CatalogPages: {}, })); vi.mock("node:fs", () => ({ - existsSync: () => true, + existsSync: (path: string) => !path.endsWith(".nitro") || mocks.nitroExists, promises: { stat: mocks.stat, + writeFile: mocks.write, readFile: vi.fn().mockResolvedValue(Buffer.alloc(256)), }, })); @@ -57,6 +60,9 @@ vi.mock("@/lib/services/import/core/download", () => ({ vi.mock("@/lib/services/swf-to-nitro", () => ({ parseNitroBundle: () => ({ json: {} }), extractIconFromSwfAsync: async () => null, + convertSwfToNitroAsync: async () => { + throw Error("Source conversion failed"); + }, })); import { importSingleFurni } from "./furni-import"; @@ -78,6 +84,10 @@ function queries() { describe("completion of existing furniture", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.nitroExists = true; + mocks.write.mockImplementation(async () => { + mocks.nitroExists = true; + }); mocks.official.mockResolvedValue(null); mocks.download.mockResolvedValue(false); const rows = [ @@ -92,6 +102,23 @@ describe("completion of existing furniture", () => { mocks.execute.mockResolvedValue([[{ id: 123, page_id: "15" }], []]); mocks.build.mockImplementation((value) => value); }); + it("recovers missing Nitro before completing the existing item without changing its IDs", async () => { + mocks.nitroExists = false; + const recover = vi.fn(async () => Buffer.alloc(256)); + const result = await importSingleFurni({ + ...input, + resolveMissingNitro: recover, + }); + expect(recover).toHaveBeenCalledWith(1); + expect(mocks.write).toHaveBeenCalledWith( + expect.stringContaining("recycler_scrapbookA.nitro"), + expect.any(Buffer), + { flag: "wx" }, + ); + expect(result.ok).toBe(true); + expect(result.itemId).toBe(2000037257); + }); + it("reuses local item and sprite IDs without rewriting SQL settings or offers", async () => { const result = await importSingleFurni(input); expect(result.ok).toBe(true); diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index b2dcaa08..9104e534 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -517,7 +517,8 @@ export async function importSingleFurni(params: { preserveAssets?: boolean; /** Validated matching bundle supplied by a queued import. */ providedNitro?: Buffer; - onProgress?: (status: string) => void; + onProgress?: (status: string) => void | Promise; + resolveMissingNitro?: (revision: number) => Promise; /** Per-source SWF download base URL (e.g. "https://virtualc.nl/dcr"). */ sourceSwfBaseUrl?: string; /** Per-source nitro bundle + icon base URL. */ @@ -660,7 +661,7 @@ export async function importSingleFurni(params: { // For color variants (classname has `*`): try color-specific first, // fall back to base-classname icon (Nitro reuses base for all colors // when no color-specific icon exists). - onProgress?.("downloading"); + await onProgress?.("downloading"); const iconColorAware = classname.replace(/\*/g, "_"); @@ -770,7 +771,7 @@ export async function importSingleFurni(params: { warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`); // ── Convert SWF to Nitro ────────────────────────────────────────── - onProgress?.("converting"); + await onProgress?.("converting"); let conversionResult: ConversionResult | null = null; if (existsSync(/*turbopackIgnore: true*/ nitroPath)) { @@ -803,6 +804,16 @@ export async function importSingleFurni(params: { } } + if (!existsSync(nitroPath) && params.resolveMissingNitro) { + await onProgress?.("checking_sources"); + const recovered = await params.resolveMissingNitro(rev); + if (recovered) await fs.writeFile(nitroPath, recovered, { flag: "wx" }); + else + warnings.push( + "No matching bundle was recovered from the checked sources. Review source availability or attach the original .nitro file.", + ); + } + if (!iconOk && existsSync(nitroPath)) { try { const icon = extractFurniIconPng(await fs.readFile(nitroPath)); @@ -847,7 +858,7 @@ export async function importSingleFurni(params: { } // ── Insert into DB with enriched data ────────────────────────────── - onProgress?.("writing_db"); + await onProgress?.("writing_db"); try { if (preservingExisting) { warnings.push( @@ -1032,6 +1043,7 @@ export async function importSingleFurni(params: { // Batch mode: return the entry so the caller writes once at the end } else { try { + await onProgress?.("writing_furnidata"); await appendFurniEntry(furniEntry, itemType); } catch (err) { logger.warn("[import-furni] Failed to update FurnitureData.json", { diff --git a/src/lib/services/furni-job-store.test.ts b/src/lib/services/furni-job-store.test.ts index 7283066e..c2a68d28 100644 --- a/src/lib/services/furni-job-store.test.ts +++ b/src/lib/services/furni-job-store.test.ts @@ -242,3 +242,36 @@ it("attaches recovery files only to safe failed items and preserves retry histor s.retry(original.id, 4, { chair: randomUUID() }), ).rejects.toThrow("latest retry"); }); + +it("clears old source provenance and phase when creating a retry", async () => { + const s = await store(); + const original: ImportJob = { + ...job(), + state: "completed", + items: [ + { + id: 1, + classname: "chair", + name: "Chair", + description: "", + type: "flooritem", + revision: 1, + category: "other", + state: "failed", + phase: "converting", + phaseStartedAt: new Date().toISOString(), + sourceAttempt: "Old source", + recoveredSource: { + sourceId: "old", + sourceName: "Old source", + revision: 1, + }, + }, + ], + }; + await s.create(original); + const child = await s.retry(original.id, 4); + expect(child?.items[0].phase).toBeUndefined(); + expect(child?.items[0].recoveredSource).toBeUndefined(); + expect(child?.items[0].sourceAttempt).toBeUndefined(); +}); diff --git a/src/lib/services/furni-job-store.ts b/src/lib/services/furni-job-store.ts index addab578..e9eacca0 100644 --- a/src/lib/services/furni-job-store.ts +++ b/src/lib/services/furni-job-store.ts @@ -111,7 +111,16 @@ export class ImportJobStore { state: "queued", cancelRequested: false, items: items.map( - ({ error: _error, warnings: _warnings, itemId: _itemId, ...item }) => ({ + ({ + error: _error, + warnings: _warnings, + itemId: _itemId, + phase: _phase, + phaseStartedAt: _phaseStartedAt, + sourceAttempt: _sourceAttempt, + recoveredSource: _recoveredSource, + ...item + }) => ({ ...item, ...(Object.hasOwn(attachments, item.classname) ? { attachmentId: attachments[item.classname] } diff --git a/src/lib/services/furni-job-worker.test.ts b/src/lib/services/furni-job-worker.test.ts index a705d819..75fef4ad 100644 --- a/src/lib/services/furni-job-worker.test.ts +++ b/src/lib/services/furni-job-worker.test.ts @@ -221,7 +221,7 @@ it("does not save a translated outcome after ownership is lost during translatio }); await drainFurnitureImports(); expect(mocks.translate).toHaveBeenCalledOnce(); - expect(snapshots).toHaveLength(2); + expect(snapshots).toHaveLength(3); expect(snapshots.at(-1)?.items[0].state).toBe("running"); expect(mocks.updateCatalog).not.toHaveBeenCalled(); }); @@ -249,3 +249,16 @@ it("keeps successful imports complete if their audit write fails", async () => { expect(job.items[0].state).toBe("done"); expect(job.items[0].warnings).toContain("Audit record could not be saved"); }); + +it("persists progress before continuing an import and retains the failed phase", async () => { + mocks.import.mockImplementationOnce(async (params) => { + await params.onProgress("downloading"); + expect(job.items[0].phase).toBe("downloading"); + expect(mocks.save).toHaveBeenCalled(); + await params.onProgress("converting"); + throw Error("Conversion failed"); + }); + await drainFurnitureImports(); + expect(job.items[0]).toMatchObject({ state: "failed", phase: "converting" }); + expect(job.items[0].phaseStartedAt).toBeTruthy(); +}); diff --git a/src/lib/services/furni-job-worker.ts b/src/lib/services/furni-job-worker.ts index 438c9ae8..1c95d404 100644 --- a/src/lib/services/furni-job-worker.ts +++ b/src/lib/services/furni-job-worker.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { createStore, runWithStore } from "@/lib/foundation/request-context"; import type { IpAddress, RequestId, UserId } from "@/lib/foundation/types"; -import type { ImportJob } from "@/lib/furni/import-job"; +import { IMPORT_PHASES, type ImportJob } from "@/lib/furni/import-job"; import { redis } from "@/lib/redis"; import { logServerError } from "@/lib/server-log"; import { logAudit } from "./audit"; @@ -14,6 +14,7 @@ import { ensureDirectories, importSingleFurni } from "./furni-import"; import { ImportJobStore } from "./furni-job-store"; import { runSyncJobItem } from "./furni-sync-item"; import { repairFurniture } from "./furniture-repair"; +import { recoverFurnitureNitro } from "./furniture-source-recovery"; import { rcon } from "./rcon"; const LOCK = "furniture-import-worker:v1"; @@ -120,7 +121,15 @@ async function drain() { break; } item.state = "running"; - await saveOwned(job); + const onProgress = async (status: string) => { + const phase = IMPORT_PHASES.find((value) => value === status); + if (!phase) return; + if (item.phase !== phase) + item.phaseStartedAt = new Date().toISOString(); + item.phase = phase; + await saveOwned(job); + }; + await onProgress("validating"); try { if (job.sourceId && !source) throw Error("Import source no longer exists"); @@ -133,12 +142,32 @@ async function drain() { : undefined; await requireLease(); const result = job.syncKind - ? await runSyncJobItem(item) + ? await runSyncJobItem(item, onProgress) : await (job.mode === "repair" ? repairFurniture : importSingleFurni)({ ...item, repairExisting: true, + onProgress, + resolveMissingNitro: async (revision) => { + const recovered = await recoverFurnitureNitro( + { ...item, revision }, + { + excludeSourceId: job.sourceId, + onAttempt: async (name) => { + item.sourceAttempt = name; + await onProgress("checking_sources"); + }, + }, + ); + await requireLease(); + if (!recovered) return undefined; + const { buffer, ...provenance } = recovered; + item.recoveredSource = provenance; + item.sourceAttempt = undefined; + await saveOwned(job); + return buffer; + }, providedNitro, sourceSwfBaseUrl: source?.sourceSwfBaseUrl, nitroBaseUrl: source?.nitroBaseUrl, @@ -148,7 +177,6 @@ async function drain() { item.warnings = result.warnings; item.itemId = result.itemId; if (!result.ok) throw Error(result.error || "Import failed"); - item.state = "done"; await logAudit({ userId: job.userId, action: "furni_import", @@ -171,12 +199,14 @@ async function drain() { }); if (job.translate && job.mode !== "repair") try { + await onProgress("translating"); await patchLocalizedFurniDataEntries([item], true, job.langs); } catch { item.warnings.push( "Translation failed; furniture imported successfully", ); } + item.state = "done"; } catch (error) { if (error instanceof ImportLeaseLostError) throw error; await requireLease(); diff --git a/src/lib/services/furni-sync-item.ts b/src/lib/services/furni-sync-item.ts index 028db9b2..09b65ec6 100644 --- a/src/lib/services/furni-sync-item.ts +++ b/src/lib/services/furni-sync-item.ts @@ -6,7 +6,10 @@ import { getSource } from "./clone-sources"; import { importSingleFurni } from "./furni-import"; /** Called only inside the shared import lease and catalog export lock. */ -export async function runSyncJobItem(item: ImportJobItem) { +export async function runSyncJobItem( + item: ImportJobItem, + onProgress?: (status: string) => void | Promise, +) { const [existing] = await db .select({ id: ItemsBase.id }) .from(ItemsBase) @@ -22,7 +25,11 @@ export async function runSyncJobItem(item: ImportJobItem) { const source = await getSource(item.cloneSourceId); if (!source || !item.cloneEntry) throw Error("Synchronization source no longer available"); - const result = await cloneSingleFurni({ source, entry: item.cloneEntry }); + const result = await cloneSingleFurni({ + source, + entry: item.cloneEntry, + onProgress, + }); const [created] = result.ok ? await db .select({ id: ItemsBase.id }) @@ -32,5 +39,5 @@ export async function runSyncJobItem(item: ImportJobItem) { : []; return { ...result, itemId: created?.id }; } - return importSingleFurni({ ...item, repairExisting: false }); + return importSingleFurni({ ...item, repairExisting: false, onProgress }); } diff --git a/src/lib/services/furniture-source-recovery.test.ts b/src/lib/services/furniture-source-recovery.test.ts new file mode 100644 index 00000000..68ac2ec5 --- /dev/null +++ b/src/lib/services/furniture-source-recovery.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sources: vi.fn(), + validate: vi.fn((buffer: Buffer) => buffer), +})); +vi.mock("./clone-sources", () => ({ listSources: mocks.sources })); +vi.mock("./furni-attachment", () => ({ + validateFurnitureAttachment: mocks.validate, +})); + +import { recoverFurnitureNitro } from "./furniture-source-recovery"; + +const item = { classname: "chair", type: "flooritem", revision: 42 }; +const source = (id: string) => ({ + id, + name: id, + furnidataUrl: `https://${id}.test/data`, + nitroBaseUrl: `https://${id}.test/nitro`, +}); +beforeEach(() => { + vi.clearAllMocks(); + mocks.validate.mockImplementation((buffer) => buffer); +}); +it("skips the selected and disabled sources and rejects a different type or revision", async () => { + mocks.sources.mockResolvedValue([ + source("selected"), + { ...source("disabled"), enabled: false }, + source("wrong"), + source("matching"), + ]); + const fetcher = vi.fn(async (url: string) => + url.endsWith("/data") + ? Response.json({ + roomitemtypes: { + furnitype: [ + { classname: "chair", revision: url.includes("wrong") ? 41 : 42 }, + ], + }, + }) + : new Response("bundle"), + ); + vi.stubGlobal("fetch", fetcher); + const result = await recoverFurnitureNitro(item, { + excludeSourceId: "selected", + }); + expect(result?.sourceId).toBe("matching"); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + "https://wrong.test/data", + "https://matching.test/data", + "https://matching.test/nitro/chair.nitro", + ]); + expect(mocks.validate).toHaveBeenCalledWith(Buffer.from("bundle"), "chair"); +}); +it("rejects bundles with the wrong identity without writing them", async () => { + mocks.sources.mockResolvedValue([source("badbundle")]); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + url.endsWith("/data") + ? Response.json({ + roomitemtypes: { + furnitype: [{ classname: "chair", revision: 42 }], + }, + }) + : new Response("other furniture"), + ), + ); + mocks.validate.mockImplementation(() => { + throw Error("Wrong identity"); + }); + expect(await recoverFurnitureNitro(item)).toBeNull(); +}); +it("does not download a floor bundle for a wall item or a similar classname", async () => { + mocks.sources.mockResolvedValue([source("floor")]); + const fetcher = vi.fn(async () => + Response.json({ + roomitemtypes: { furnitype: [{ classname: "chair", revision: 42 }] }, + }), + ); + vi.stubGlobal("fetch", fetcher); + expect(await recoverFurnitureNitro({ ...item, type: "wallitem" })).toBeNull(); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it("rejects excessive content-length before buffering the bundle", async () => { + mocks.sources.mockResolvedValue([source("oversize")]); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + url.endsWith("/data") + ? Response.json({ + roomitemtypes: { + furnitype: [{ classname: "chair", revision: 42 }], + }, + }) + : new Response("huge", { headers: { "content-length": "1000000000" } }), + ), + ); + expect(await recoverFurnitureNitro(item)).toBeNull(); + expect(mocks.validate).not.toHaveBeenCalled(); +}); diff --git a/src/lib/services/furniture-source-recovery.ts b/src/lib/services/furniture-source-recovery.ts new file mode 100644 index 00000000..80e92a23 --- /dev/null +++ b/src/lib/services/furniture-source-recovery.ts @@ -0,0 +1,162 @@ +import { listSources } from "./clone-sources"; +import { validateFurnitureAttachment } from "./furni-attachment"; +import { browserHeaders } from "./import/core/browser-headers"; + +type Item = { classname: string; type: string; revision: number }; +export interface RecoveredFurnitureSource { + buffer: Buffer; + sourceId: string; + sourceName: string; + revision: number; +} +type CatalogEntry = Pick; +type Catalog = { + roomitemtypes?: { furnitype?: CatalogEntry[] }; + wallitemtypes?: { furnitype?: CatalogEntry[] }; + furnitype?: CatalogEntry[]; +}; +function compactCatalog(raw: Catalog): Catalog { + const entries = (value: unknown): CatalogEntry[] => { + if (!Array.isArray(value)) return []; + return value.slice(0, 100_000).flatMap((entry) => { + if ( + !entry || + typeof entry.classname !== "string" || + entry.classname.length > 256 || + !Number.isSafeInteger(Number(entry.revision)) + ) + return []; + return [{ classname: entry.classname, revision: Number(entry.revision) }]; + }); + }; + + return { + roomitemtypes: { + furnitype: entries(raw?.roomitemtypes?.furnitype ?? raw?.furnitype), + }, + wallitemtypes: { furnitype: entries(raw?.wallitemtypes?.furnitype) }, + }; +} +const cache = new Map(); + +async function boundedDownload( + url: string, + maxBytes: number, + signal: AbortSignal, +) { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) + throw Error("Unsupported source protocol"); + const response = await fetch(url, { signal, headers: browserHeaders() }); + if ( + !response.ok || + Number(response.headers.get("content-length")) > maxBytes || + !response.body + ) { + await response.body?.cancel(); + throw Error("Source unavailable or too large"); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > maxBytes) throw Error("Source exceeds size limit"); + chunks.push(value); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + return Buffer.concat(chunks, size); +} + +/** Only exact classname/type/revision matches; never borrow another furniture's metadata. */ +export async function recoverFurnitureNitro( + item: Item, + options: { + excludeSourceId?: string; + onAttempt?: (sourceName: string) => Promise; + } = {}, +): Promise { + if (!Number.isSafeInteger(item.revision) || item.revision <= 0) return null; + const deadline = Date.now() + 20_000; + const sources = (await listSources()) + .filter( + (source) => + source.enabled !== false && + source.id !== options.excludeSourceId && + source.nitroBaseUrl && + source.furnidataUrl, + ) + .slice(0, 8); + for (const source of sources) { + if (Date.now() >= deadline) break; + // Await outside the catch: a lost worker lease must stop the import. + await options.onAttempt?.(source.name); + try { + const signal = AbortSignal.timeout( + Math.max(1, Math.min(4000, deadline - Date.now())), + ); + let catalog = cache.get(source.furnidataUrl); + if (!catalog || catalog.expires <= Date.now()) { + let data: Catalog | null = null; + try { + data = compactCatalog( + JSON.parse( + ( + await boundedDownload( + source.furnidataUrl, + 20 * 1024 * 1024, + signal, + ) + ).toString("utf8"), + ), + ); + } catch { + /* Try the next configured source. */ + } + if (cache.size >= 20) cache.clear(); + catalog = { + expires: Date.now() + (data ? 300_000 : 60_000), + catalog: data, + }; + cache.set(source.furnidataUrl, catalog); + } + const entries = + item.type === "flooritem" + ? (catalog.catalog?.roomitemtypes?.furnitype ?? + catalog.catalog?.furnitype) + : catalog.catalog?.wallitemtypes?.furnitype; + if ( + !Array.isArray(entries) || + !entries.some( + (entry) => + entry.classname === item.classname && + Number(entry.revision) === item.revision, + ) + ) + continue; + if (Date.now() >= deadline) break; + const base = encodeURIComponent(item.classname.split("*")[0]); + const buffer = await boundedDownload( + `${source.nitroBaseUrl.replace(/\/+$/, "")}/${base}.nitro`, + 50 * 1024 * 1024, + AbortSignal.timeout(Math.max(1, Math.min(4000, deadline - Date.now()))), + ); + validateFurnitureAttachment(buffer, item.classname); + return { + buffer, + sourceId: source.id, + sourceName: source.name, + revision: item.revision, + }; + } catch { + /* Invalid, blocked or missing bundles never become local assets. */ + } + } + return null; +} diff --git a/src/lib/services/swf/nitro-builder.ts b/src/lib/services/swf/nitro-builder.ts index 826f90ee..b57c7836 100644 --- a/src/lib/services/swf/nitro-builder.ts +++ b/src/lib/services/swf/nitro-builder.ts @@ -238,7 +238,10 @@ export function createNitroBundle( // ── .nitro Bundle Parser ────────────────────────────────────────── -export function parseNitroBundle(buffer: Buffer): { +export function parseNitroBundle( + buffer: Buffer, + options?: { maxOutputLength?: number }, +): { json: Record; jsonFileName: string; png: Buffer; @@ -267,7 +270,7 @@ export function parseNitroBundle(buffer: Buffer): { offset += 4; const jsonCompressed = buffer.subarray(offset, offset + jsonCompressedLen); offset += jsonCompressedLen; - const jsonStr = inflateJsonOrGzip(jsonCompressed).toString("utf-8"); + const jsonStr = inflateJsonOrGzip(jsonCompressed, options).toString("utf-8"); const json = JSON.parse(jsonStr); // File 2: PNG @@ -279,7 +282,7 @@ export function parseNitroBundle(buffer: Buffer): { offset += 4; const pngCompressed = buffer.subarray(offset, offset + pngCompressedLen); offset += pngCompressedLen; - const png = inflateJsonOrGzip(pngCompressed); + const png = inflateJsonOrGzip(pngCompressed, options); return { json, jsonFileName, png, pngFileName }; } @@ -289,10 +292,13 @@ export function parseNitroBundle(buffer: Buffer): { * gzip their contents instead of raw-deflate. Try inflate first, then fall * back to gunzip so both formats parse. */ -function inflateJsonOrGzip(input: Buffer): Buffer { +function inflateJsonOrGzip( + input: Buffer, + options?: { maxOutputLength?: number }, +): Buffer { try { - return inflateSync(input); + return inflateSync(input, options); } catch { - return gunzipSync(input); + return gunzipSync(input, options); } } diff --git a/src/lib/services/swf/nitro-bundle-limits.test.ts b/src/lib/services/swf/nitro-bundle-limits.test.ts new file mode 100644 index 00000000..9a16821a --- /dev/null +++ b/src/lib/services/swf/nitro-bundle-limits.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vitest"; +import { createNitroBundle, parseNitroBundle } from "./nitro-builder"; + +it("honors an explicit decompression limit for imported bundles", () => { + const bundle = createNitroBundle( + { name: "chair", value: "a".repeat(4096) }, + Buffer.from("png"), + "chair", + ); + expect(() => parseNitroBundle(bundle, { maxOutputLength: 1024 })).toThrow(); + expect(parseNitroBundle(bundle).json.name).toBe("chair"); +}); diff --git a/src/messages/ar.json b/src/messages/ar.json index c544aed5..71e238ff 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -541,6 +541,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "myDashboard": { diff --git a/src/messages/bg.json b/src/messages/bg.json index b07bf91d..3d85bb13 100644 --- a/src/messages/bg.json +++ b/src/messages/bg.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/cs.json b/src/messages/cs.json index f6bceec2..95c55df7 100644 --- a/src/messages/cs.json +++ b/src/messages/cs.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/da.json b/src/messages/da.json index a3c3c68c..cb94ca7b 100644 --- a/src/messages/da.json +++ b/src/messages/da.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/de.json b/src/messages/de.json index 67717036..f75ad9cd 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -3466,6 +3466,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "radioRequests": { diff --git a/src/messages/el.json b/src/messages/el.json index e547d5a3..4f141c29 100644 --- a/src/messages/el.json +++ b/src/messages/el.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/en.json b/src/messages/en.json index 43b21cf8..88c9b31c 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -4380,6 +4380,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "mod": { diff --git a/src/messages/es.json b/src/messages/es.json index 0905ad32..e8cc4a5b 100644 --- a/src/messages/es.json +++ b/src/messages/es.json @@ -3466,6 +3466,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "radioRequests": { diff --git a/src/messages/fi.json b/src/messages/fi.json index 1a1a81e8..db7fe7c8 100644 --- a/src/messages/fi.json +++ b/src/messages/fi.json @@ -541,6 +541,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "myDashboard": { diff --git a/src/messages/fr.json b/src/messages/fr.json index 677a0040..3ef2e028 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -3466,6 +3466,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "radioRequests": { diff --git a/src/messages/hr.json b/src/messages/hr.json index ab639925..76871b8a 100644 --- a/src/messages/hr.json +++ b/src/messages/hr.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/hu.json b/src/messages/hu.json index bf0e2d58..cc8a1ae7 100644 --- a/src/messages/hu.json +++ b/src/messages/hu.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/it.json b/src/messages/it.json index 7998efc0..9ce382dd 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -4355,6 +4355,20 @@ "failures": "{count} errori", "streaming": "Risposta in streaming: la misura termina alla creazione della risposta, non alla fine dell’operazione.", "operation": "ID operazione" + }, + "importProgress": { + "validating": "Verifica dati locali", + "downloading": "Download dei file", + "converting": "Conversione in Nitro", + "checking_sources": "Ricerca nelle sorgenti configurate", + "writing_db": "Salvataggio SQL e catalogo", + "writing_furnidata": "Aggiornamento furnidata", + "translating": "Aggiornamento traduzioni", + "current": "Fase attuale: {phase}", + "last": "Ultima fase: {phase}", + "elapsed": "{seconds}s in questa fase", + "recovered": "Bundle recuperato da {source} · revisione {revision}. Classname, tipo e revisione corrispondenti; identità del bundle verificata.", + "recoveryHint": "Puoi continuare. Se i file originali non sono disponibili, l’importatore cercherà nelle sorgenti attive lo stesso classname, tipo e revisione, verificando poi il bundle Nitro. Puoi comunque allegare manualmente i file mancanti." } }, "radioRequests": { diff --git a/src/messages/ja.json b/src/messages/ja.json index 613c1ae6..ab35e90d 100644 --- a/src/messages/ja.json +++ b/src/messages/ja.json @@ -541,6 +541,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "myDashboard": { diff --git a/src/messages/nl.json b/src/messages/nl.json index f3e08176..35a08065 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -3971,6 +3971,20 @@ "failures": "{count} fouten", "streaming": "Streamingantwoord: de meting stopt bij het aanmaken van het antwoord, niet aan het einde van de operatie.", "operation": "Operatie-ID" + }, + "importProgress": { + "validating": "Lokale gegevens controleren", + "downloading": "Bestanden downloaden", + "converting": "Omzetten naar Nitro", + "checking_sources": "In ingestelde bronnen zoeken", + "writing_db": "SQL en catalogus opslaan", + "writing_furnidata": "Furnidata bijwerken", + "translating": "Vertalingen bijwerken", + "current": "Huidige fase: {phase}", + "last": "Laatste fase: {phase}", + "elapsed": "{seconds}s in deze fase", + "recovered": "Bundel hersteld via {source} · revisie {revision}. Classname, type en revisie komen overeen; identiteit van de bundel gecontroleerd.", + "recoveryHint": "Je kunt doorgaan. Als de oorspronkelijke bestanden ontbreken, zoekt de importeur in actieve bronnen naar dezelfde classname, hetzelfde type en dezelfde revisie en controleert daarna de Nitro-bundel. Ontbrekende bestanden kun je ook handmatig toevoegen." } }, "mod": { diff --git a/src/messages/no.json b/src/messages/no.json index 437dcc3d..a72ec570 100644 --- a/src/messages/no.json +++ b/src/messages/no.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/pl.json b/src/messages/pl.json index 7427dee7..59c31547 100644 --- a/src/messages/pl.json +++ b/src/messages/pl.json @@ -3496,6 +3496,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/pt.json b/src/messages/pt.json index 92afb894..82d75521 100644 --- a/src/messages/pt.json +++ b/src/messages/pt.json @@ -3496,6 +3496,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/ro.json b/src/messages/ro.json index e924daf5..7061fc1d 100644 --- a/src/messages/ro.json +++ b/src/messages/ro.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/ru.json b/src/messages/ru.json index 0fe5c1a9..414a15d9 100644 --- a/src/messages/ru.json +++ b/src/messages/ru.json @@ -3494,6 +3494,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/sk.json b/src/messages/sk.json index 7e521602..8634d70d 100644 --- a/src/messages/sk.json +++ b/src/messages/sk.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/sr.json b/src/messages/sr.json index 836a7963..6882e13d 100644 --- a/src/messages/sr.json +++ b/src/messages/sr.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/sv.json b/src/messages/sv.json index ab85be67..80abc498 100644 --- a/src/messages/sv.json +++ b/src/messages/sv.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/tr.json b/src/messages/tr.json index 10c97571..1b6b5d89 100644 --- a/src/messages/tr.json +++ b/src/messages/tr.json @@ -3496,6 +3496,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": { diff --git a/src/messages/uk.json b/src/messages/uk.json index e7c059c7..8ec287be 100644 --- a/src/messages/uk.json +++ b/src/messages/uk.json @@ -3495,6 +3495,20 @@ "failures": "{count} failures", "streaming": "Streaming response: timing stops at response creation, not at the end of the operation.", "operation": "Operation ID" + }, + "importProgress": { + "validating": "Checking local data", + "downloading": "Downloading files", + "converting": "Converting to Nitro", + "checking_sources": "Searching configured sources", + "writing_db": "Saving SQL and catalog", + "writing_furnidata": "Updating furnidata", + "translating": "Updating translations", + "current": "Current phase: {phase}", + "last": "Last phase: {phase}", + "elapsed": "{seconds}s in this phase", + "recovered": "Bundle recovered from {source} · revision {revision}. Classname, type and revision matched; bundle identity validated.", + "recoveryHint": "You can continue. If the original files fail, the importer will search enabled sources for a matching classname, type and revision, then validate the Nitro bundle. Unavailable files can still be attached manually." } }, "error": {