diff --git a/Dat/Loaders/WaterObjectLoader.cs b/Dat/Loaders/WaterObjectLoader.cs index 9f6f0d2f..8e61571b 100644 --- a/Dat/Loaders/WaterObjectLoader.cs +++ b/Dat/Loaders/WaterObjectLoader.cs @@ -6,6 +6,7 @@ using Definitions.ObjectModels.Types; namespace Dat.Loaders; + public abstract class WaterObjectLoader : IDatObjectLoader { public static class Constants diff --git a/Definitions/DTO/Comparers/DtoObjectDescriptorComparer.cs b/Definitions/DTO/Comparers/DtoObjectDescriptorComparer.cs index 9580d5b9..9f44c1ea 100644 --- a/Definitions/DTO/Comparers/DtoObjectDescriptorComparer.cs +++ b/Definitions/DTO/Comparers/DtoObjectDescriptorComparer.cs @@ -2,9 +2,9 @@ namespace Definitions.DTO.Comparers; -public class DtoObjectDescriptorComparer : IEqualityComparer +public class DtoObjectDescriptorComparer : IEqualityComparer { - public bool Equals(DtoObjectDescriptor? x, DtoObjectDescriptor? y) + public bool Equals(DtoObjectPostResponse? x, DtoObjectPostResponse? y) { if (x is null || y is null) { @@ -31,6 +31,6 @@ public bool Equals(DtoObjectDescriptor? x, DtoObjectDescriptor? y) && x.StringTable.Equals(y.StringTable); } - public int GetHashCode([DisallowNull] DtoObjectDescriptor obj) + public int GetHashCode([DisallowNull] DtoObjectPostResponse obj) => obj.GetHashCode(); } diff --git a/Definitions/DTO/Comparers/DtoObjectMissingUploadComparer.cs b/Definitions/DTO/Comparers/DtoObjectMissingUploadComparer.cs index 9014b6bc..e2524b0e 100644 --- a/Definitions/DTO/Comparers/DtoObjectMissingUploadComparer.cs +++ b/Definitions/DTO/Comparers/DtoObjectMissingUploadComparer.cs @@ -2,9 +2,9 @@ namespace Definitions.DTO.Comparers; -public class DtoObjectMissingUploadComparer : IEqualityComparer +public class DtoObjectMissingUploadComparer : IEqualityComparer { - public bool Equals(DtoObjectMissingUpload? x, DtoObjectMissingUpload? y) + public bool Equals(DtoObjectMissingPost? x, DtoObjectMissingPost? y) { if (x is null || y is null) { @@ -17,6 +17,6 @@ public bool Equals(DtoObjectMissingUpload? x, DtoObjectMissingUpload? y) && x.ObjectType == y.ObjectType; } - public int GetHashCode([DisallowNull] DtoObjectMissingUpload obj) + public int GetHashCode([DisallowNull] DtoObjectMissingPost obj) => HashCode.Combine(obj.DatName, obj.DatChecksum, obj.ObjectType); } diff --git a/Definitions/DTO/DtoObjectMissingUpload.cs b/Definitions/DTO/DtoObjectMissingPost.cs similarity index 77% rename from Definitions/DTO/DtoObjectMissingUpload.cs rename to Definitions/DTO/DtoObjectMissingPost.cs index 0d15f2a2..c0671c31 100644 --- a/Definitions/DTO/DtoObjectMissingUpload.cs +++ b/Definitions/DTO/DtoObjectMissingPost.cs @@ -2,7 +2,7 @@ namespace Definitions.DTO; -public record DtoObjectMissingUpload( +public record DtoObjectMissingPost( string DatName, uint32_t DatChecksum, ObjectType ObjectType); diff --git a/Definitions/DTO/DtoUploadDat.cs b/Definitions/DTO/DtoObjectPost.cs similarity index 84% rename from Definitions/DTO/DtoUploadDat.cs rename to Definitions/DTO/DtoObjectPost.cs index 1cfe9e5b..d7bc2a51 100644 --- a/Definitions/DTO/DtoUploadDat.cs +++ b/Definitions/DTO/DtoObjectPost.cs @@ -1,6 +1,6 @@ namespace Definitions.DTO; -public record DtoUploadDat( +public record DtoObjectPost( string DatBytesAsBase64, ulong xxHash3, ObjectAvailability InitialAvailability, diff --git a/Definitions/DTO/DtoObjectDescriptor.cs b/Definitions/DTO/DtoObjectPostResponse.cs similarity index 94% rename from Definitions/DTO/DtoObjectDescriptor.cs rename to Definitions/DTO/DtoObjectPostResponse.cs index 993d8cae..c99f0a67 100644 --- a/Definitions/DTO/DtoObjectDescriptor.cs +++ b/Definitions/DTO/DtoObjectPostResponse.cs @@ -4,7 +4,7 @@ namespace Definitions.DTO; -public record DtoObjectDescriptor( +public record DtoObjectPostResponse( UniqueObjectId Id, string Name, string DisplayName, diff --git a/Definitions/DTO/Mappers/DtoExtensions.cs b/Definitions/DTO/Mappers/DtoExtensions.cs index f29d61a7..cf22eb83 100644 --- a/Definitions/DTO/Mappers/DtoExtensions.cs +++ b/Definitions/DTO/Mappers/DtoExtensions.cs @@ -6,7 +6,7 @@ namespace Definitions.DTO.Mappers; public static class DtoExtensions { - public static DtoObjectDescriptor ToDtoDescriptor(this ExpandedTbl x /*, IDtoSubObject SubObject*/) + public static DtoObjectPostResponse ToDtoDescriptor(this ExpandedTbl x /*, IDtoSubObject SubObject*/) => new( x!.Object.Id, x!.Object.Name, diff --git a/Definitions/ObjectModels/LocoObjectMetadata.cs b/Definitions/ObjectModels/ObjectMetadata.cs similarity index 94% rename from Definitions/ObjectModels/LocoObjectMetadata.cs rename to Definitions/ObjectModels/ObjectMetadata.cs index c3dea497..36888c9c 100644 --- a/Definitions/ObjectModels/LocoObjectMetadata.cs +++ b/Definitions/ObjectModels/ObjectMetadata.cs @@ -4,7 +4,7 @@ namespace Definitions.ObjectModels; -public class LocoObjectMetadata(string internalName) +public class ObjectMetadata(string internalName) { public UniqueObjectId UniqueObjectId { get; init; } diff --git a/Definitions/ObjectModels/Objects/ScenarioText/ScenarioTextObject.cs b/Definitions/ObjectModels/Objects/ScenarioText/ScenarioTextObject.cs index 8edb36ff..93c94360 100644 --- a/Definitions/ObjectModels/Objects/ScenarioText/ScenarioTextObject.cs +++ b/Definitions/ObjectModels/Objects/ScenarioText/ScenarioTextObject.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; namespace Definitions.ObjectModels.Objects.ScenarioText; + public class ScenarioTextObject : ILocoStruct { public IEnumerable Validate(ValidationContext validationContext) diff --git a/Definitions/Web/Client.cs b/Definitions/Web/Client.cs index e186900b..ecb09b58 100644 --- a/Definitions/Web/Client.cs +++ b/Definitions/Web/Client.cs @@ -16,14 +16,23 @@ public static async Task> GetObjectListAsync(HttpCli null, logger) ?? []; - public static async Task GetObjectAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null) - => await ClientHelpers.GetAsync( + public static async Task GetObjectAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null) + => await ClientHelpers.GetAsync( client, ApiVersion, RoutesV2.Objects, id, logger); + public static async Task UpdateObjectAsync(HttpClient client, UniqueObjectId id, DtoObjectPostResponse request, ILogger? logger = null) + => await ClientHelpers.PutAsync( + client, + ApiVersion, + RoutesV2.Objects, + id, + request, + logger); + public static async Task GetObjectFileAsync(HttpClient client, UniqueObjectId id, ILogger? logger = null) => await ClientHelpers.SendRequestAsync( client, @@ -32,26 +41,58 @@ public static async Task> GetObjectListAsync(HttpCli ClientHelpers.ReadBinaryContentAsync, logger) ?? default; - public static async Task UploadDatFileAsync(HttpClient client, string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate, ILogger logger) + public static async Task UploadDatFileAsync(HttpClient client, string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate, ILogger logger) { var xxHash3 = XxHash3.HashToUInt64(datFileBytes); logger.Debug($"Posting {filename} to {client.BaseAddress?.OriginalString}{RoutesV2.Objects}"); - var request = new DtoUploadDat(Convert.ToBase64String(datFileBytes), xxHash3, ObjectAvailability.Available, creationDate, modifiedDate); - return await ClientHelpers.PostAsync( + var request = new DtoObjectPost(Convert.ToBase64String(datFileBytes), xxHash3, ObjectAvailability.Available, creationDate, modifiedDate); + return await ClientHelpers.PostAsync( client, ApiVersion, RoutesV2.Objects, request); } - public static async Task AddMissingObjectAsync(HttpClient client, DtoObjectMissingUpload entry, ILogger? logger = null) + public static async Task AddMissingObjectAsync(HttpClient client, DtoObjectMissingPost entry, ILogger? logger = null) { logger?.Debug($"Posting missing object {entry.DatName} with checksum {entry.DatChecksum} to {client.BaseAddress?.OriginalString}{RoutesV2.Objects}{RoutesV2.Missing}"); - return await ClientHelpers.PostAsync( + return await ClientHelpers.PostAsync( client, ApiVersion, RoutesV2.Objects + RoutesV2.Missing, entry, logger); } + + public static async Task> GetLicencesAsync(HttpClient client, ILogger? logger = null) + => await ClientHelpers.GetAsync>( + client, + ApiVersion, + RoutesV2.Licences, + null, + logger) ?? []; + + public static async Task> GetAuthorsAsync(HttpClient client, ILogger? logger = null) + => await ClientHelpers.GetAsync>( + client, + ApiVersion, + RoutesV2.Authors, + null, + logger) ?? []; + + public static async Task> GetTagsAsync(HttpClient client, ILogger? logger = null) + => await ClientHelpers.GetAsync>( + client, + ApiVersion, + RoutesV2.Tags, + null, + logger) ?? []; + + public static async Task> GetObjectPacksAsync(HttpClient client, ILogger? logger = null) + => await ClientHelpers.GetAsync>( + client, + ApiVersion, + RoutesV2.ObjectPacks, + null, + logger) ?? []; } diff --git a/Gui/App.axaml b/Gui/App.axaml index f686c07f..01d2b8e6 100644 --- a/Gui/App.axaml +++ b/Gui/App.axaml @@ -7,7 +7,6 @@ xmlns:domt="using:Definitions.ObjectModels.Types" xmlns:vm="using:Gui.ViewModels" xmlns:vi="using:Gui.Views" - xmlns:oldt="using:Dat.Types" xmlns:log="using:Common.Logging" xmlns:cnv="using:Gui.Converters" xmlns:pgc="clr-namespace:Avalonia.PropertyGrid.Controls;assembly=Avalonia.PropertyGrid" @@ -97,12 +96,6 @@ - @@ -112,7 +105,7 @@ - + diff --git a/Gui/Converters/EnumToMaterialIconConverter.cs b/Gui/Converters/EnumToMaterialIconConverter.cs index 363e7248..906b2a03 100644 --- a/Gui/Converters/EnumToMaterialIconConverter.cs +++ b/Gui/Converters/EnumToMaterialIconConverter.cs @@ -58,7 +58,7 @@ public class EnumToMaterialIconConverter : IValueConverter static readonly Dictionary MiscMappings = new() { { nameof(ObjectIndexEntry), "ViewList" }, - { nameof(LocoObjectMetadata), "ViewListOutline" }, + { nameof(ObjectMetadata), "ViewListOutline" }, }; static readonly Dictionary ObjectMapping = new() diff --git a/Gui/Models/LocoUIObjectModel.cs b/Gui/Models/LocoUIObjectModel.cs index 20f06da7..39d7811c 100644 --- a/Gui/Models/LocoUIObjectModel.cs +++ b/Gui/Models/LocoUIObjectModel.cs @@ -8,6 +8,6 @@ namespace Gui.Models; public class LocoUIObjectModel { public LocoObject? LocoObject { get; set; } - public LocoObjectMetadata? Metadata { get; set; } + public ObjectMetadata? Metadata { get; set; } public DatHeaderInfo? DatInfo { get; set; } } diff --git a/Gui/Models/ObjectEditorModel.cs b/Gui/Models/ObjectEditorModel.cs index c4bd037e..9e560ec9 100644 --- a/Gui/Models/ObjectEditorModel.cs +++ b/Gui/Models/ObjectEditorModel.cs @@ -31,7 +31,7 @@ public class ObjectEditorModel : IDisposable public ObjectIndex? ObjectIndexOnline { get; set; } - public Dictionary OnlineCache { get; } = []; + public Dictionary OnlineCache { get; } = []; public PaletteMap PaletteMap { get; set; } @@ -197,7 +197,7 @@ bool TryLoadOnlineFile(FileSystemItem filesystemItem, out LocoUIObjectModel? loc DatHeaderInfo? fileInfo = null; LocoObject? locoObject = null; - LocoObjectMetadata? metadata = null; + ObjectMetadata? metadata = null; //List> images = []; if (filesystemItem.Id == null) @@ -304,7 +304,7 @@ bool TryLoadOnlineFile(FileSystemItem filesystemItem, out LocoUIObjectModel? loc fileInfo = new DatHeaderInfo(fakeS5Header, ObjectHeader.NullHeader); } - metadata = new LocoObjectMetadata(cachedLocoObjDto.Name) + metadata = new ObjectMetadata(cachedLocoObjDto.Name) { UniqueObjectId = cachedLocoObjDto.Id, Description = cachedLocoObjDto.Description, @@ -342,7 +342,7 @@ bool TryLoadLocalFile(FileSystemItem filesystemItem, out LocoUIObjectModel? loco DatHeaderInfo? fileInfo = null; LocoObject? locoObject = null; - LocoObjectMetadata? metadata = null; + ObjectMetadata? metadata = null; var filename = File.Exists(filesystemItem.FileName) ? filesystemItem.FileName @@ -351,7 +351,7 @@ bool TryLoadLocalFile(FileSystemItem filesystemItem, out LocoUIObjectModel? loco var obj = SawyerStreamReader.LoadFullObject(filename, logger: Logger); fileInfo = obj.DatFileInfo; locoObject = obj.LocoObject; - metadata = new LocoObjectMetadata("") + metadata = new ObjectMetadata("") { CreatedDate = filesystemItem.CreatedDate?.ToDateTimeOffset(), ModifiedDate = filesystemItem.ModifiedDate?.ToDateTimeOffset(), diff --git a/Gui/ObjectServiceClient.cs b/Gui/ObjectServiceClient.cs index f059b1a5..1820f944 100644 --- a/Gui/ObjectServiceClient.cs +++ b/Gui/ObjectServiceClient.cs @@ -61,15 +61,30 @@ public ObjectServiceClient(EditorSettings settings, ILogger logger) public async Task> GetObjectListAsync() => await Client.GetObjectListAsync(WebClient, Logger); - public async Task GetObjectAsync(UniqueObjectId id) + public async Task GetObjectAsync(UniqueObjectId id) => await Client.GetObjectAsync(WebClient, id, Logger); + public async Task UpdateObjectAsync(UniqueObjectId id, DtoObjectPostResponse request) + => await Client.UpdateObjectAsync(WebClient, id, request, Logger); + public async Task GetObjectFileAsync(UniqueObjectId id) => await Client.GetObjectFileAsync(WebClient, id, Logger); - public async Task UploadDatFileAsync(string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate) + public async Task UploadDatFileAsync(string filename, byte[] datFileBytes, DateOnly creationDate, DateOnly modifiedDate) => await Client.UploadDatFileAsync(WebClient, filename, datFileBytes, creationDate, modifiedDate, Logger); - public async Task AddMissingObjectAsync(DtoObjectMissingUpload entry) + public async Task AddMissingObjectAsync(DtoObjectMissingPost entry) => await Client.AddMissingObjectAsync(WebClient, entry, Logger); + + public async Task> GetLicencesAsync() + => await Client.GetLicencesAsync(WebClient, Logger); + + public async Task> GetAuthorsAsync() + => await Client.GetAuthorsAsync(WebClient, Logger); + + public async Task> GetTagsAsync() + => await Client.GetTagsAsync(WebClient, Logger); + + public async Task> GetObjectPacksAsync() + => await Client.GetObjectPacksAsync(WebClient, Logger); } diff --git a/Gui/ViewModels/FolderTreeViewModel.cs b/Gui/ViewModels/FolderTreeViewModel.cs index 271eae0b..d654ed59 100644 --- a/Gui/ViewModels/FolderTreeViewModel.cs +++ b/Gui/ViewModels/FolderTreeViewModel.cs @@ -47,7 +47,7 @@ public DesignerFolderTreeViewModel() var availableFilterCategories = new List { new() { Type = typeof(ObjectIndexEntry), DisplayName = "Index data", IconName = nameof(ObjectIndexEntry) }, - new() { Type = typeof (LocoObjectMetadata), DisplayName = "Metadata", IconName = nameof(LocoObjectMetadata) } + new() { Type = typeof (ObjectMetadata), DisplayName = "Metadata", IconName = nameof(ObjectMetadata) } }; //Filters.Add(new FilterViewModel(availableFilterCategories, RemoveFilter)); diff --git a/Gui/ViewModels/LocoObjectMetadataViewModel.cs b/Gui/ViewModels/LocoObjectMetadataViewModel.cs deleted file mode 100644 index 7b2749f4..00000000 --- a/Gui/ViewModels/LocoObjectMetadataViewModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Definitions.ObjectModels; -using ReactiveUI; - -namespace Gui.ViewModels; - -public class LocoObjectMetadataViewModel(LocoObjectMetadata metadata) : ReactiveObject -{ - public LocoObjectMetadata Metadata { get; } = metadata; - -} diff --git a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index cbe307b8..2e45d8c0 100644 --- a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs @@ -5,6 +5,7 @@ using Dat.Converters; using Dat.Data; using Dat.FileParsing; +using Definitions.DTO; using Definitions.ObjectModels; using Definitions.ObjectModels.Objects.Common; using Definitions.ObjectModels.Objects.Sound; @@ -50,7 +51,7 @@ public class ObjectEditorViewModel : BaseFileViewModel public LocoUIObjectModel? CurrentObject { get; private set; } [Reactive] - public LocoObjectMetadataViewModel? MetadataViewModel { get; set; } + public ObjectMetadataViewModel? MetadataViewModel { get; set; } [Reactive] public ObjectModelHeaderViewModel? ObjectModelHeaderViewModel { get; set; } @@ -306,7 +307,7 @@ public override void Load() if (CurrentObject?.Metadata != null) { - MetadataViewModel = new LocoObjectMetadataViewModel(CurrentObject.Metadata); + MetadataViewModel = new ObjectMetadataViewModel(CurrentObject.Metadata, Model.ObjectServiceClient, logger); } else { @@ -348,6 +349,82 @@ public override void Save() ? CurrentFile.FileName : Path.Combine(Model.Settings.DownloadFolder, Path.ChangeExtension($"{CurrentFile.DisplayName}-{CurrentFile.Id}", ".dat")); SaveCore(savePath, new SaveParameters(SaveType.DAT, ObjectDatHeaderViewModel?.DatEncoding)); + + // Upload metadata to server when in online mode + if (CurrentFile.FileLocation == FileLocation.Online && CurrentFile.Id.HasValue && MetadataViewModel != null) + { + _ = UploadMetadataAsync(CurrentFile.Id.Value).ContinueWith(t => + { + // Observe any exceptions to prevent unobserved task exceptions + if (t.Exception != null) + { + logger.Error("Unhandled exception in metadata upload", t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + } + + async Task UploadMetadataAsync(UniqueObjectId objectId) + { + if (MetadataViewModel?.Metadata == null) + { + logger.Warning("Cannot upload metadata - metadata is null"); + return; + } + + if (CurrentObject?.DatInfo == null) + { + logger.Warning("Cannot upload metadata - DatInfo is null"); + return; + } + + try + { + logger.Info($"Uploading metadata for object {objectId}"); + + // Create DTO from current metadata + var dtoRequest = new DtoObjectPostResponse( + Id: objectId, + Name: MetadataViewModel.Metadata.InternalName, + DisplayName: CurrentFile.DisplayName, + DatChecksum: CurrentObject.DatInfo.S5Header.Checksum, + Description: MetadataViewModel.Metadata.Description, + ObjectSource: CurrentObject.DatInfo.S5Header.ObjectSource.Convert( + CurrentObject.DatInfo.S5Header.Name, + CurrentObject.DatInfo.S5Header.Checksum), + ObjectType: CurrentObject.DatInfo.S5Header.ObjectType.Convert(), + VehicleType: null, + Availability: MetadataViewModel.Metadata.Availability, + CreatedDate: MetadataViewModel.Metadata.CreatedDate.HasValue + ? DateOnly.FromDateTime(MetadataViewModel.Metadata.CreatedDate.Value.UtcDateTime) + : null, + ModifiedDate: MetadataViewModel.Metadata.ModifiedDate.HasValue + ? DateOnly.FromDateTime(MetadataViewModel.Metadata.ModifiedDate.Value.UtcDateTime) + : null, + UploadedDate: DateOnly.FromDateTime(MetadataViewModel.Metadata.UploadedDate.UtcDateTime), + Licence: MetadataViewModel.Metadata.Licence, + Authors: MetadataViewModel.Metadata.Authors, + Tags: MetadataViewModel.Metadata.Tags, + ObjectPacks: MetadataViewModel.Metadata.ObjectPacks, + DatObjects: MetadataViewModel.Metadata.DatObjects, + StringTable: new DtoStringTableDescriptor(new Dictionary>(), objectId) + ); + + var result = await Model.ObjectServiceClient.UpdateObjectAsync(objectId, dtoRequest); + + if (result != null) + { + logger.Info($"Successfully uploaded metadata for object {objectId}"); + } + else + { + logger.Error($"Failed to upload metadata for object {objectId}"); + } + } + catch (Exception ex) + { + logger.Error($"Error uploading metadata for object {objectId}", ex); + } } public override string? SaveAs(SaveParameters saveParameters) diff --git a/Gui/ViewModels/LocoTypes/Objects/CargoViewModel.cs b/Gui/ViewModels/LocoTypes/Objects/CargoViewModel.cs index d5702fb1..9ca0bd63 100644 --- a/Gui/ViewModels/LocoTypes/Objects/CargoViewModel.cs +++ b/Gui/ViewModels/LocoTypes/Objects/CargoViewModel.cs @@ -30,7 +30,7 @@ public CargoCategory CargoCategory } } - [Description("Allows the user to set a custom value for the cargo category.")] + [Category("Cargo"), Description("Allows the user to set a custom value for the cargo category.")] public uint16_t CargoCategoryOverride { get => (uint16_t)Model.CargoCategory; diff --git a/Gui/ViewModels/LocoTypes/Objects/Vehicle/VehicleViewModel.cs b/Gui/ViewModels/LocoTypes/Objects/Vehicle/VehicleViewModel.cs index 623da1be..00909c9b 100644 --- a/Gui/ViewModels/LocoTypes/Objects/Vehicle/VehicleViewModel.cs +++ b/Gui/ViewModels/LocoTypes/Objects/Vehicle/VehicleViewModel.cs @@ -463,7 +463,7 @@ void SynchronizeCargoTypeSpriteOffsets(NotifyCollectionChangedEventArgs e) { var isStillUsed = CompatibleCargo1.CargoCategories.Any(x => x.Category == item.Category) || CompatibleCargo2.CargoCategories.Any(x => x.Category == item.Category); - + if (!isStillUsed) { _ = CargoTypeSpriteOffsets.Remove(offsetToRemove); diff --git a/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs b/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs index 87c57174..59c4e76f 100644 --- a/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs +++ b/Gui/ViewModels/LocoTypes/SCV5ViewModel.cs @@ -164,7 +164,7 @@ async Task DownloadMissingObjects(GameObjDataFolder targetFolder) logger.Error($"Couldn't find a matching object in the online index. Name=\"{obj.Name}\" Checksum={obj.Checksum} ObjectType={obj.ObjectType} "); // Add this missing object to the server's missing objects list - var missingEntry = new DtoObjectMissingUpload( + var missingEntry = new DtoObjectMissingPost( obj.Name, obj.Checksum, obj.ObjectType.Convert()); diff --git a/Gui/ViewModels/ObjectMetadataViewModel.cs b/Gui/ViewModels/ObjectMetadataViewModel.cs new file mode 100644 index 00000000..762687b9 --- /dev/null +++ b/Gui/ViewModels/ObjectMetadataViewModel.cs @@ -0,0 +1,263 @@ +using Common.Logging; +using Definitions; +using Definitions.DTO; +using Definitions.ObjectModels; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; + +namespace Gui.ViewModels; + +public class ObjectMetadataViewModel : ReactiveObject +{ + readonly Gui.ObjectServiceClient? objectServiceClient; + readonly ILogger? logger; + + public ObjectMetadataViewModel(ObjectMetadata metadata, Gui.ObjectServiceClient? objectServiceClient = null, ILogger? logger = null) + { + Metadata = metadata; + description = metadata.Description; + createdDate = metadata.CreatedDate; + modifiedDate = metadata.ModifiedDate; + selectedLicence = metadata.Licence; + this.logger = logger; + + // Initialize observable collections from metadata + Authors = new ObservableCollection(metadata.Authors); + Tags = new ObservableCollection(metadata.Tags); + ObjectPacks = new ObservableCollection(metadata.ObjectPacks); + + this.objectServiceClient = objectServiceClient; + + // Initialize commands + AddAuthorCommand = ReactiveCommand.Create(author => + { + if (author != null && !Authors.Contains(author)) + { + Authors.Add(author); + SyncAuthorsToMetadata(); + } + }); + + RemoveAuthorCommand = ReactiveCommand.Create(author => + { + Authors.Remove(author); + SyncAuthorsToMetadata(); + }); + + AddTagCommand = ReactiveCommand.Create(tag => + { + if (tag != null && !Tags.Contains(tag)) + { + Tags.Add(tag); + SyncTagsToMetadata(); + } + }); + + RemoveTagCommand = ReactiveCommand.Create(tag => + { + Tags.Remove(tag); + SyncTagsToMetadata(); + }); + + AddObjectPackCommand = ReactiveCommand.Create(pack => + { + if (pack != null && !ObjectPacks.Contains(pack)) + { + ObjectPacks.Add(pack); + SyncObjectPacksToMetadata(); + } + }); + + RemoveObjectPackCommand = ReactiveCommand.Create(pack => + { + ObjectPacks.Remove(pack); + SyncObjectPacksToMetadata(); + }); + + // Load data from server if we have a client + if (objectServiceClient != null) + { + _ = LoadServerDataAsync().ContinueWith(t => + { + // Log any exceptions that occur + if (t.Exception != null) + { + logger?.Error("Failed to load server data for metadata editing", t.Exception); + } + }, TaskContinuationOptions.OnlyOnFaulted); + } + } + + public ObjectMetadataViewModel() : this(new ObjectMetadata("")) + { + } + + public ObjectMetadata Metadata { get; } + + // InternalName is readonly (init-only in the model) + public string InternalName => Metadata.InternalName; + + // Availability is readonly (user cannot change this) + public ObjectAvailability Availability => Metadata.Availability; + + // Collections for editing + public ObservableCollection Authors { get; } + public ObservableCollection Tags { get; } + public ObservableCollection ObjectPacks { get; } + + // Commands + public ReactiveCommand AddAuthorCommand { get; } + public ReactiveCommand RemoveAuthorCommand { get; } + public ReactiveCommand AddTagCommand { get; } + public ReactiveCommand RemoveTagCommand { get; } + public ReactiveCommand AddObjectPackCommand { get; } + public ReactiveCommand RemoveObjectPackCommand { get; } + + // Available items for selection + ObservableCollection availableAuthors = []; + public ObservableCollection AvailableAuthors + { + get => availableAuthors; + set => this.RaiseAndSetIfChanged(ref availableAuthors, value); + } + + ObservableCollection availableTags = []; + public ObservableCollection AvailableTags + { + get => availableTags; + set => this.RaiseAndSetIfChanged(ref availableTags, value); + } + + ObservableCollection availableObjectPacks = []; + public ObservableCollection AvailableObjectPacks + { + get => availableObjectPacks; + set => this.RaiseAndSetIfChanged(ref availableObjectPacks, value); + } + + // Available licences + ObservableCollection availableLicences = []; + public ObservableCollection AvailableLicences + { + get => availableLicences; + set => this.RaiseAndSetIfChanged(ref availableLicences, value); + } + + async Task LoadServerDataAsync() + { + if (objectServiceClient == null) + { + return; + } + + try + { + // Load licences + var licences = await objectServiceClient.GetLicencesAsync(); + var licenceList = new List { null }; // Add None option + licenceList.AddRange(licences); + AvailableLicences = new ObservableCollection(licenceList); + + // Load authors, tags, and object packs + var authors = await objectServiceClient.GetAuthorsAsync(); + AvailableAuthors = new ObservableCollection(authors); + + var tags = await objectServiceClient.GetTagsAsync(); + AvailableTags = new ObservableCollection(tags); + + var objectPacks = await objectServiceClient.GetObjectPacksAsync(); + AvailableObjectPacks = new ObservableCollection(objectPacks); + } + catch (Exception ex) + { + // Log the exception so users know why data failed to load + logger?.Error("Failed to load server data for metadata editing", ex); + + // If we can't load data (e.g., offline mode), just set empty lists + AvailableLicences = new ObservableCollection { null }; + AvailableAuthors = new ObservableCollection(); + AvailableTags = new ObservableCollection(); + AvailableObjectPacks = new ObservableCollection(); + } + } + + // Commands for adding/removing items + void SyncAuthorsToMetadata() + { + Metadata.Authors.Clear(); + foreach (var author in Authors) + { + Metadata.Authors.Add(author); + } + } + + void SyncTagsToMetadata() + { + Metadata.Tags.Clear(); + foreach (var tag in Tags) + { + Metadata.Tags.Add(tag); + } + } + + void SyncObjectPacksToMetadata() + { + Metadata.ObjectPacks.Clear(); + foreach (var pack in ObjectPacks) + { + Metadata.ObjectPacks.Add(pack); + } + } + + string? description; + public string? Description + { + get => description; + set + { + _ = this.RaiseAndSetIfChanged(ref description, value); + Metadata.Description = value; + } + } + + DtoLicenceEntry? selectedLicence; + public DtoLicenceEntry? SelectedLicence + { + get => selectedLicence; + set + { + _ = this.RaiseAndSetIfChanged(ref selectedLicence, value); + Metadata.Licence = value; + } + } + + DateTimeOffset? createdDate; + public DateTimeOffset? CreatedDate + { + get => createdDate; + set + { + _ = this.RaiseAndSetIfChanged(ref createdDate, value); + Metadata.CreatedDate = value; + } + } + + DateTimeOffset? modifiedDate; + public DateTimeOffset? ModifiedDate + { + get => modifiedDate; + set + { + _ = this.RaiseAndSetIfChanged(ref modifiedDate, value); + Metadata.ModifiedDate = value; + } + } + + // UploadedDate is readonly (server-managed) + public DateTimeOffset UploadedDate => Metadata.UploadedDate; +} diff --git a/Gui/Views/MetadataView.axaml b/Gui/Views/MetadataView.axaml index 42f643cd..036cbf34 100644 --- a/Gui/Views/MetadataView.axaml +++ b/Gui/Views/MetadataView.axaml @@ -8,13 +8,12 @@ xmlns:vm="using:Gui.ViewModels" d:DesignHeight="450" d:DesignWidth="800" - x:DataType="vm:LocoObjectMetadataViewModel" + x:DataType="vm:ObjectMetadataViewModel" mc:Ignorable="d"> + Background="{DynamicResource ExpanderContentBackground}"> @@ -45,145 +44,266 @@ Margin="4" VerticalAlignment="Center" IsReadOnly="True" - Text="{Binding UniqueObjectId}" /> + Text="{Binding Metadata.UniqueObjectId}" /> + Text="Internal Name" /> + Text="{Binding InternalName}" /> - + + IsReadOnly="False" + Text="{Binding Description, Mode=TwoWay}" /> - + + IsReadOnly="True" + Text="{Binding Availability}" /> + Text="Created Date" /> + IsEnabled="True" + SelectedDate="{Binding CreatedDate, Mode=TwoWay}" /> - + + IsEnabled="True" + SelectedDate="{Binding ModifiedDate, Mode=TwoWay}" /> - + + IsEnabled="False" + SelectedDate="{Binding UploadedDate, Mode=OneWay}" /> + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + +