From 98b542800324ecd806e4401d8873856f6df1c275 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:42:44 +0000 Subject: [PATCH 01/13] Initial plan From 8e8d82e37d6e3b097958aefc29dfdeda74a3911e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:52:36 +0000 Subject: [PATCH 02/13] Implement metadata editing and upload functionality - Made metadata fields editable in MetadataView.axaml (Description, CreatedDate, ModifiedDate) - Added two-way binding support in LocoObjectMetadataViewModel with ReactiveUI - Implemented server-side UpdateAsync endpoint in ObjectRouteHandler.cs - Added client-side UpdateObjectAsync method in Client.cs and ObjectServiceClient.cs - Integrated metadata upload in ObjectEditorViewModel.Save() for online mode Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- Definitions/Web/Client.cs | 9 +++ Gui/ObjectServiceClient.cs | 3 + Gui/ViewModels/LocoObjectMetadataViewModel.cs | 49 +++++++++++++- .../LocoTypes/ObjectEditorViewModel.cs | 64 +++++++++++++++++++ Gui/Views/MetadataView.axaml | 12 ++-- .../TableHandlers/ObjectRouteHandler.cs | 37 ++++++++++- 6 files changed, 165 insertions(+), 9 deletions(-) diff --git a/Definitions/Web/Client.cs b/Definitions/Web/Client.cs index e186900b..46ebe4ca 100644 --- a/Definitions/Web/Client.cs +++ b/Definitions/Web/Client.cs @@ -24,6 +24,15 @@ public static async Task> GetObjectListAsync(HttpCli id, logger); + public static async Task UpdateObjectAsync(HttpClient client, UniqueObjectId id, DtoObjectDescriptor 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, diff --git a/Gui/ObjectServiceClient.cs b/Gui/ObjectServiceClient.cs index f059b1a5..adc6b68e 100644 --- a/Gui/ObjectServiceClient.cs +++ b/Gui/ObjectServiceClient.cs @@ -64,6 +64,9 @@ public async Task> GetObjectListAsync() public async Task GetObjectAsync(UniqueObjectId id) => await Client.GetObjectAsync(WebClient, id, Logger); + public async Task UpdateObjectAsync(UniqueObjectId id, DtoObjectDescriptor request) + => await Client.UpdateObjectAsync(WebClient, id, request, Logger); + public async Task GetObjectFileAsync(UniqueObjectId id) => await Client.GetObjectFileAsync(WebClient, id, Logger); diff --git a/Gui/ViewModels/LocoObjectMetadataViewModel.cs b/Gui/ViewModels/LocoObjectMetadataViewModel.cs index 7b2749f4..baa6da55 100644 --- a/Gui/ViewModels/LocoObjectMetadataViewModel.cs +++ b/Gui/ViewModels/LocoObjectMetadataViewModel.cs @@ -1,10 +1,55 @@ using Definitions.ObjectModels; using ReactiveUI; +using System; namespace Gui.ViewModels; -public class LocoObjectMetadataViewModel(LocoObjectMetadata metadata) : ReactiveObject +public class LocoObjectMetadataViewModel : ReactiveObject { - public LocoObjectMetadata Metadata { get; } = metadata; + public LocoObjectMetadata Metadata { get; } + string? description; + public string? Description + { + get => description; + set + { + this.RaiseAndSetIfChanged(ref description, value); + Metadata.Description = 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; + } + } + + public LocoObjectMetadataViewModel(LocoObjectMetadata metadata) + { + Metadata = metadata; + description = metadata.Description; + createdDate = metadata.CreatedDate; + modifiedDate = metadata.ModifiedDate; + } + + public LocoObjectMetadataViewModel() : this(new LocoObjectMetadata("")) + { + } } diff --git a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index 20dd8c9f..57718278 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; @@ -348,6 +349,69 @@ 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) + { + _ = Task.Run(async () => await UploadMetadataAsync(CurrentFile.Id.Value)); + } + } + + async Task UploadMetadataAsync(UniqueObjectId objectId) + { + if (MetadataViewModel?.Metadata == null) + { + logger.Warning("Cannot upload metadata - metadata is null"); + return; + } + + try + { + logger.Info($"Uploading metadata for object {objectId}"); + + // Create DTO from current metadata + var dtoRequest = new DtoObjectDescriptor( + 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) ?? ObjectSource.Custom, + ObjectType: CurrentObject?.DatInfo?.S5Header.ObjectType.Convert() ?? ObjectType.Airport, + 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/Views/MetadataView.axaml b/Gui/Views/MetadataView.axaml index 42f643cd..2990713d 100644 --- a/Gui/Views/MetadataView.axaml +++ b/Gui/Views/MetadataView.axaml @@ -58,8 +58,8 @@ Grid.Column="1" Margin="4" VerticalAlignment="Center" - IsReadOnly="True" - Text="{Binding Description}" /> + IsReadOnly="False" + Text="{Binding Description, Mode=TwoWay}" /> + IsEnabled="True" + SelectedDate="{Binding CreatedDate, Mode=TwoWay}" /> + IsEnabled="True" + SelectedDate="{Binding ModifiedDate, Mode=TwoWay}" /> ReadAsync([FromRoute] UniqueObjectId id, [FromService static async Task UpdateAsync([FromRoute] UniqueObjectId id, DtoObjectDescriptor request, [FromServices] LocoDbContext db, [FromServices] ILogger logger) { logger.LogInformation("[UpdateAsync] Update requested for object {ObjectId}", id); - return await Task.Run(() => Results.Problem(statusCode: StatusCodes.Status501NotImplemented)); + + var obj = await db.Objects + .Include(x => x.Licence) + .Include(x => x.Authors) + .Include(x => x.Tags) + .Include(x => x.ObjectPacks) + .Where(x => x.Id == id) + .SingleOrDefaultAsync(); + + if (obj == null) + { + return Results.NotFound($"Object with id {id} not found"); + } + + // Update editable metadata fields + obj.Description = request.Description; + obj.CreatedDate = request.CreatedDate; + obj.ModifiedDate = request.ModifiedDate; + obj.Availability = request.Availability; + + // Save changes + _ = await db.SaveChangesAsync(); + + logger.LogInformation("[UpdateAsync] Successfully updated object {ObjectId}", id); + + // Return updated object + var updatedObj = await db.Objects + .Where(x => x.Id == id) + .Include(x => x.Licence) + .Include(x => x.DatObjects) + .Include(x => x.StringTable) + .Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)) + .SingleOrDefaultAsync(); + + var descriptor = updatedObj?.ToDtoDescriptor(); + return Results.Ok(descriptor); } static async Task DeleteAsync([FromRoute] UniqueObjectId id, [FromServices] LocoDbContext db, [FromServices] ILogger logger) From 6e552421cc8e63475afddef1093f934768b0de72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:55:11 +0000 Subject: [PATCH 03/13] Add test for metadata update endpoint - Updated ObjectRoutesTest to test the PUT endpoint for metadata updates - Test verifies that Description, CreatedDate, ModifiedDate, and Availability fields can be updated - Test passes successfully Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- .../ObjectRoutesTest.cs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Tests/ObjectServiceIntegrationTests/ObjectRoutesTest.cs b/Tests/ObjectServiceIntegrationTests/ObjectRoutesTest.cs index 1ef82463..9be7b9ec 100644 --- a/Tests/ObjectServiceIntegrationTests/ObjectRoutesTest.cs +++ b/Tests/ObjectServiceIntegrationTests/ObjectRoutesTest.cs @@ -36,7 +36,7 @@ protected override DtoObjectDescriptor PutResponseDto => throw new NotImplementedException(); protected override DtoUploadDat PutRequestDto - => throw new NotImplementedException(); + => throw new NotImplementedException("PUT operation uses DtoObjectDescriptor, not DtoUploadDat. Override PutAsync test instead."); //protected override DtoUploadDat PostRequestDto // => new(3, "test-name-3", "display-name-3", 123, "456", ObjectSource.Custom, ObjectType.Vehicle, Dat.Objects.VehicleType.Bus, Definitions.ObjectAvailability.Available, null, null, DateOnly.Today); @@ -224,4 +224,51 @@ [new DtoDatObjectEntry(1, "AZVOG15C", 3072098364, 7051740550869341430, 3)], // d AssertDtoObjectDescriptorsAreEqual(results, expected); } + + [Test] + public override async Task PutAsync() + { + // arrange + const int id = 2; + var existingObj = DbSeedData.ToList()[id - 1]; + var updatedDescription = "Updated description"; + var updatedCreatedDate = DateOnly.FromDateTime(new DateTime(2020, 1, 1)); + var updatedModifiedDate = DateOnly.FromDateTime(new DateTime(2024, 12, 15)); + + var updateRequest = new DtoObjectDescriptor( + Id: id, + Name: existingObj.Name, + DisplayName: "test-display-name-2", + DatChecksum: null, + Description: updatedDescription, + ObjectSource: existingObj.ObjectSource, + ObjectType: existingObj.ObjectType, + VehicleType: existingObj.VehicleType, + Availability: ObjectAvailability.Available, + CreatedDate: updatedCreatedDate, + ModifiedDate: updatedModifiedDate, + UploadedDate: DateOnly.UtcToday, + Licence: null, + Authors: [], + Tags: [], + ObjectPacks: [], + DatObjects: [], + StringTable: new DtoStringTableDescriptor([], id) + ); + + // act + var result = await ClientHelpers.PutAsync( + HttpClient!, RoutesV2.Prefix, BaseRoute, id, updateRequest); + + // assert + using (Assert.EnterMultipleScope()) + { + Assert.That(result, Is.Not.Null); + Assert.That(result!.Id, Is.EqualTo(id)); + Assert.That(result.Description, Is.EqualTo(updatedDescription)); + Assert.That(result.CreatedDate, Is.EqualTo(updatedCreatedDate)); + Assert.That(result.ModifiedDate, Is.EqualTo(updatedModifiedDate)); + Assert.That(result.Availability, Is.EqualTo(ObjectAvailability.Available)); + } + } } From 8ad869be52beda7ca12c250e9dbaece22e7908d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:57:35 +0000 Subject: [PATCH 04/13] Address code review feedback - Eliminated redundant database query in UpdateAsync endpoint - Added null check for CurrentObject.DatInfo before accessing - Improved error logging format - Removed unnecessary Task.Run wrapper Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- .../LocoTypes/ObjectEditorViewModel.cs | 18 ++++++++++++------ .../TableHandlers/ObjectRouteHandler.cs | 13 ++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index 57718278..91a613bb 100644 --- a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs @@ -353,7 +353,7 @@ public override void Save() // Upload metadata to server when in online mode if (CurrentFile.FileLocation == FileLocation.Online && CurrentFile.Id.HasValue && MetadataViewModel != null) { - _ = Task.Run(async () => await UploadMetadataAsync(CurrentFile.Id.Value)); + _ = UploadMetadataAsync(CurrentFile.Id.Value); } } @@ -365,6 +365,12 @@ async Task UploadMetadataAsync(UniqueObjectId objectId) return; } + if (CurrentObject?.DatInfo == null) + { + logger.Warning("Cannot upload metadata - DatInfo is null"); + return; + } + try { logger.Info($"Uploading metadata for object {objectId}"); @@ -374,12 +380,12 @@ async Task UploadMetadataAsync(UniqueObjectId objectId) Id: objectId, Name: MetadataViewModel.Metadata.InternalName, DisplayName: CurrentFile.DisplayName, - DatChecksum: CurrentObject?.DatInfo?.S5Header.Checksum, + DatChecksum: CurrentObject.DatInfo.S5Header.Checksum, Description: MetadataViewModel.Metadata.Description, - ObjectSource: CurrentObject?.DatInfo?.S5Header.ObjectSource.Convert( + ObjectSource: CurrentObject.DatInfo.S5Header.ObjectSource.Convert( CurrentObject.DatInfo.S5Header.Name, - CurrentObject.DatInfo.S5Header.Checksum) ?? ObjectSource.Custom, - ObjectType: CurrentObject?.DatInfo?.S5Header.ObjectType.Convert() ?? ObjectType.Airport, + CurrentObject.DatInfo.S5Header.Checksum), + ObjectType: CurrentObject.DatInfo.S5Header.ObjectType.Convert(), VehicleType: null, Availability: MetadataViewModel.Metadata.Availability, CreatedDate: MetadataViewModel.Metadata.CreatedDate.HasValue @@ -410,7 +416,7 @@ async Task UploadMetadataAsync(UniqueObjectId objectId) } catch (Exception ex) { - logger.Error($"Error uploading metadata for object {objectId}:", ex); + logger.Error($"Error uploading metadata for object {objectId}", ex); } } diff --git a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs index 38686976..b624f141 100644 --- a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs @@ -374,6 +374,8 @@ static async Task UpdateAsync([FromRoute] UniqueObjectId id, DtoObjectD .Include(x => x.Authors) .Include(x => x.Tags) .Include(x => x.ObjectPacks) + .Include(x => x.DatObjects) + .Include(x => x.StringTable) .Where(x => x.Id == id) .SingleOrDefaultAsync(); @@ -394,15 +396,8 @@ static async Task UpdateAsync([FromRoute] UniqueObjectId id, DtoObjectD logger.LogInformation("[UpdateAsync] Successfully updated object {ObjectId}", id); // Return updated object - var updatedObj = await db.Objects - .Where(x => x.Id == id) - .Include(x => x.Licence) - .Include(x => x.DatObjects) - .Include(x => x.StringTable) - .Select(x => new ExpandedTbl(x, x.Authors, x.Tags, x.ObjectPacks)) - .SingleOrDefaultAsync(); - - var descriptor = updatedObj?.ToDtoDescriptor(); + var expandedObj = new ExpandedTbl(obj, obj.Authors, obj.Tags, obj.ObjectPacks); + var descriptor = expandedObj.ToDtoDescriptor(); return Results.Ok(descriptor); } From 70fb9f926fb70e2030907fdaa9c9b7bdedccafe1 Mon Sep 17 00:00:00 2001 From: Benjamin Sutas Date: Tue, 30 Dec 2025 13:53:42 +1100 Subject: [PATCH 05/13] rename classes --- ...ocoObjectMetadata.cs => ObjectMetadata.cs} | 2 +- Gui/Converters/EnumToMaterialIconConverter.cs | 2 +- Gui/Models/LocoUIObjectModel.cs | 2 +- Gui/Models/ObjectEditorModel.cs | 8 ++--- Gui/ViewModels/FolderTreeViewModel.cs | 2 +- .../LocoTypes/ObjectEditorViewModel.cs | 4 +-- ...iewModel.cs => ObjectMetadataViewModel.cs} | 34 +++++++++---------- .../TableHandlers/ObjectRouteHandler.cs | 2 +- 8 files changed, 28 insertions(+), 28 deletions(-) rename Definitions/ObjectModels/{LocoObjectMetadata.cs => ObjectMetadata.cs} (94%) rename Gui/ViewModels/{LocoObjectMetadataViewModel.cs => ObjectMetadataViewModel.cs} (61%) 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/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..bdba041b 100644 --- a/Gui/Models/ObjectEditorModel.cs +++ b/Gui/Models/ObjectEditorModel.cs @@ -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/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/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index 91a613bb..5dd25556 100644 --- a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs @@ -51,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; } @@ -307,7 +307,7 @@ public override void Load() if (CurrentObject?.Metadata != null) { - MetadataViewModel = new LocoObjectMetadataViewModel(CurrentObject.Metadata); + MetadataViewModel = new ObjectMetadataViewModel(CurrentObject.Metadata); } else { diff --git a/Gui/ViewModels/LocoObjectMetadataViewModel.cs b/Gui/ViewModels/ObjectMetadataViewModel.cs similarity index 61% rename from Gui/ViewModels/LocoObjectMetadataViewModel.cs rename to Gui/ViewModels/ObjectMetadataViewModel.cs index baa6da55..7110d523 100644 --- a/Gui/ViewModels/LocoObjectMetadataViewModel.cs +++ b/Gui/ViewModels/ObjectMetadataViewModel.cs @@ -4,9 +4,21 @@ namespace Gui.ViewModels; -public class LocoObjectMetadataViewModel : ReactiveObject +public class ObjectMetadataViewModel : ReactiveObject { - public LocoObjectMetadata Metadata { get; } + public ObjectMetadataViewModel(ObjectMetadata metadata) + { + Metadata = metadata; + description = metadata.Description; + createdDate = metadata.CreatedDate; + modifiedDate = metadata.ModifiedDate; + } + + public ObjectMetadataViewModel() : this(new ObjectMetadata("")) + { + } + + public ObjectMetadata Metadata { get; } string? description; public string? Description @@ -14,7 +26,7 @@ public string? Description get => description; set { - this.RaiseAndSetIfChanged(ref description, value); + _ = this.RaiseAndSetIfChanged(ref description, value); Metadata.Description = value; } } @@ -25,7 +37,7 @@ public DateTimeOffset? CreatedDate get => createdDate; set { - this.RaiseAndSetIfChanged(ref createdDate, value); + _ = this.RaiseAndSetIfChanged(ref createdDate, value); Metadata.CreatedDate = value; } } @@ -36,20 +48,8 @@ public DateTimeOffset? ModifiedDate get => modifiedDate; set { - this.RaiseAndSetIfChanged(ref modifiedDate, value); + _ = this.RaiseAndSetIfChanged(ref modifiedDate, value); Metadata.ModifiedDate = value; } } - - public LocoObjectMetadataViewModel(LocoObjectMetadata metadata) - { - Metadata = metadata; - description = metadata.Description; - createdDate = metadata.CreatedDate; - modifiedDate = metadata.ModifiedDate; - } - - public LocoObjectMetadataViewModel() : this(new LocoObjectMetadata("")) - { - } } diff --git a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs index b624f141..c433c7a1 100644 --- a/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs +++ b/ObjectService/RouteHandlers/TableHandlers/ObjectRouteHandler.cs @@ -438,7 +438,7 @@ await db.Objects .TypeToStruct(objectType) .GetProperties(); - var metadataPropertiesForObject = typeof(ObjectMetadata).GetProperties(); + var metadataPropertiesForObject = typeof(Definitions.SourceData.ObjectMetadata).GetProperties(); var allProperties = locoPropertiesForObject.Union(metadataPropertiesForObject); From ae4edac7dec9f8140a790cf885dc20bb2eccca35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:01:57 +0000 Subject: [PATCH 06/13] Expose all editable metadata properties in ViewModel and UI - Added Availability property to ObjectMetadataViewModel with two-way binding - Added InternalName property (readonly) to ViewModel - Added UploadedDate property to ViewModel - Updated MetadataView.axaml to show all properties with appropriate controls - Changed Availability from readonly TextBox to editable ComboBox - Added InternalName field display - Fixed DataContext binding to use ViewModel properties instead of direct Metadata binding - Updated type references from LocoObjectMetadataViewModel to ObjectMetadataViewModel Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- Gui/App.axaml | 2 +- Gui/ViewModels/ObjectMetadataViewModel.cs | 33 +++++++++++ Gui/Views/MetadataView.axaml | 71 ++++++++++++++--------- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/Gui/App.axaml b/Gui/App.axaml index f686c07f..8c908407 100644 --- a/Gui/App.axaml +++ b/Gui/App.axaml @@ -112,7 +112,7 @@ - + diff --git a/Gui/ViewModels/ObjectMetadataViewModel.cs b/Gui/ViewModels/ObjectMetadataViewModel.cs index 7110d523..3c9f16c1 100644 --- a/Gui/ViewModels/ObjectMetadataViewModel.cs +++ b/Gui/ViewModels/ObjectMetadataViewModel.cs @@ -1,6 +1,9 @@ +using Definitions; using Definitions.ObjectModels; using ReactiveUI; using System; +using System.Collections.Generic; +using System.Linq; namespace Gui.ViewModels; @@ -10,8 +13,10 @@ public ObjectMetadataViewModel(ObjectMetadata metadata) { Metadata = metadata; description = metadata.Description; + availability = metadata.Availability; createdDate = metadata.CreatedDate; modifiedDate = metadata.ModifiedDate; + uploadedDate = metadata.UploadedDate; } public ObjectMetadataViewModel() : this(new ObjectMetadata("")) @@ -20,6 +25,12 @@ public ObjectMetadataViewModel() : this(new ObjectMetadata("")) public ObjectMetadata Metadata { get; } + // InternalName is readonly (init-only in the model) + public string InternalName => Metadata.InternalName; + + // Available values for Availability enum + public IEnumerable AvailabilityValues => Enum.GetValues(); + string? description; public string? Description { @@ -31,6 +42,17 @@ public string? Description } } + ObjectAvailability availability; + public ObjectAvailability Availability + { + get => availability; + set + { + _ = this.RaiseAndSetIfChanged(ref availability, value); + Metadata.Availability = value; + } + } + DateTimeOffset? createdDate; public DateTimeOffset? CreatedDate { @@ -52,4 +74,15 @@ public DateTimeOffset? ModifiedDate Metadata.ModifiedDate = value; } } + + DateTimeOffset uploadedDate; + public DateTimeOffset UploadedDate + { + get => uploadedDate; + set + { + _ = this.RaiseAndSetIfChanged(ref uploadedDate, value); + Metadata.UploadedDate = value; + } + } } diff --git a/Gui/Views/MetadataView.axaml b/Gui/Views/MetadataView.axaml index 2990713d..17a8280f 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,95 +44,109 @@ Margin="4" VerticalAlignment="Center" IsReadOnly="True" - Text="{Binding UniqueObjectId}" /> + Text="{Binding Metadata.UniqueObjectId}" /> + Text="Internal Name" /> + IsReadOnly="True" + Text="{Binding InternalName}" /> - + + IsReadOnly="False" + Text="{Binding Description, Mode=TwoWay}" /> - + + ItemsSource="{Binding AvailabilityValues}" + SelectedItem="{Binding Availability, Mode=TwoWay}" /> + Text="Created Date" /> + IsEnabled="True" + SelectedDate="{Binding CreatedDate, Mode=TwoWay}" /> - + + IsEnabled="True" + SelectedDate="{Binding ModifiedDate, Mode=TwoWay}" /> - + + + + + Text="{Binding Metadata.Licence.Name}" /> - + @@ -151,7 +164,7 @@ - + @@ -167,7 +180,7 @@ - + @@ -183,7 +196,7 @@ - + From 2c7e1c6ec2d665654bb19d60f79415b682dd1ea9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:04:17 +0000 Subject: [PATCH 07/13] Make UploadedDate truly readonly in ViewModel - Changed UploadedDate to direct property binding (no setter) - Removed backing field for UploadedDate - Changed binding mode to OneWay in MetadataView.axaml Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- Gui/ViewModels/ObjectMetadataViewModel.cs | 13 ++----------- Gui/Views/MetadataView.axaml | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Gui/ViewModels/ObjectMetadataViewModel.cs b/Gui/ViewModels/ObjectMetadataViewModel.cs index 3c9f16c1..0c337814 100644 --- a/Gui/ViewModels/ObjectMetadataViewModel.cs +++ b/Gui/ViewModels/ObjectMetadataViewModel.cs @@ -16,7 +16,6 @@ public ObjectMetadataViewModel(ObjectMetadata metadata) availability = metadata.Availability; createdDate = metadata.CreatedDate; modifiedDate = metadata.ModifiedDate; - uploadedDate = metadata.UploadedDate; } public ObjectMetadataViewModel() : this(new ObjectMetadata("")) @@ -75,14 +74,6 @@ public DateTimeOffset? ModifiedDate } } - DateTimeOffset uploadedDate; - public DateTimeOffset UploadedDate - { - get => uploadedDate; - set - { - _ = this.RaiseAndSetIfChanged(ref uploadedDate, value); - Metadata.UploadedDate = value; - } - } + // UploadedDate is readonly (server-managed) + public DateTimeOffset UploadedDate => Metadata.UploadedDate; } diff --git a/Gui/Views/MetadataView.axaml b/Gui/Views/MetadataView.axaml index 17a8280f..b706a33c 100644 --- a/Gui/Views/MetadataView.axaml +++ b/Gui/Views/MetadataView.axaml @@ -128,7 +128,7 @@ Margin="4" VerticalAlignment="Center" IsEnabled="False" - SelectedDate="{Binding UploadedDate}" /> + SelectedDate="{Binding UploadedDate, Mode=OneWay}" /> Date: Tue, 30 Dec 2025 14:05:05 +1100 Subject: [PATCH 08/13] remove old oldt namespace --- Gui/App.axaml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Gui/App.axaml b/Gui/App.axaml index 8c908407..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 @@ - From 22d06e060c7476e38c59996d11c095e5309b6e00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:19:38 +0000 Subject: [PATCH 09/13] Implement all requested metadata editing features 1. Made Availability and InternalName readonly (removed editable ComboBox for Availability) 2. Made Licence editable as ComboBox with values from /licences endpoint, includes "None" option 3. Added UI for editing Authors, Tags, and ObjectPacks with add/remove functionality 4. Added ReactiveCommands for add/remove operations 5. Fetch available Authors, Tags, ObjectPacks, and Licences from server 6. Sync collections back to Metadata model when changed Co-authored-by: LeftofZen <7483209+LeftofZen@users.noreply.github.com> --- Definitions/Web/Client.cs | 32 +++ Gui/ObjectServiceClient.cs | 12 + .../LocoTypes/ObjectEditorViewModel.cs | 2 +- Gui/ViewModels/ObjectMetadataViewModel.cs | 189 +++++++++++++++- Gui/Views/MetadataView.axaml | 207 +++++++++++++----- 5 files changed, 382 insertions(+), 60 deletions(-) diff --git a/Definitions/Web/Client.cs b/Definitions/Web/Client.cs index 46ebe4ca..2a86574c 100644 --- a/Definitions/Web/Client.cs +++ b/Definitions/Web/Client.cs @@ -63,4 +63,36 @@ public static async Task> GetObjectListAsync(HttpCli 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/ObjectServiceClient.cs b/Gui/ObjectServiceClient.cs index adc6b68e..eb2689e4 100644 --- a/Gui/ObjectServiceClient.cs +++ b/Gui/ObjectServiceClient.cs @@ -75,4 +75,16 @@ public async Task> GetObjectListAsync() public async Task AddMissingObjectAsync(DtoObjectMissingUpload 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/LocoTypes/ObjectEditorViewModel.cs b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs index 5dd25556..dbe648fa 100644 --- a/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs +++ b/Gui/ViewModels/LocoTypes/ObjectEditorViewModel.cs @@ -307,7 +307,7 @@ public override void Load() if (CurrentObject?.Metadata != null) { - MetadataViewModel = new ObjectMetadataViewModel(CurrentObject.Metadata); + MetadataViewModel = new ObjectMetadataViewModel(CurrentObject.Metadata, Model.ObjectServiceClient); } else { diff --git a/Gui/ViewModels/ObjectMetadataViewModel.cs b/Gui/ViewModels/ObjectMetadataViewModel.cs index 0c337814..a35bec68 100644 --- a/Gui/ViewModels/ObjectMetadataViewModel.cs +++ b/Gui/ViewModels/ObjectMetadataViewModel.cs @@ -1,21 +1,86 @@ 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 { - public ObjectMetadataViewModel(ObjectMetadata metadata) + readonly Gui.ObjectServiceClient? objectServiceClient; + + public ObjectMetadataViewModel(ObjectMetadata metadata, Gui.ObjectServiceClient? objectServiceClient = null) { Metadata = metadata; description = metadata.Description; - availability = metadata.Availability; createdDate = metadata.CreatedDate; modifiedDate = metadata.ModifiedDate; + selectedLicence = metadata.Licence; + + // 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(); + } } public ObjectMetadataViewModel() : this(new ObjectMetadata("")) @@ -27,8 +92,114 @@ public ObjectMetadataViewModel() : this(new ObjectMetadata("")) // InternalName is readonly (init-only in the model) public string InternalName => Metadata.InternalName; - // Available values for Availability enum - public IEnumerable AvailabilityValues => Enum.GetValues(); + // 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) + { + // 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 @@ -41,14 +212,14 @@ public string? Description } } - ObjectAvailability availability; - public ObjectAvailability Availability + DtoLicenceEntry? selectedLicence; + public DtoLicenceEntry? SelectedLicence { - get => availability; + get => selectedLicence; set { - _ = this.RaiseAndSetIfChanged(ref availability, value); - Metadata.Availability = value; + _ = this.RaiseAndSetIfChanged(ref selectedLicence, value); + Metadata.Licence = value; } } diff --git a/Gui/Views/MetadataView.axaml b/Gui/Views/MetadataView.axaml index b706a33c..f4008482 100644 --- a/Gui/Views/MetadataView.axaml +++ b/Gui/Views/MetadataView.axaml @@ -80,13 +80,13 @@ Margin="4" VerticalAlignment="Center" Text="Availability" /> - + IsReadOnly="True" + Text="{Binding Availability}" /> - + ItemsSource="{Binding AvailableLicences}" + SelectedItem="{Binding SelectedLicence, Mode=TwoWay}"> + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + +