Skip to content

Commit d98a3aa

Browse files
CopilotLeftofZen
andauthored
feat: add file download support for scenarios, sc5filepacks, and objectpacks
Agent-Logs-Url: https://github.com/OpenLoco/ObjectEditor/sessions/3482200a-1fe0-46f4-a160-138b9304a75c Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com>
1 parent 72a6c3b commit d98a3aa

9 files changed

Lines changed: 227 additions & 9 deletions

File tree

Definitions/Web/Client.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,30 @@ public static async Task<IEnumerable<DtoObjectEntry>> GetObjectListAsync(HttpCli
5353
ClientHelpers.ReadBinaryContentAsync,
5454
logger) ?? default;
5555

56+
public static async Task<byte[]?> GetScenarioFileAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null)
57+
=> await ClientHelpers.SendRequestAsync(
58+
client,
59+
ApiVersion + RoutesV2.Scenarios + $"/{id}/file",
60+
() => client.GetAsync(ApiVersion + RoutesV2.Scenarios + $"/{id}/file"),
61+
ClientHelpers.ReadBinaryContentAsync,
62+
logger) ?? default;
63+
64+
public static async Task<byte[]?> GetSC5FilePackFileAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null)
65+
=> await ClientHelpers.SendRequestAsync(
66+
client,
67+
ApiVersion + RoutesV2.SC5FilePacks + $"/{id}/file",
68+
() => client.GetAsync(ApiVersion + RoutesV2.SC5FilePacks + $"/{id}/file"),
69+
ClientHelpers.ReadBinaryContentAsync,
70+
logger) ?? default;
71+
72+
public static async Task<byte[]?> GetObjectPackFileAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null)
73+
=> await ClientHelpers.SendRequestAsync(
74+
client,
75+
ApiVersion + RoutesV2.ObjectPacks + $"/{id}/file",
76+
() => client.GetAsync(ApiVersion + RoutesV2.ObjectPacks + $"/{id}/file"),
77+
ClientHelpers.ReadBinaryContentAsync,
78+
logger) ?? default;
79+
5680
public static async Task<DtoObjectPostResponse?> UploadDatFileAsync(HttpClient client, string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate, ILogger logger)
5781
{
5882
var xxHash3 = XxHash3.HashToUInt64(datFileBytes);

Gui/Models/FileSystemItems.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ public bool CanOpen
2828
&& Id != null
2929
&& ObjectType != null);
3030

31+
[JsonIgnore]
32+
public bool CanDownload
33+
=> FileLocation == FileLocationKind.Online
34+
&& Id != null
35+
&& OnlineApiEndpointGroup is OnlineApiEndpointGroupKind.Objects or OnlineApiEndpointGroupKind.Scenarios;
36+
3137
[JsonIgnore]
3238
public bool CanOpenFolder
3339
=> FileLocation == FileLocationKind.Local && IsLeafNode && !string.IsNullOrEmpty(FileName);

Gui/ObjectServiceClient.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ public async Task<IEnumerable<DtoObjectEntry>> GetObjectListAsync()
7373
public async Task<byte[]?> GetObjectFileAsync(UniqueObjectId id)
7474
=> await Client.GetObjectFileAsync(WebClient, id, Logger);
7575

76+
public async Task<byte[]?> GetScenarioFileAsync(UniqueObjectId id)
77+
=> await Client.GetScenarioFileAsync(WebClient, id, Logger);
78+
79+
public async Task<byte[]?> GetSC5FilePackFileAsync(UniqueObjectId id)
80+
=> await Client.GetSC5FilePackFileAsync(WebClient, id, Logger);
81+
82+
public async Task<byte[]?> GetObjectPackFileAsync(UniqueObjectId id)
83+
=> await Client.GetObjectPackFileAsync(WebClient, id, Logger);
84+
7685
public async Task<DtoObjectPostResponse?> UploadDatFileAsync(string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate)
7786
=> await Client.UploadDatFileAsync(WebClient, filename, datFileBytes, creationDate, modifiedDate, Logger);
7887

