diff --git a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs index 0e0a306..4da86e3 100644 --- a/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs +++ b/DorkNet.Server/Controllers/Admin/RoomZipImportController.cs @@ -636,6 +636,14 @@ public class RoomZipImportController( .Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length)) .ToList(); + var studioRoomFolders = entryByPath.Keys + .Where(k => k.EndsWith("/room.json", StringComparison.OrdinalIgnoreCase)) + .Select(k => k[..^"/room.json".Length]) + .Where(f => HasStudioSaveEntries(f, entryByPath.Keys)) + .Where(f => !roomFolders.Contains(f, StringComparer.OrdinalIgnoreCase)) + .ToList(); + roomFolders.AddRange(studioRoomFolders); + // Inventions can live either at the zip root (Inventions/Invention___/) // OR inside a room folder (Rooms/Room___/Inventions/Invention_…). // Both layouts show up in the wild — handle either. @@ -734,10 +742,21 @@ public class RoomZipImportController( Dictionary entries, bool normalizeBlobs) { - // RoomDetails.json + // RoomDetails.json. Studio single-room dumps use room.json at + // the room root; it is the same rooms.rec.net detail payload and + // can be read through RoomDetailsDto directly. var detailsKey = $"{roomFolder}/RoomDetails.json"; - var details = await ReadJsonAsync(entries[detailsKey]) ?? new RoomDetailsDto(); - var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder); + var studioDetailsKey = $"{roomFolder}/room.json"; + ZipArchiveEntry detailsEntry; + if (entries.TryGetValue(detailsKey, out var recNetDetailsEntry)) + detailsEntry = recNetDetailsEntry; + else if (entries.TryGetValue(studioDetailsKey, out var studioDetailsEntry)) + detailsEntry = studioDetailsEntry; + else + throw new InvalidOperationException($"Missing RoomDetails.json or room.json in {roomFolder}"); + + var details = await ReadJsonAsync(detailsEntry) ?? new RoomDetailsDto(); + var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : StripStudioSuffix(ZipFileName(roomFolder)); if (await db.Rooms.AnyAsync(r => r.Name == name)) return new { name, ok = false, error = "duplicate_name", folder = roomFolder }; @@ -759,6 +778,12 @@ public class RoomZipImportController( var resolvedScenes = new List(); var missingScenes = new List(); + if (scenes.Count == 0 && entries.ContainsKey(studioDetailsKey)) + { + (resolvedScenes, missingScenes) = await ResolveStudioDumpSubroomsAsync( + roomFolder, details, name, entries, normalizeBlobs); + } + foreach (var sceneName in scenes.Keys) { var sceneFolder = $"{subroomRoot}{sceneName}"; @@ -985,6 +1010,7 @@ public class RoomZipImportController( imageEntry = entries.FirstOrDefault(kv => kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value; } + imageEntry ??= FindStudioPhotoEntry(roomFolder, details.ImageName, entries); // Reorder so entry is first. Manifest order preserved for the rest. var ordered = new List { entry }; @@ -1186,6 +1212,16 @@ public class RoomZipImportController( var bytes = await ReadAllAsync(pentry); await WriteBlobAsync(0, fname, bytes, creator); } + var studioPromoPrefix = $"{roomFolder}/photos/"; + foreach (var (k, pentry) in entries.Where(kv => + kv.Key.StartsWith(studioPromoPrefix, StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(kv.Key).StartsWith("promo", StringComparison.OrdinalIgnoreCase))) + { + var fname = $"PromoImage_{Path.GetFileName(k)}"; + if (await db.RoomDataBlobs.AnyAsync(b => b.BlobName == fname)) continue; + var bytes = await ReadAllAsync(pentry); + await WriteBlobAsync(0, fname, bytes, creator); + } db.Rooms.Add(new RoomEntity { @@ -1318,6 +1354,148 @@ public class RoomZipImportController( }; } + private async Task<(List Resolved, List Missing)> ResolveStudioDumpSubroomsAsync( + string roomFolder, + RoomDetailsDto details, + string roomName, + Dictionary entries, + bool normalizeBlobs) + { + var resolvedScenes = new List(); + var missingScenes = new List(); + var rootPrefix = $"{roomFolder}/"; + var sceneFolders = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in entries.Keys.Where(k => k.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))) + { + var rel = path[rootPrefix.Length..]; + var slash = rel.IndexOf('/'); + if (slash <= 0) continue; + var sceneFolder = rel[..slash]; + if (rel[(slash + 1)..].StartsWith("saves/", StringComparison.OrdinalIgnoreCase)) + sceneFolders.Add(sceneFolder); + } + + foreach (var sceneFolderName in sceneFolders.OrderBy(StripStudioSuffix, StringComparer.OrdinalIgnoreCase)) + { + var sceneName = StripStudioSuffix(sceneFolderName); + var savesPrefix = $"{rootPrefix}{sceneFolderName}/saves/"; + var saves = new List<(long SaveId, HistorySidecarDto Sidecar, ZipArchiveEntry DataEntry)>(); + + foreach (var (path, jsonEntry) in entries.Where(kv => + kv.Key.StartsWith(savesPrefix, StringComparison.OrdinalIgnoreCase) + && kv.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase) + && !kv.Key[savesPrefix.Length..].Contains('/'))) + { + var stem = Path.GetFileNameWithoutExtension(path); + if (!long.TryParse(stem, out var saveId)) continue; + if (!entries.TryGetValue($"{savesPrefix}{saveId}__data.room", out var dataEntry)) + { + missingScenes.Add($"{sceneName} save {saveId} (missing {saveId}__data.room)"); + continue; + } + var sidecar = await ReadJsonAsync(jsonEntry) ?? new HistorySidecarDto(); + saves.Add((sidecar.SubRoomDataSaveId ?? saveId, sidecar, dataEntry)); + } + + if (saves.Count == 0) + { + missingScenes.Add($"{sceneName} (no usable saves)"); + continue; + } + + var current = saves.OrderBy(s => s.SaveId).Last(); + var subroomDetails = details.SubRooms?.FirstOrDefault(s => + (s.SubRoomId.HasValue && s.SubRoomId == (current.Sidecar.SubRoomId ?? TryParseStudioId(sceneFolderName))) + || string.Equals(s.Name, sceneName, StringComparison.OrdinalIgnoreCase)); + var manifest = new SubRoomDto + { + SubRoomId = current.Sidecar.SubRoomId ?? subroomDetails?.SubRoomId ?? TryParseStudioId(sceneFolderName), + UnitySceneId = subroomDetails?.UnitySceneId, + Name = subroomDetails?.Name ?? sceneName, + IsSandbox = subroomDetails?.IsSandbox ?? true, + MaxPlayers = subroomDetails?.MaxPlayers ?? details.MaxPlayers ?? 8, + Accessibility = subroomDetails?.Accessibility ?? details.Accessibility ?? 1, + CurrentSave = new CurrentSaveDto + { + SubRoomDataSaveId = current.SaveId, + DataBlob = current.Sidecar.DataBlob ?? ZipFileName(current.DataEntry.FullName), + }, + }; + + var blobBytes = await ReadAllAsync(current.DataEntry); + var norm = roomBlobNormalizer.Normalize(blobBytes); + var persistedBytes = normalizeBlobs ? norm.Bytes : blobBytes; + + var htrAssets = new List<(string Name, ZipArchiveEntry Entry)>(); + var pvImages = new List<(string Name, ZipArchiveEntry Entry)>(); + var polaroids = new List<(string Name, ZipArchiveEntry Entry)>(); + + foreach (var save in saves) + { + var refPrefix = $"{savesPrefix}{save.SaveId}__ref_"; + foreach (var (path, assetEntry) in entries.Where(kv => + kv.Key.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase) + && !kv.Key[refPrefix.Length..].Contains('/'))) + { + var assetName = path[refPrefix.Length..]; + var ext = Path.GetExtension(assetName).ToLowerInvariant(); + if (ext == ".htr") + { + htrAssets.Add((assetName, assetEntry)); + } + else if (IsImageExt(ext)) + { + if (assetName.StartsWith("PVImage_", StringComparison.OrdinalIgnoreCase)) + { + pvImages.Add((assetName, assetEntry)); + polaroids.Add((assetName["PVImage_".Length..], assetEntry)); + } + else + { + pvImages.Add(($"PVImage_{assetName}", assetEntry)); + polaroids.Add((assetName, assetEntry)); + } + } + } + } + + var history = new List(); + foreach (var save in saves.Where(s => s.SaveId != current.SaveId)) + { + history.Add(new HistorySave( + DataBlob: !string.IsNullOrWhiteSpace(save.Sidecar.DataBlob) + ? save.Sidecar.DataBlob! + : ZipFileName(save.DataEntry.FullName), + RoomEntry: save.DataEntry, + SubRoomId: save.Sidecar.SubRoomId ?? manifest.SubRoomId, + SubRoomDataSaveId: save.Sidecar.SubRoomDataSaveId ?? save.SaveId, + CreatedAt: save.Sidecar.CreatedAt, + Description: save.Sidecar.Description, + SavedByAccountId: save.Sidecar.SavedByAccountId)); + } + + resolvedScenes.Add(new ResolvedSubroom( + Manifest: manifest, + SceneName: sceneName, + Bytes: persistedBytes, + RawBytes: blobBytes.Length, + NormalizedOk: norm.Normalized, + HtrAssets: htrAssets, + PvImages: pvImages, + Polaroids: polaroids, + History: history)); + + logger.LogInformation( + "[zip-import] studio-dump room='{Room}' scene='{Scene}' save={SaveId} raw={Raw:N0} normOutput={Norm:N0} persistedFrom={Source} htr={Htr} pv={Pv} polaroids={Pol} history={History}", + roomName, sceneName, current.SaveId, blobBytes.Length, norm.Bytes.Length, + normalizeBlobs ? "normaliser" : "raw", + htrAssets.Count, pvImages.Count, polaroids.Count, history.Count); + } + + return (resolvedScenes, missingScenes); + } + // ── Per-invention import ───────────────────────────────────────── private async Task ImportInventionAsync( @@ -1520,6 +1698,74 @@ public class RoomZipImportController( private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/'); + private static string ZipFileName(string path) + { + var normalized = Normalize(path).TrimEnd('/'); + var slash = normalized.LastIndexOf('/'); + return slash >= 0 ? normalized[(slash + 1)..] : normalized; + } + + private static string StripStudioSuffix(string name) + { + var marker = name.LastIndexOf("__", StringComparison.Ordinal); + return marker > 0 && long.TryParse(name[(marker + 2)..], out _) + ? name[..marker] + : name; + } + + private static long? TryParseStudioId(string name) + { + var marker = name.LastIndexOf("__", StringComparison.Ordinal); + return marker > 0 && long.TryParse(name[(marker + 2)..], out var id) ? id : null; + } + + private static bool IsImageExt(string ext) => ext.ToLowerInvariant() switch + { + ".jpg" or ".jpeg" or ".png" or ".webp" or ".gif" => true, + _ => false, + }; + + private static bool HasStudioSaveEntries(string roomFolder, IEnumerable paths) + { + var prefix = $"{roomFolder.TrimEnd('/')}/"; + return paths.Any(k => + { + if (!k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || !k.EndsWith("__data.room", StringComparison.OrdinalIgnoreCase)) + return false; + var rel = k[prefix.Length..]; + var parts = rel.Split('/'); + return parts.Length == 3 + && parts[0].Contains("__", StringComparison.Ordinal) + && string.Equals(parts[1], "saves", StringComparison.OrdinalIgnoreCase); + }); + } + + private static ZipArchiveEntry? FindStudioPhotoEntry( + string roomFolder, + string? imageName, + Dictionary entries) + { + var photosPrefix = $"{roomFolder}/photos/"; + if (!string.IsNullOrWhiteSpace(imageName)) + { + var byImageName = entries.FirstOrDefault(kv => + kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(Path.GetFileName(kv.Key), imageName, StringComparison.OrdinalIgnoreCase)).Value; + if (byImageName is not null) return byImageName; + + var imageStem = Path.GetFileNameWithoutExtension(imageName); + byImageName = entries.FirstOrDefault(kv => + kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(Path.GetFileNameWithoutExtension(kv.Key), imageStem, StringComparison.OrdinalIgnoreCase)).Value; + if (byImageName is not null) return byImageName; + } + + return entries.FirstOrDefault(kv => + kv.Key.StartsWith(photosPrefix, StringComparison.OrdinalIgnoreCase) + && string.Equals(Path.GetFileNameWithoutExtension(kv.Key), "main", StringComparison.OrdinalIgnoreCase)).Value; + } + private static async Task ReadAllAsync(ZipArchiveEntry entry) { await using var s = entry.Open(); diff --git a/DorkNet.Server/admin-ui/src/pages/ImportRoom.tsx b/DorkNet.Server/admin-ui/src/pages/ImportRoom.tsx index 72dc32d..7b62aae 100644 --- a/DorkNet.Server/admin-ui/src/pages/ImportRoom.tsx +++ b/DorkNet.Server/admin-ui/src/pages/ImportRoom.tsx @@ -28,6 +28,16 @@ import { Upload, Trash, RefreshCw } from '../components/Icons'; // 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. @@ -930,16 +940,19 @@ async function parseZipPreview(file: File, onProgress?: (percent: number) => voi // 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 + inventionManifestPaths.length; + const totalManifests = roomManifestPaths.length + studioRoomManifestPaths.length + inventionManifestPaths.length; let processed = 0; const bump = () => { processed++; @@ -1079,6 +1092,131 @@ async function parseZipPreview(file: File, onProgress?: (percent: number) => voi 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). @@ -1108,7 +1246,9 @@ async function parseZipPreview(file: File, onProgress?: (percent: number) => voi // 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(); @@ -1128,3 +1268,24 @@ async function readText(entry: Entry): 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); +}