import { useEffect, useMemo, useRef, useState } from 'react'; import { BlobReader, TextWriter, ZipReader, type Entry } from '@zip.js/zip.js'; import { ApiError, api, uploadZipInChunks, type UploadProgress } from '../lib/api'; import { isChromium } from '../lib/browser'; import type { Player } from '../lib/types'; import { useApi } from '../lib/useApi'; import { PageHeader } from '../components/PageHeader'; import { useToast } from '../components/Toast'; import { Upload, Trash, RefreshCw } from '../components/Icons'; // Matches RecNet's real export layout (see comments in // Controllers/Admin/RoomZipImportController.cs for the schema): // // Rooms/Room___/ // RoomDetails.json // RoomImage.jpg // Promo/Image/PromoImage_*.jpg // SubRooms// // Subroom.json // .room (referenced by Subroom.CurrentSave.DataBlob) // AudioSampler/.htr // Holotar/.htr // Image/PVImage_*.jpg // Inventions/Invention___/ // Invention.json // InventionDetails.json // InventionVersion.json // InventionImage.jpg // .inv // // Studio single-room dump zips are also accepted directly: // // __/ // room.json // manifest.json // photos/main.jpg // __/saves/.json // __/saves/__data.room // __/saves/__ref_.htr|jpg|png // // We parse the archive client-side purely for a preview/validation // pass — the same blob is then POSTed to the server, which re-parses // it authoritatively. interface RoomDetailsJson { RoomId?: number; Name?: string; FriendlyName?: string; Description?: string; ImageName?: string; CreatedAt?: string; PublishedAt?: string; ModifiedAt?: string; Stats?: { CheerCount?: number; VisitCount?: number; VisitorCount?: number; FavoriteCount?: number }; Tags?: Array<{ Tag?: string; Type?: number }>; SubRooms?: Array<{ Name?: string; CurrentSave?: { DataBlob?: string }; IsSandbox?: boolean; MaxPlayers?: number; ModifiedAt?: string }>; } /// The 2020 Rec Room watch was finalized around September 2020; rooms /// authored after end-of-2020 commonly reference circuits, props, and /// UI components added in 2021+ builds that the 2020 binary can't /// resolve. Importing them still produces a row in the DB but the /// scene either loads with missing chunks or crashes the watch at /// load time. We don't refuse the import — just flag in the preview /// so admins know to expect breakage and can untick those rooms. const CLIENT_BUILD_CUTOFF = new Date('2020-12-31T00:00:00Z'); interface SubroomJson { Name?: string; CurrentSave?: { DataBlob?: string }; IsSandbox?: boolean; MaxPlayers?: number; UnitySceneId?: string; ModifiedAt?: string; } interface InventionJson { Name?: string; Description?: string; ImageName?: string; CurrentVersionNumber?: number; } interface PreviewRoom { folder: string; name: string; description: string; tags: string[]; stats: { visits: number; visitors: number; cheers: number; favorites: number }; subrooms: PreviewSubroom[]; hasImage: boolean; promoImageCount: number; /// Latest of CreatedAt / PublishedAt / ModifiedAt / per-subroom /// ModifiedAt. Used to flag rooms newer than the 2020 client's /// release window — see CLIENT_BUILD_CUTOFF. latestModifiedAt: string | null; postBuildCutoff: boolean; } interface PreviewSubroom { scene: string; blobFilename: string | null; blobFound: boolean; htrAssetCount: number; pvImageCount: number; polaroidCount: number; hasSubroomJson: boolean; } interface PreviewInvention { folder: string; name: string; description: string; hasBlob: boolean; hasImage: boolean; versionNumber: number; } interface ZipPreview { rooms: PreviewRoom[]; inventions: PreviewInvention[]; unknownTopLevel: string[]; } interface AssetBreakdown { referenced: number; newlyImported: number; alreadyInDb: number } interface ImportResultRoom { name: string; ok: boolean; error?: string; roomId?: number; folder?: string; sceneCount?: number; entryScene?: string; entryDetectionSource?: string; htrAssets?: AssetBreakdown; pvImages?: AssetBreakdown; polaroids?: AssetBreakdown; history?: AssetBreakdown & { skipped?: number }; // Legacy flat counters — keep reading them as a fallback in case // we point this UI at an older server build. htrAssetsImported?: number; pvImagesImported?: number; missingScenes?: string[] | null; tags?: string[]; stats?: { cheers: number; favorites: number; visitors: number; visits: number }; scenes?: Array<{ name: string; blobName: string; bytes: number; htrAssets: number; pvImages: number; isSandbox?: boolean; maxPlayers?: number }>; } interface ImportResultInvention { name: string; ok: boolean; error?: string; inventionId?: number; folder?: string; blobName?: string; bytes?: number; tags?: string[]; } interface ImportResult { archiveBytes: number; roomCount: number; inventionCount: number; rooms: ImportResultRoom[]; inventions: ImportResultInvention[]; } // Stages for the visible upload progress strip. "parsing" lasts until // the client-side zip preview lands; "uploading" tracks bytes-on-wire; // "server" is the indeterminate window between upload completion and // the server response (during which the server is walking the archive, // normalising blobs, inserting rows). "done" stays terminal until the // user clears the file. type ImportStage = 'idle' | 'parsing' | 'uploading' | 'server' | 'done'; export function ImportRoom() { const chromium = useMemo(() => isChromium(), []); const { data: players } = useApi('/players?take=500'); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); const [previewErr, setPreviewErr] = useState(null); const [parseProgress, setParseProgress] = useState(0); // 0..100 — number of entries we've inspected const [ownerId, setOwnerId] = useState(null); // Per-folder selection. Initialised to "everything checked except // post-2020-cutoff rooms" once the preview lands — admins typically // want to import everything compatible by default but skip the // flagged ones until they've vetted them. The Set holds folder paths // (Rooms/Room_*… or Inventions/Invention_*…). const [selectedFolders, setSelectedFolders] = useState>(new Set()); // Per-import "round-trip the .room bytes through the 2020 protobuf // normaliser" toggle. Defaults OFF because the re-encode crashes the // watch on load; flip on to opt back into the normaliser for a // specific zip once we're confident its bytes survive the round-trip. const [normalizeBlobs, setNormalizeBlobs] = useState(true); // "Wipe duplicates first" — POST every selected room name + invention // id from the preview to /import/wipe-targets before kicking off the // upload. Defaults ON because the zip-recovery workflow re-imports the // same room with refreshed .inv bytes most of the time, and without // the wipe (1) the room rejects with duplicate_name and (2) every // invention auto-ids to a fresh row, leaving placement refs in the // room blob pointing at orphans. Admins who really want a no-wipe // import can flip this off explicitly. const [wipeFirst, setWipeFirst] = useState(true); const [stage, setStage] = useState('idle'); const [uploadProgress, setUploadProgress] = useState({ loaded: 0, total: 0, percent: 0 }); const [serverStartedAt, setServerStartedAt] = useState(null); const [serverElapsed, setServerElapsed] = useState(0); const [result, setResult] = useState(null); const [drag, setDrag] = useState(false); const fileInputRef = useRef(null); const toast = useToast(); // Tick a clock while the server is processing so the user knows // something is happening — large archives can spend 10s+ in // System.IO.Compression + EF inserts with no wire traffic. useEffect(() => { if (stage !== 'server' || serverStartedAt === null) return; setServerElapsed(0); const id = window.setInterval(() => setServerElapsed(Math.round((Date.now() - serverStartedAt) / 1000)), 250); return () => window.clearInterval(id); }, [stage, serverStartedAt]); useEffect(() => { if (!file) { setPreview(null); setPreviewErr(null); setStage('idle'); setSelectedFolders(new Set()); return; } let cancelled = false; (async () => { setStage('parsing'); setPreviewErr(null); setParseProgress(0); try { const p = await parseZipPreview(file, pct => { if (!cancelled) setParseProgress(pct); }); if (!cancelled) { setPreview(p); setStage('idle'); // Default selection: every importable room + invention, // EXCEPT rooms flagged as post-2020-cutoff (admin can tick // them back on explicitly after acknowledging the warning). const next = new Set(); for (const r of p.rooms) if (!r.postBuildCutoff) next.add(r.folder); for (const inv of p.inventions) next.add(inv.folder); setSelectedFolders(next); } } catch (e) { if (!cancelled) { setPreview(null); setPreviewErr((e as Error).message); setStage('idle'); } } })(); return () => { cancelled = true; }; }, [file]); const toggleFolder = (folder: string) => { setSelectedFolders(prev => { const next = new Set(prev); if (next.has(folder)) next.delete(folder); else next.add(folder); return next; }); }; const upload = async () => { if (!file || !ownerId) return; setResult(null); // Optional pre-flight wipe — clears the DB rows that would conflict // with this import so a re-upload after a recovery pass cleanly // overwrites instead of producing duplicate-named rooms and // auto-id-on-collision inventions. Folder names embed the original // Rec Room InventionId: `Invention__RecRoom.InventionData`. if (wipeFirst && preview) { const inventionIds = (preview.inventions ?? []) .filter(inv => selectedFolders.has(inv.folder)) .map(inv => { const m = inv.folder.match(/Invention_(\d+)_/); return m ? Number(m[1]) : NaN; }) .filter(n => Number.isFinite(n)); const roomNames = (preview.rooms ?? []) .filter(r => selectedFolders.has(r.folder)) .map(r => r.name); if (inventionIds.length > 0 || roomNames.length > 0) { try { const wipe = await api<{ inventionsDeleted: number; inventionsNotFound: number; inventionBlobsRemoved: number; roomsDeleted: number; roomsNotFound: number; roomBlobsRemoved: number; roomScenesRemoved: number; skippedRooms?: Array<{ id: number; name: string; reason: string }>; }>('/import/wipe-targets', { method: 'POST', body: { InventionIds: inventionIds, RoomNames: roomNames }, }); const skipped = wipe.skippedRooms?.length ?? 0; toast.push( `Wiped ${wipe.roomsDeleted} room${wipe.roomsDeleted === 1 ? '' : 's'} + ${wipe.inventionsDeleted} invention${wipe.inventionsDeleted === 1 ? '' : 's'}` + (skipped > 0 ? ` (${skipped} room${skipped === 1 ? '' : 's'} preserved — dorms/seeded)` : ''), 'info', ); } catch (e) { const msg = e instanceof ApiError ? e.message : (e as Error).message; toast.push(`Wipe failed (continuing anyway): ${msg}`, 'error'); } } } setStage('uploading'); setUploadProgress({ loaded: 0, total: file.size, percent: 0 }); try { // Chunked upload — Cloudflare's edge caps request bodies at // 100 MB on free/pro plans, so even our 165 MB sample zip needs // splitting. The helper slices into ZIP_CHUNK_BYTES (50 MB) pieces // sent to /zip-upload-chunk, then calls /zip-upload-finalize which // runs the same import logic the single-shot endpoint does. // Split the selection into the two server-side filters. Empty // arrays mean "don't filter" on the server, so if the admin // unticked everything in a category we send a sentinel ([""]) // that matches no real folder and effectively imports nothing // from that category. const selectedRoomFolders = (preview?.rooms ?? []) .filter(r => selectedFolders.has(r.folder)) .map(r => r.folder); const selectedInventionFolders = (preview?.inventions ?? []) .filter(inv => selectedFolders.has(inv.folder)) .map(inv => inv.folder); const anyRoomSelected = (preview?.rooms.length ?? 0) > 0 && selectedRoomFolders.length === 0; const anyInventionSelected = (preview?.inventions.length ?? 0) > 0 && selectedInventionFolders.length === 0; const res = await uploadZipInChunks( file, ownerId, p => { setUploadProgress(p); if (p.total > 0 && p.loaded >= p.total) { setStage(prev => (prev === 'uploading' ? 'server' : prev)); setServerStartedAt(prev => prev ?? Date.now()); } }, 30 * 60_000, { selectedRoomFolders: anyRoomSelected ? ['__NONE__'] : selectedRoomFolders, selectedInventionFolders: anyInventionSelected ? ['__NONE__'] : selectedInventionFolders, normalizeBlobs, }, ); setStage('done'); setResult(res); const ok = res.rooms.filter(r => r.ok).length; toast.push( `Imported ${ok}/${res.rooms.length} rooms · ${res.inventions.filter(i => i.ok).length}/${res.inventions.length} inventions`, ok === res.rooms.length ? 'success' : 'info', ); } catch (e) { setStage('idle'); const msg = e instanceof ApiError ? e.message : (e as Error).message; toast.push(msg, 'error'); } finally { setServerStartedAt(null); } }; const busy = stage === 'uploading' || stage === 'server'; const previewBusy = stage === 'parsing'; const validRooms = preview?.rooms.filter(r => r.subrooms.some(s => s.blobFound)) ?? []; const totalScenes = validRooms.reduce((n, r) => n + r.subrooms.length, 0); const totalHtr = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.htrAssetCount, 0), 0); const totalPolaroids = validRooms.reduce((n, r) => n + r.subrooms.reduce((m, s) => m + s.polaroidCount, 0), 0); return (
Use legacy importer →} /> {!chromium && (
⚠
Chromium-based browser required

The zip importer uploads multi-hundred-megabyte archives via FormData and uses streaming Blob slicing. Firefox and Safari both choke on this — Firefox's upload progress buffers stall over ~500 MB, and Safari hits a memory ceiling cloning the form body and aborts mid-upload. Use Chrome, Edge, Brave, Vivaldi, or any other Chromium-based browser for this page. (The rest of the admin works fine everywhere.)

)}
{ e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); if (!chromium) { toast.push('Switch to a Chromium-based browser first', 'error'); return; } const f = e.dataTransfer.files?.[0]; if (f && f.name.toLowerCase().endsWith('.zip')) { setFile(f); setResult(null); } else if (f) { toast.push('Drop a .zip file', 'error'); } }} className={`card !p-8 mb-4 border-2 border-dashed transition-colors ${ drag ? 'border-brand-500 bg-brand-500/5' : 'border-ink-700 bg-ink-900/40' }`} >
Drop your archive here, or pick a file
.zip up to ~2 GB
{ setFile(e.target.files?.[0] ?? null); setResult(null); }} className="hidden" />
{file && ( )}
{file && (
{file.name} · {(file.size / 1024 / 1024).toFixed(1)} MB
)}
{previewBusy && (
Parsing archive — reading the central directory and walking room manifests… {Math.min(100, parseProgress)}%
)} {previewErr && (
Failed to parse archive: {previewErr}
)} {/* Live import status strip — appears while we're uploading the archive and then while the server processes it. */} {busy && (
{stage === 'uploading' && ( <>
Uploading archive… {(uploadProgress.loaded / 1024 / 1024).toFixed(1)} / {(uploadProgress.total / 1024 / 1024).toFixed(1)} MB {' · '}{uploadProgress.percent}%
)} {stage === 'server' && ( <>
Server is processing the archive… {serverElapsed}s elapsed
{uploadProgress.phase && (

{uploadProgress.phase}

)}

Unpacking, normalising every .room blob, inserting room + scene rows, persisting bundled .htr assets and preview images. Multi-GB archives can take several minutes; the importer now runs as a background job and the SPA polls for status, so Cloudflare won't drop the connection mid-import.

)}
)} {preview && (
{preview.rooms.length > 0 && (
Rooms ({preview.rooms.filter(r => selectedFolders.has(r.folder)).length}/{preview.rooms.length} selected)
{preview.rooms.map(r => (
{r.folder.split('/').pop()} {!r.hasImage && no image} {r.postBuildCutoff && ( may be incompatible )} {r.latestModifiedAt && ( {r.latestModifiedAt.slice(0, 10)} )}
{r.description &&

{r.description}

}
{r.tags.map(t => {t})}
n + s.htrAssetCount, 0)} /> n + s.pvImageCount, 0)} /> n + s.polaroidCount, 0)} />
{r.subrooms.length > 0 && (
Scene breakdown {r.subrooms.map(s => ( ))}
Scene Blob HTR PV Pol
{s.scene} {s.blobFilename ?? no DataBlob in Subroom.json} {s.blobFilename && !s.blobFound && (missing!)} {!s.hasSubroomJson && (no Subroom.json)} {s.htrAssetCount} {s.pvImageCount} {s.polaroidCount}
)}
))}
)} {preview.inventions.length > 0 && (
Inventions ({preview.inventions.filter(inv => selectedFolders.has(inv.folder)).length}/{preview.inventions.length} selected) {preview.inventions.map(inv => ( ))}
Name Folder Version
toggleFolder(inv.folder)} className="size-4 accent-brand-500" /> {inv.name} {inv.folder.split('/').pop()} {inv.versionNumber}
{!inv.hasBlob && no blob} {!inv.hasImage && no image}
)} {preview.unknownTopLevel.length > 0 && (
{preview.unknownTopLevel.length} file(s) outside the schema
    {preview.unknownTopLevel.map(f =>
  • {f}
  • )}
)}
)} {preview && (preview.rooms.length > 0 || preview.inventions.length > 0) && (
{/* "Wipe duplicates first" toggle. Clears DB rows that the importer would otherwise reject (duplicate-name rooms) or auto-id (existing-id inventions). Defaults ON because the zip-recovery workflow is overwhelmingly the common case now — admins re-import the same room with refreshed .inv bytes from HTTPCache and need a clean slate to avoid orphaning placement refs. Off only for first-ever imports where there's nothing to clobber anyway, so leaving it on is safe in both directions. */} {/* Per-import blob-normaliser toggle. Defaults ON now that the normaliser projects modern-quaternion TransformData to the legacy Euler form the 2020.12 watch reads — without that pass, modern-authored rooms render with every shape rotated to (0,0,0) (the Studio-import rotation "shapes in the wrong place" bug). Leave ON for any modern Rec Room export. */}
)} {result && (

Import result

{result.rooms.length > 0 && ( <>

Rooms

{result.rooms.map(r => ( ))}
Room Result Entry Scenes HTR PV Polaroids History
{r.name}{r.roomId !== undefined && · #{r.roomId}} {r.ok ? imported : failed{r.error ? ` — ${r.error}` : ''}} {r.entryScene ?? '—'} {r.entryDetectionSource && ({r.entryDetectionSource})} {r.sceneCount ?? '—'}
)} {result.inventions.length > 0 && ( <>

Inventions

{result.inventions.map(i => ( ))}
{i.name} {i.ok ? invention #{i.inventionId} : {i.error ?? 'failed'}} {i.blobName}
)}
)}
); } function Stat({ label, value }: { label: string; value: number | string }) { return (
{label}
{typeof value === 'number' ? value.toLocaleString() : value}
); } // "16 new · 44 already · 60 total" so admins can see at a glance // whether HTR/PV blobs are present even after a re-import where most // were already in the DB from a prior run (purge-custom-rooms keeps // shared assets by design). function AssetCell({ breakdown, legacy }: { breakdown?: AssetBreakdown; legacy?: number }) { if (!breakdown) { return {legacy ?? 0}; } const present = breakdown.newlyImported + breakdown.alreadyInDb; if (breakdown.referenced === 0) return —; const allPresent = present >= breakdown.referenced; return (
{present}/{breakdown.referenced} ({breakdown.newlyImported} new {breakdown.alreadyInDb > 0 ? ` · ${breakdown.alreadyInDb} kept` : ''})
); } function Mini({ label, value }: { label: string; value: number }) { return (
{label}
{value}
); } // Determinate bar when percent is given (0-100); indeterminate uses a // keyframed sliding strip so the user can see something's happening // even though we don't know how long the server will take. function ProgressBar({ percent, indeterminate }: { percent?: number; indeterminate?: boolean }) { if (indeterminate) { return (
); } const clamped = Math.max(0, Math.min(100, percent ?? 0)); return (
); } // ── Zip parsing ─────────────────────────────────────────────────────── async function parseZipPreview(file: File, onProgress?: (percent: number) => void): Promise { const reader = new ZipReader(new BlobReader(file)); let entries: Entry[]; try { entries = await reader.getEntries(); } finally { await reader.close(); } // ZipReader.getEntries reads the central directory — that's the // slow bit. Once we have the entries array, the rest is in-memory // map building + small JSON reads for the manifest files. We tick // progress as we walk; manifest JSONs read after that bump us to // 100%. onProgress?.(20); // Build a path-indexed view first; the rest of the parse runs against // this map, never touching the zip stream again. const allPaths = new Set(); const entryByPath = new Map(); for (const e of entries) { if (e.directory) continue; const p = e.filename.replace(/\\/g, '/').replace(/^\/+/, ''); allPaths.add(p); entryByPath.set(p, e); } // Count manifests up front so we can advance progress smoothly // as we read each one — the central-directory parse and the // manifest reads dominate; the rest is O(entries) string work. // // CRITICAL: the counter has to match what the iteration loops below // actually call `bump()` on, or progress overflows past 100%. Invention // manifests are accepted at TWO locations (top-level `Inventions/...` // AND per-room `Rooms/Room_X/Inventions/...`); originally we only // counted the top-level ones, so a zip with many per-room inventions // hit 374% on the parse bar. Match the same predicates here. const roomManifestPaths: string[] = []; const studioRoomManifestPaths: string[] = []; const inventionManifestPaths: string[] = []; for (const p of allPaths) { if (p.startsWith('Rooms/') && p.endsWith('/RoomDetails.json')) { roomManifestPaths.push(p); } else if (p.endsWith('/room.json') && hasStudioSaveEntries(p.slice(0, -'/room.json'.length), allPaths)) { studioRoomManifestPaths.push(p); } else if (p.endsWith('/Invention.json') && (p.startsWith('Inventions/') || p.includes('/Inventions/'))) { inventionManifestPaths.push(p); } } const totalManifests = roomManifestPaths.length + studioRoomManifestPaths.length + inventionManifestPaths.length; let processed = 0; const bump = () => { processed++; // Reserve 20% for the central-dir parse already done, leave 10% // for the final cleanup, so manifest reads occupy 20..90%. Clamp // defensively in case the counters drift again — better to peg the // bar at 90% than show "374%" to the user. const raw = totalManifests > 0 ? 20 + (processed / totalManifests) * 70 : 90; onProgress?.(Math.min(90, Math.round(raw))); }; // Per-room walk. Anchor on Rooms//RoomDetails.json. const rooms: PreviewRoom[] = []; for (const path of allPaths) { if (!path.startsWith('Rooms/') || !path.endsWith('/RoomDetails.json')) continue; const roomFolder = path.slice(0, -'/RoomDetails.json'.length); const detailsEntry = entryByPath.get(path); let details: RoomDetailsJson = {}; if (detailsEntry) { try { details = JSON.parse(await readText(detailsEntry)); } catch { /* leave as default */ } } // Scene folders under SubRooms/ const sceneRoot = `${roomFolder}/SubRooms/`; const sceneNames = new Set(); for (const p of allPaths) { if (!p.startsWith(sceneRoot)) continue; const rest = p.slice(sceneRoot.length); const slash = rest.indexOf('/'); if (slash <= 0) continue; sceneNames.add(rest.slice(0, slash)); } const subrooms: PreviewSubroom[] = []; for (const scene of sceneNames) { const sceneFolder = `${sceneRoot}${scene}`; const subroomJsonPath = `${sceneFolder}/Subroom.json`; let subroomJson: SubroomJson = {}; const hasSubroomJson = entryByPath.has(subroomJsonPath); if (hasSubroomJson) { try { subroomJson = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)); } catch { /* default */ } } const blobFilename = subroomJson.CurrentSave?.DataBlob ?? null; const blobPath = blobFilename ? `${sceneFolder}/${blobFilename}` : null; const blobFound = blobPath ? entryByPath.has(blobPath) : false; let htrAssetCount = 0; let pvImageCount = 0; let polaroidCount = 0; const imgPrefix = `${sceneFolder}/Image/`; const polPrefix = `${sceneFolder}/Polaroids/`; // Server-side mirrors this: a `PVImage_.ext` file in // Image/ counts as BOTH a PV image (its own filename) and a // polaroid (with the prefix stripped). The watch fetches both // URLs, so the importer writes the bytes twice. for (const p of allPaths) { if (p.startsWith(`${sceneFolder}/AudioSampler/`) && p.endsWith('.htr')) htrAssetCount++; else if (p.startsWith(`${sceneFolder}/Holotar/`) && p.endsWith('.htr')) htrAssetCount++; else if (p.startsWith(imgPrefix)) { pvImageCount++; const file = p.slice(imgPrefix.length); if (file.toLowerCase().startsWith('pvimage_')) polaroidCount++; } else if (p.startsWith(polPrefix)) polaroidCount++; } subrooms.push({ scene, blobFilename, blobFound, htrAssetCount, pvImageCount, polaroidCount, hasSubroomJson }); } // Sort by RoomDetails.SubRooms[] order if present so the preview // shows the entry-scene first. if (details.SubRooms && details.SubRooms.length > 0) { const manifestOrder = details.SubRooms.map(s => s.Name ?? '').filter(Boolean); subrooms.sort((a, b) => { const ai = manifestOrder.indexOf(a.scene); const bi = manifestOrder.indexOf(b.scene); if (ai === -1 && bi === -1) return a.scene.localeCompare(b.scene); if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); } else { subrooms.sort((a, b) => a.scene.localeCompare(b.scene)); } // Image / promo discovery const hasImage = !!details.ImageName && entryByPath.has(`${roomFolder}/${details.ImageName}`) || Array.from(allPaths).some(p => p.startsWith(`${roomFolder}/RoomImage.`)); let promoImageCount = 0; for (const p of allPaths) if (p.startsWith(`${roomFolder}/Promo/Image/`)) promoImageCount++; // Pick the most-recent timestamp across the room's own CreatedAt / // PublishedAt / ModifiedAt and each subroom's ModifiedAt — the // most aggressive signal of "made on a build newer than 2020". const candidateDates: number[] = []; for (const raw of [details.CreatedAt, details.PublishedAt, details.ModifiedAt]) { if (raw) { const t = Date.parse(raw); if (!Number.isNaN(t)) candidateDates.push(t); } } // We need to re-read the subroom jsons we already parsed above // for their ModifiedAt fields. Cheap second pass — preview only // parses small JSONs. for (const scene of sceneNames) { const subroomJsonPath = `${sceneRoot}${scene}/Subroom.json`; if (!entryByPath.has(subroomJsonPath)) continue; try { const sr = JSON.parse(await readText(entryByPath.get(subroomJsonPath)!)) as SubroomJson; if (sr.ModifiedAt) { const t = Date.parse(sr.ModifiedAt); if (!Number.isNaN(t)) candidateDates.push(t); } } catch { /* skip */ } } const latestMs = candidateDates.length > 0 ? Math.max(...candidateDates) : null; const latestModifiedAt = latestMs !== null ? new Date(latestMs).toISOString() : null; const postBuildCutoff = latestMs !== null && latestMs > CLIENT_BUILD_CUTOFF.getTime(); rooms.push({ folder: roomFolder, name: details.Name ?? details.FriendlyName ?? roomFolder.split('/').pop()!, description: details.Description ?? '', tags: (details.Tags ?? []).map(t => t.Tag).filter((t): t is string => !!t), stats: { visits: details.Stats?.VisitCount ?? 0, visitors: details.Stats?.VisitorCount ?? 0, cheers: details.Stats?.CheerCount ?? 0, favorites: details.Stats?.FavoriteCount ?? 0, }, subrooms, hasImage, promoImageCount, latestModifiedAt, postBuildCutoff, }); bump(); } // Studio single-room dump walk. Anchor on __/room.json. for (const path of studioRoomManifestPaths) { const roomFolder = path.slice(0, -'/room.json'.length); let details: RoomDetailsJson = {}; try { details = JSON.parse(await readText(entryByPath.get(path)!)); } catch { /* leave as default */ } const roomRoot = `${roomFolder}/`; const sceneFolders = new Set(); for (const p of allPaths) { if (!p.startsWith(roomRoot)) continue; const rest = p.slice(roomRoot.length); const slash = rest.indexOf('/'); if (slash <= 0) continue; const scene = rest.slice(0, slash); if (rest.slice(slash + 1).startsWith('saves/')) sceneFolders.add(scene); } const subrooms: PreviewSubroom[] = []; const candidateDates: number[] = []; for (const raw of [details.CreatedAt, details.PublishedAt, details.ModifiedAt]) { if (raw) { const t = Date.parse(raw); if (!Number.isNaN(t)) candidateDates.push(t); } } for (const sceneFolder of sceneFolders) { const savesPrefix = `${roomRoot}${sceneFolder}/saves/`; const saveIds: number[] = []; for (const p of allPaths) { if (!p.startsWith(savesPrefix) || !p.endsWith('.json')) continue; const stem = p.slice(savesPrefix.length, -'.json'.length); if (/^\d+$/.test(stem)) saveIds.push(parseInt(stem, 10)); } saveIds.sort((a, b) => a - b); const latestSave = saveIds.at(-1) ?? null; let latestSidecar: { CreatedAt?: string; DataBlob?: string } = {}; if (latestSave !== null) { const sidecarEntry = entryByPath.get(`${savesPrefix}${latestSave}.json`); if (sidecarEntry) { try { latestSidecar = JSON.parse(await readText(sidecarEntry)); } catch { /* default */ } if (latestSidecar.CreatedAt) { const t = Date.parse(latestSidecar.CreatedAt); if (!Number.isNaN(t)) candidateDates.push(t); } } } const blobFilename = latestSave !== null ? `${latestSave}__data.room` : null; const blobFound = blobFilename ? entryByPath.has(`${savesPrefix}${blobFilename}`) : false; let htrAssetCount = 0; let pvImageCount = 0; let polaroidCount = 0; if (latestSave !== null) { const refPrefix = `${savesPrefix}${latestSave}__ref_`; for (const p of allPaths) { if (!p.startsWith(refPrefix)) continue; const assetName = p.slice(refPrefix.length); const lower = assetName.toLowerCase(); if (lower.endsWith('.htr')) htrAssetCount++; else if (isImageName(lower)) { pvImageCount++; polaroidCount++; } } } subrooms.push({ scene: stripStudioSuffix(sceneFolder), blobFilename: latestSidecar.DataBlob ?? blobFilename, blobFound, htrAssetCount, pvImageCount, polaroidCount, hasSubroomJson: true, }); } if (details.SubRooms && details.SubRooms.length > 0) { const manifestOrder = details.SubRooms.map(s => s.Name ?? '').filter(Boolean); subrooms.sort((a, b) => { const ai = manifestOrder.indexOf(a.scene); const bi = manifestOrder.indexOf(b.scene); if (ai === -1 && bi === -1) return a.scene.localeCompare(b.scene); if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }); } else { subrooms.sort((a, b) => a.scene.localeCompare(b.scene)); } const hasImage = (!!details.ImageName && Array.from(allPaths).some(p => p.startsWith(`${roomFolder}/photos/`) && p.endsWith(`/${details.ImageName}`))) || allPaths.has(`${roomFolder}/photos/main.jpg`) || allPaths.has(`${roomFolder}/photos/main.png`); let promoImageCount = 0; for (const p of allPaths) { if (p.startsWith(`${roomFolder}/photos/`) && p.split('/').pop()?.toLowerCase().startsWith('promo')) promoImageCount++; } const latestMs = candidateDates.length > 0 ? Math.max(...candidateDates) : null; const latestModifiedAt = latestMs !== null ? new Date(latestMs).toISOString() : null; const postBuildCutoff = latestMs !== null && latestMs > CLIENT_BUILD_CUTOFF.getTime(); rooms.push({ folder: roomFolder, name: details.Name ?? details.FriendlyName ?? stripStudioSuffix(roomFolder.split('/').pop()!), description: details.Description ?? '', tags: (details.Tags ?? []).map(t => t.Tag).filter((t): t is string => !!t), stats: { visits: details.Stats?.VisitCount ?? 0, visitors: details.Stats?.VisitorCount ?? 0, cheers: details.Stats?.CheerCount ?? 0, favorites: details.Stats?.FavoriteCount ?? 0, }, subrooms, hasImage, promoImageCount, latestModifiedAt, postBuildCutoff, }); bump(); } // Inventions — top-level `Inventions/` OR per-room `Rooms//Inventions/`. // The exporter uses either layout depending on the room (rooms with // their own attached inventions ship them inside the room folder). const inventions: PreviewInvention[] = []; for (const path of allPaths) { if (!path.endsWith('/Invention.json')) continue; if (!path.startsWith('Inventions/') && !path.includes('/Inventions/')) continue; const folder = path.slice(0, -'/Invention.json'.length); let meta: InventionJson = {}; try { meta = JSON.parse(await readText(entryByPath.get(path)!)); } catch { /* default */ } const hasBlob = Array.from(allPaths).some(p => p.startsWith(`${folder}/`) && p.endsWith('.inv')); const hasImage = (!!meta.ImageName && entryByPath.has(`${folder}/${meta.ImageName}`)) || Array.from(allPaths).some(p => p.startsWith(`${folder}/InventionImage.`)); inventions.push({ folder, name: meta.Name ?? folder.split('/').pop()!, description: meta.Description ?? '', hasBlob, hasImage, versionNumber: meta.CurrentVersionNumber ?? 1, }); bump(); } // Anything not under Rooms/ or top-level Inventions/ is flagged. // Per-room Inventions/ paths are valid (live under Rooms/) so they // don't trigger here. const unknownTopLevel: string[] = []; const studioRoomFolders = studioRoomManifestPaths.map(p => p.slice(0, -'/room.json'.length)); for (const p of allPaths) { if (studioRoomFolders.some(f => p === f || p.startsWith(`${f}/`))) continue; if (!p.startsWith('Rooms/') && !p.startsWith('Inventions/')) unknownTopLevel.push(p); } unknownTopLevel.sort(); rooms.sort((a, b) => a.name.localeCompare(b.name)); inventions.sort((a, b) => a.name.localeCompare(b.name)); onProgress?.(100); return { rooms, inventions, unknownTopLevel }; } // Entry is a union of FileEntry | DirectoryEntry; only FileEntry has // .getData. We already filter out directories before reaching this, // but TS can't carry that info through the Map, so narrow explicitly. async function readText(entry: Entry): Promise { const file = entry as Entry & { getData?: (writer: TextWriter) => Promise }; if (!file.getData) throw new Error(`zip entry "${entry.filename}" has no data (directory entry)`); return file.getData(new TextWriter()); } function stripStudioSuffix(name: string): string { const idx = name.lastIndexOf('__'); if (idx <= 0) return name; return /^\d+$/.test(name.slice(idx + 2)) ? name.slice(0, idx) : name; } function hasStudioSaveEntries(roomFolder: string, paths: Set): boolean { const prefix = `${roomFolder.replace(/\/+$/, '')}/`; for (const p of paths) { if (!p.startsWith(prefix) || !p.endsWith('__data.room')) continue; const rel = p.slice(prefix.length); const parts = rel.split('/'); if (parts.length === 3 && parts[0].includes('__') && parts[1].toLowerCase() === 'saves') return true; } return false; } function isImageName(name: string): boolean { return /\.(jpe?g|png|webp|gif)$/i.test(name); }