Gui/ViewModels/FolderTreeViewModel.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ public class FolderTreeViewModel : ReactiveObject
148148
public ReactiveCommand<Unit, Unit>? OpenCurrentFolder { get; }
149149
public ReactiveCommand<FileSystemItem, Unit>? OpenFolderFor { get; }
150150
public ReactiveCommand<FileSystemItem, Unit>? SelectOnlineBrowseFileSystemItem { get; }
151+
public ReactiveCommand<FileSystemItem, Unit>? DownloadOnlineItemCommand { get; private set; }
152+
public ReactiveCommand<OnlineItemPackBrowseResult, Unit>? DownloadOnlinePackCommand { get; private set; }
151153

152154
public ObservableCollection<ObjectDisplayMode> DisplayModeItems { get; } = [.. Enum.GetValues<ObjectDisplayMode>()];
153155
static OnlineBrowseTargetOption ObjectOnlineBrowseTarget { get; } = new(OnlineApiEndpointGroup.Objects, "Objects", "Objects", Client.ObjectsEndpointGroup);
@@ -242,6 +244,8 @@ public FolderTreeViewModel(ObjectEditorContext editorContext)
242244
OpenCurrentFolder = ReactiveCommand.Create(() => PlatformSpecific.FolderOpenInDesktop(IsLocal ? CurrentLocalDirectory : this.EditorContext.Settings.DownloadFolder, this.EditorContext.Logger));
243245
AddFilterCommand = ReactiveCommand.Create(() => Filters.Add(new FilterViewModel(this.EditorContext, availableFilterCategories, RemoveFilter)));
244246
SelectOnlineBrowseFileSystemItem = ReactiveCommand.Create<FileSystemItem>(item => CurrentlySelectedObject = item);
247+
DownloadOnlineItemCommand = ReactiveCommand.CreateFromTask<FileSystemItem>(DownloadOnlineItemAsync);
248+
DownloadOnlinePackCommand = ReactiveCommand.CreateFromTask<OnlineItemPackBrowseResult>(DownloadOnlinePackAsync);
245249
OpenFolderFor = ReactiveCommand.Create((FileSystemItem clickedOn) =>
246250
{
247251
if (IsLocal
@@ -771,4 +775,57 @@ static FileSystemItem CreateOnlineScenarioFileSystemItem(DtoScenarioEntry item)
771775
{
772776
OnlineApiEndpointGroup = OnlineApiEndpointGroup.Scenarios,
773777
};
778+
779+
async Task DownloadOnlineItemAsync(FileSystemItem item)
780+
{
781+
if (EditorContext.ObjectServiceClient == null || item.Id == null)
782+
{
783+
return;
784+
}
785+
786+
byte[]? fileBytes = item.OnlineApiEndpointGroup switch
787+
{
788+
OnlineApiEndpointGroup.Objects => await EditorContext.ObjectServiceClient.GetObjectFileAsync(item.Id.Value),
789+
OnlineApiEndpointGroup.Scenarios => await EditorContext.ObjectServiceClient.GetScenarioFileAsync(item.Id.Value),
790+
_ => null,
791+
};
792+
793+
if (fileBytes == null || fileBytes.Length == 0)
794+
{
795+
EditorContext.Logger.Error($"Failed to download \"{item.DisplayName}\" (Id={item.Id})");
796+
return;
797+
}
798+
799+
var extension = item.OnlineApiEndpointGroup == OnlineApiEndpointGroup.Scenarios ? ".SC5" : ".dat";
800+
var safeName = Path.GetInvalidFileNameChars().Aggregate(item.DisplayName, (current, c) => current.Replace(c, '_'));
801+
var filename = Path.Combine(EditorContext.Settings.DownloadFolder, $"{safeName}-{item.Id}{extension}");
802+
await File.WriteAllBytesAsync(filename, fileBytes);
803+
EditorContext.Logger.Info($"Downloaded \"{item.DisplayName}\" to \"{filename}\"");
804+
}
805+
806+
async Task DownloadOnlinePackAsync(OnlineItemPackBrowseResult pack)
807+
{
808+
if (EditorContext.ObjectServiceClient == null)
809+
{
810+
return;
811+
}
812+
813+
byte[]? fileBytes = pack.Group switch
814+
{
815+
OnlineApiEndpointGroup.ObjectPacks => await EditorContext.ObjectServiceClient.GetObjectPackFileAsync(pack.Id),
816+
OnlineApiEndpointGroup.SC5FilePacks => await EditorContext.ObjectServiceClient.GetSC5FilePackFileAsync(pack.Id),
817+
_ => null,
818+
};
819+
820+
if (fileBytes == null || fileBytes.Length == 0)
821+
{
822+
EditorContext.Logger.Error($"Failed to download pack \"{pack.Name}\" (Id={pack.Id})");
823+
return;
824+
}
825+
826+
var safePackName = Path.GetInvalidFileNameChars().Aggregate(pack.Name, (current, c) => current.Replace(c, '_'));
827+
var filename = Path.Combine(EditorContext.Settings.DownloadFolder, $"{safePackName}.zip");
828+
await File.WriteAllBytesAsync(filename, fileBytes);
829+
EditorContext.Logger.Info($"Downloaded pack \"{pack.Name}\" to \"{filename}\"");
830+
}
774831
}

Gui/Views/FolderTreeView.axaml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,9 @@
158158
<materialIcons:MaterialIcon Kind="{Binding DisplayName, Converter={StaticResource EnumToMaterialIconConverter}}" Width="24" Height="24" Margin="2"/>
159159
<TextBlock VerticalAlignment="Center" Text="{Binding NameComputed}"/>
160160
<StackPanel.ContextMenu>
161-
<ContextMenu IsVisible="{Binding CanOpenFolder}">
162-
<MenuItem Header="Open folder" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
161+
<ContextMenu>
162+
<MenuItem Header="Open folder" IsVisible="{Binding CanOpenFolder}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
163+
<MenuItem Header="Download" IsVisible="{Binding CanDownload}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).DownloadOnlineItemCommand}" CommandParameter="{Binding}" />
163164
</ContextMenu>
164165
</StackPanel.ContextMenu>
165166
</StackPanel>
@@ -170,8 +171,9 @@
170171
<materialIcons:MaterialIcon Kind="{Binding DisplayName, Converter={StaticResource EnumToMaterialIconConverter}}" Width="24" Height="24" Margin="2" />
171172
<TextBlock VerticalAlignment="Center" Text="{Binding NameComputed}"/>
172173
<StackPanel.ContextMenu>
173-
<ContextMenu IsVisible="{Binding CanOpenFolder}">
174-
<MenuItem Header="Open folder" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
174+
<ContextMenu>
175+
<MenuItem Header="Open folder" IsVisible="{Binding CanOpenFolder}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).OpenFolderFor}" CommandParameter="{Binding}" />
176+
<MenuItem Header="Download" IsVisible="{Binding CanDownload}" Command="{Binding $parent[TreeDataGrid].((vm:FolderTreeViewModel)DataContext).DownloadOnlineItemCommand}" CommandParameter="{Binding}" />
175177
</ContextMenu>
176178
</StackPanel.ContextMenu>
177179
</StackPanel>

Gui/Views/OnlineBrowseResultsView.axaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,11 @@
8888
<StackPanel Grid.Column="1" VerticalAlignment="Center">
8989
<TextBlock FontSize="14" FontWeight="SemiBold" Text="{Binding Name}" />
9090
</StackPanel>
91-
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
92-
<TextBlock HorizontalAlignment="Right" Text="{Binding ItemCountText}" />
91+
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right" Orientation="Horizontal" Spacing="4">
92+
<TextBlock VerticalAlignment="Center" Text="{Binding ItemCountText}" />
93+
<Button ToolTip.Tip="Download pack" Command="{Binding $parent[UserControl].((vm:FolderTreeViewModel)DataContext).DownloadOnlinePackCommand}" CommandParameter="{Binding}">
94+
<materialIcons:MaterialIcon Kind="Download" Width="20" Height="20" />
95+
</Button>
9396
</StackPanel>
9497
</Grid>
9598
</Expander.Header>

ObjectService/RouteHandlers/TableHandlers/ObjectPackRouteHandler.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
using Definitions.Database;
22
using Definitions.DTO;
33
using Definitions.DTO.Mappers;
4+
using Definitions.ObjectModels.Types;
45
using Definitions.SourceData;
56
using Definitions.Web;
67
using Microsoft.AspNetCore.Mvc;
78
using Microsoft.EntityFrameworkCore;
9+
using System.IO.Compression;
810

911
namespace ObjectService.RouteHandlers.TableHandlers;
1012

@@ -21,7 +23,10 @@ public static void MapRoutes(IEndpointRouteBuilder endpoints)
2123
=> BaseTableRouteHandler.MapRoutes<ObjectPackRouteHandler>(endpoints);
2224

2325
public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute)
24-
{ }
26+
{
27+
var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute);
28+
_ = resourceRoute.MapGet(RoutesV2.File, GetObjectPackFileAsync);
29+
}
2530

