mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Enhance room import functionality: support studio single-room dumps, improve zip parsing, and add validation for save entries
This commit is contained in:
@@ -636,6 +636,14 @@ public class RoomZipImportController(
|
|||||||
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
|
.Select(k => k.Substring(0, k.Length - "/RoomDetails.json".Length))
|
||||||
.ToList();
|
.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_<id>_<name>_<ts>/)
|
// Inventions can live either at the zip root (Inventions/Invention_<id>_<name>_<ts>/)
|
||||||
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
|
// OR inside a room folder (Rooms/Room_<id>_<name>_<ts>/Inventions/Invention_…).
|
||||||
// Both layouts show up in the wild — handle either.
|
// Both layouts show up in the wild — handle either.
|
||||||
@@ -734,10 +742,21 @@ public class RoomZipImportController(
|
|||||||
Dictionary<string, ZipArchiveEntry> entries,
|
Dictionary<string, ZipArchiveEntry> entries,
|
||||||
bool normalizeBlobs)
|
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 detailsKey = $"{roomFolder}/RoomDetails.json";
|
||||||
var details = await ReadJsonAsync<RoomDetailsDto>(entries[detailsKey]) ?? new RoomDetailsDto();
|
var studioDetailsKey = $"{roomFolder}/room.json";
|
||||||
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : Path.GetFileName(roomFolder);
|
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<RoomDetailsDto>(detailsEntry) ?? new RoomDetailsDto();
|
||||||
|
var name = !string.IsNullOrWhiteSpace(details.Name) ? details.Name!.Trim() : StripStudioSuffix(ZipFileName(roomFolder));
|
||||||
|
|
||||||
if (await db.Rooms.AnyAsync(r => r.Name == name))
|
if (await db.Rooms.AnyAsync(r => r.Name == name))
|
||||||
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
|
return new { name, ok = false, error = "duplicate_name", folder = roomFolder };
|
||||||
@@ -759,6 +778,12 @@ public class RoomZipImportController(
|
|||||||
var resolvedScenes = new List<ResolvedSubroom>();
|
var resolvedScenes = new List<ResolvedSubroom>();
|
||||||
var missingScenes = new List<string>();
|
var missingScenes = new List<string>();
|
||||||
|
|
||||||
|
if (scenes.Count == 0 && entries.ContainsKey(studioDetailsKey))
|
||||||
|
{
|
||||||
|
(resolvedScenes, missingScenes) = await ResolveStudioDumpSubroomsAsync(
|
||||||
|
roomFolder, details, name, entries, normalizeBlobs);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var sceneName in scenes.Keys)
|
foreach (var sceneName in scenes.Keys)
|
||||||
{
|
{
|
||||||
var sceneFolder = $"{subroomRoot}{sceneName}";
|
var sceneFolder = $"{subroomRoot}{sceneName}";
|
||||||
@@ -985,6 +1010,7 @@ public class RoomZipImportController(
|
|||||||
imageEntry = entries.FirstOrDefault(kv =>
|
imageEntry = entries.FirstOrDefault(kv =>
|
||||||
kv.Key.StartsWith($"{roomFolder}/RoomImage.", StringComparison.OrdinalIgnoreCase)).Value;
|
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.
|
// Reorder so entry is first. Manifest order preserved for the rest.
|
||||||
var ordered = new List<ResolvedSubroom> { entry };
|
var ordered = new List<ResolvedSubroom> { entry };
|
||||||
@@ -1186,6 +1212,16 @@ public class RoomZipImportController(
|
|||||||
var bytes = await ReadAllAsync(pentry);
|
var bytes = await ReadAllAsync(pentry);
|
||||||
await WriteBlobAsync(0, fname, bytes, creator);
|
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
|
db.Rooms.Add(new RoomEntity
|
||||||
{
|
{
|
||||||
@@ -1318,6 +1354,148 @@ public class RoomZipImportController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(List<ResolvedSubroom> Resolved, List<string> Missing)> ResolveStudioDumpSubroomsAsync(
|
||||||
|
string roomFolder,
|
||||||
|
RoomDetailsDto details,
|
||||||
|
string roomName,
|
||||||
|
Dictionary<string, ZipArchiveEntry> entries,
|
||||||
|
bool normalizeBlobs)
|
||||||
|
{
|
||||||
|
var resolvedScenes = new List<ResolvedSubroom>();
|
||||||
|
var missingScenes = new List<string>();
|
||||||
|
var rootPrefix = $"{roomFolder}/";
|
||||||
|
var sceneFolders = new HashSet<string>(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<HistorySidecarDto>(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<HistorySave>();
|
||||||
|
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 ─────────────────────────────────────────
|
// ── Per-invention import ─────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<object> ImportInventionAsync(
|
private async Task<object> ImportInventionAsync(
|
||||||
@@ -1520,6 +1698,74 @@ public class RoomZipImportController(
|
|||||||
|
|
||||||
private static string Normalize(string path) => path.Replace('\\', '/').TrimStart('/');
|
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<string> 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<string, ZipArchiveEntry> 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<byte[]> ReadAllAsync(ZipArchiveEntry entry)
|
private static async Task<byte[]> ReadAllAsync(ZipArchiveEntry entry)
|
||||||
{
|
{
|
||||||
await using var s = entry.Open();
|
await using var s = entry.Open();
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ import { Upload, Trash, RefreshCw } from '../components/Icons';
|
|||||||
// InventionImage.jpg
|
// InventionImage.jpg
|
||||||
// <hash>.inv
|
// <hash>.inv
|
||||||
//
|
//
|
||||||
|
// Studio single-room dump zips are also accepted directly:
|
||||||
|
//
|
||||||
|
// <RoomName>__<RoomId>/
|
||||||
|
// room.json
|
||||||
|
// manifest.json
|
||||||
|
// photos/main.jpg
|
||||||
|
// <SubRoomName>__<SubRoomId>/saves/<SaveId>.json
|
||||||
|
// <SubRoomName>__<SubRoomId>/saves/<SaveId>__data.room
|
||||||
|
// <SubRoomName>__<SubRoomId>/saves/<SaveId>__ref_<hash>.htr|jpg|png
|
||||||
|
//
|
||||||
// We parse the archive client-side purely for a preview/validation
|
// We parse the archive client-side purely for a preview/validation
|
||||||
// pass — the same blob is then POSTed to the server, which re-parses
|
// pass — the same blob is then POSTed to the server, which re-parses
|
||||||
// it authoritatively.
|
// 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
|
// counted the top-level ones, so a zip with many per-room inventions
|
||||||
// hit 374% on the parse bar. Match the same predicates here.
|
// hit 374% on the parse bar. Match the same predicates here.
|
||||||
const roomManifestPaths: string[] = [];
|
const roomManifestPaths: string[] = [];
|
||||||
|
const studioRoomManifestPaths: string[] = [];
|
||||||
const inventionManifestPaths: string[] = [];
|
const inventionManifestPaths: string[] = [];
|
||||||
for (const p of allPaths) {
|
for (const p of allPaths) {
|
||||||
if (p.startsWith('Rooms/') && p.endsWith('/RoomDetails.json')) {
|
if (p.startsWith('Rooms/') && p.endsWith('/RoomDetails.json')) {
|
||||||
roomManifestPaths.push(p);
|
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')
|
} else if (p.endsWith('/Invention.json')
|
||||||
&& (p.startsWith('Inventions/') || p.includes('/Inventions/'))) {
|
&& (p.startsWith('Inventions/') || p.includes('/Inventions/'))) {
|
||||||
inventionManifestPaths.push(p);
|
inventionManifestPaths.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const totalManifests = roomManifestPaths.length + inventionManifestPaths.length;
|
const totalManifests = roomManifestPaths.length + studioRoomManifestPaths.length + inventionManifestPaths.length;
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
const bump = () => {
|
const bump = () => {
|
||||||
processed++;
|
processed++;
|
||||||
@@ -1079,6 +1092,131 @@ async function parseZipPreview(file: File, onProgress?: (percent: number) => voi
|
|||||||
bump();
|
bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Studio single-room dump walk. Anchor on <RoomName>__<RoomId>/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<string>();
|
||||||
|
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/<r>/Inventions/`.
|
// Inventions — top-level `Inventions/` OR per-room `Rooms/<r>/Inventions/`.
|
||||||
// The exporter uses either layout depending on the room (rooms with
|
// The exporter uses either layout depending on the room (rooms with
|
||||||
// their own attached inventions ship them inside the room folder).
|
// 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
|
// Per-room Inventions/ paths are valid (live under Rooms/) so they
|
||||||
// don't trigger here.
|
// don't trigger here.
|
||||||
const unknownTopLevel: string[] = [];
|
const unknownTopLevel: string[] = [];
|
||||||
|
const studioRoomFolders = studioRoomManifestPaths.map(p => p.slice(0, -'/room.json'.length));
|
||||||
for (const p of allPaths) {
|
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);
|
if (!p.startsWith('Rooms/') && !p.startsWith('Inventions/')) unknownTopLevel.push(p);
|
||||||
}
|
}
|
||||||
unknownTopLevel.sort();
|
unknownTopLevel.sort();
|
||||||
@@ -1128,3 +1268,24 @@ async function readText(entry: Entry): Promise<string> {
|
|||||||
if (!file.getData) throw new Error(`zip entry "${entry.filename}" has no data (directory entry)`);
|
if (!file.getData) throw new Error(`zip entry "${entry.filename}" has no data (directory entry)`);
|
||||||
return file.getData(new TextWriter());
|
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<string>): 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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user