2631
public static async Task<IResult> ListAsync(HttpContext context, [FromServices] LocoDbContext db)
2732
=> Results.Ok(
@@ -49,4 +54,52 @@ public static async Task<IResult> UpdateAsync([FromRoute] UniqueObjectId id, [Fr
4954

5055
public static async Task<IResult> DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db)
5156
=> await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented));
57+
58+
public static async Task<IResult> GetObjectPackFileAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp)
59+
{
60+
var pack = await db.ObjectPacks
61+
.Where(x => x.Id == id)
62+
.Include(x => x.Objects)
63+
.ThenInclude(o => o.DatObjects)
64+
.SingleOrDefaultAsync();
65+
66+
if (pack == null)
67+
{
68+
return Results.NotFound();
69+
}
70+
71+
var sfm = sp.GetRequiredService<ServerFolderManager>();
72+
var zipStream = new MemoryStream();
73+
74+
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
75+
{
76+
foreach (var obj in pack.Objects)
77+
{
78+
if (obj.ObjectSource is ObjectSource.LocomotionGoG or ObjectSource.LocomotionSteam)
79+
{
80+
continue;
81+
}
82+
83+
foreach (var dat in obj.DatObjects)
84+
{
85+
if (!sfm.ObjectIndex.TryFind((dat.DatName, dat.DatChecksum), out var entry) || entry == null || string.IsNullOrEmpty(entry.FileName))
86+
{
87+
continue;
88+
}
89+
90+
var filePath = Path.Combine(sfm.ObjectsFolder, entry.FileName);
91+
if (!File.Exists(filePath))
92+
{
93+
continue;
94+
}
95+
96+
// Use the relative path from the objects folder as the entry name to avoid duplicate filename collisions
97+
archive.CreateEntryFromFile(filePath, entry.FileName.Replace('\\', '/'));
98+
}
99+
}
100+
}
101+
102+
zipStream.Position = 0;
103+
return Results.File(zipStream, "application/zip", $"{pack.Name}.zip");
104+
}
52105
}

ObjectService/RouteHandlers/TableHandlers/SC5FilePackRouteHandler.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Definitions.Web;
66
using Microsoft.AspNetCore.Mvc;
77
using Microsoft.EntityFrameworkCore;
8+
using System.IO.Compression;
89

910
namespace ObjectService.RouteHandlers.TableHandlers;
1011

@@ -21,7 +22,10 @@ public static void MapRoutes(IEndpointRouteBuilder endpoints)
2122
=> BaseTableRouteHandler.MapRoutes<SC5FilePackRouteHandler>(endpoints);
2223

2324
public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute)
24-
{ }
25+
{
26+
var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute);
27+
_ = resourceRoute.MapGet(RoutesV2.File, GetSC5FilePackFileAsync);
28+
}
2529

2630
public static async Task<IResult> ListAsync(HttpContext context, [FromServices] LocoDbContext db)
2731
=> Results.Ok(
@@ -49,4 +53,38 @@ public static async Task<IResult> UpdateAsync([FromRoute] UniqueObjectId id, [Fr
4953

5054
public static async Task<IResult> DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db)
5155
=> await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented));
56+
57+
public static async Task<IResult> GetSC5FilePackFileAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] IServiceProvider sp)
58+
{
59+
var pack = await db.SC5FilePacks
60+
.Where(x => x.Id == id)
61+
.Include(x => x.SC5Files)
62+
.SingleOrDefaultAsync();
63+
64+
if (pack == null)
65+
{
66+
return Results.NotFound();
67+
}
68+
69+
var sfm = sp.GetRequiredService<ServerFolderManager>();
70+
var zipStream = new MemoryStream();
71+
72+
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true))
73+
{
74+
foreach (var sc5File in pack.SC5Files)
75+
{
76+
var filePath = Path.Combine(sfm.ScenariosFolder, sc5File.Name);
77+
if (!File.Exists(filePath))
78+
{
79+
continue;
80+
}
81+
82+
// Use the relative path as the entry name to avoid duplicate filename collisions
83+
archive.CreateEntryFromFile(filePath, sc5File.Name.Replace('\\', '/'));
84+
}
85+
}
86+
87+
zipStream.Position = 0;
88+
return Results.File(zipStream, "application/zip", $"{pack.Name}.zip");
89+
}
5290
}

ObjectService/RouteHandlers/TableHandlers/ScenarioRouteHandler.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,46 @@ public class ScenarioRouteHandler : ITableRouteHandler
1212
public static Delegate ReadDelegate => ReadAsync;
1313
public static Delegate UpdateDelegate => UpdateAsync;
1414
public static Delegate DeleteDelegate => DeleteAsync;
15+
1516
public static void MapRoutes(IEndpointRouteBuilder endpoints)
1617
=> BaseTableRouteHandler.MapRoutes<ScenarioRouteHandler>(endpoints);
1718

19+
public static void MapAdditionalRoutes(IEndpointRouteBuilder parentRoute)
20+
{
21+
var resourceRoute = parentRoute.MapGroup(RoutesV2.ResourceRoute);
22+
_ = resourceRoute.MapGet(RoutesV2.File, GetScenarioFileAsync);
23+
}
24+
25+
static string[] GetSortedScenarioFiles(string scenarioFolder)
26+
=> [.. Directory.GetFiles(scenarioFolder, "*.SC5", SearchOption.AllDirectories).OrderBy(x => x)];
27+
1828
static async Task<IResult> ListAsync([FromServices] IServiceProvider sp)
1929
=> await Task.Run(() =>
2030
{
2131
var sfm = sp.GetRequiredService<ServerFolderManager>();
2232
var scenarioFolder = sfm.ScenariosFolder;
23-
var files = Directory.GetFiles(scenarioFolder, "*.SC5", SearchOption.AllDirectories);
33+
var files = GetSortedScenarioFiles(scenarioFolder);
2434
var count = 0UL;
2535
var filenames = files.Select(x => new DtoScenarioEntry(count++, Path.GetRelativePath(scenarioFolder, x)));
2636
return Results.Ok(filenames.ToList());
2737
});
2838

39+
static async Task<IResult> GetScenarioFileAsync([FromRoute] UniqueObjectId id, [FromServices] IServiceProvider sp)
40+
=> await Task.Run(() =>
41+
{
42+
var sfm = sp.GetRequiredService<ServerFolderManager>();
43+
var files = GetSortedScenarioFiles(sfm.ScenariosFolder);
44+
45+
if (id >= (ulong)files.Length)
46+
{
47+
return Results.NotFound();
48+
}
49+
50+
var path = files[(int)id];
51+
const string contentType = "application/octet-stream";
52+
return Results.File(path, contentType, Path.GetFileName(path));
53+
});
54+
2955
static async Task<IResult> CreateAsync(DtoScenarioEntry request)
3056
=> await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented));
3157

0 commit comments

Comments
 (0)