From a666696a9f5c38f7b911e19f7e0e4b905ae71810 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 9 Sep 2026 21:59:07 +0000 Subject: [PATCH] Add location and asset merge tools with reference migration --- PlotLine.Tests/EntityMergeTests.cs | 31 +++ PlotLine.Tests/Program.cs | 1 + PlotLine.Tests/Sql/LocationAndAssetMerge.sql | 227 ++++++++++++++++++ PlotLine/Controllers/LocationsController.cs | 30 +++ PlotLine/Controllers/StoryAssetsController.cs | 30 +++ PlotLine/Data/Repositories.cs | 32 +++ PlotLine/Models/EntityMergeResult.cs | 9 + PlotLine/Services/CoreServices.cs | 72 ++++++ PlotLine/Sql/193_LocationAndAssetMerge.sql | 209 ++++++++++++++++ PlotLine/ViewModels/EntityMergeViewModel.cs | 22 ++ PlotLine/Views/Locations/Details.cshtml | 19 +- PlotLine/Views/Locations/Merge.cshtml | 53 ++++ PlotLine/Views/StoryAssets/Details.cshtml | 24 +- PlotLine/Views/StoryAssets/Merge.cshtml | 53 ++++ 14 files changed, 800 insertions(+), 12 deletions(-) create mode 100644 PlotLine.Tests/EntityMergeTests.cs create mode 100644 PlotLine.Tests/Sql/LocationAndAssetMerge.sql create mode 100644 PlotLine/Models/EntityMergeResult.cs create mode 100644 PlotLine/Sql/193_LocationAndAssetMerge.sql create mode 100644 PlotLine/ViewModels/EntityMergeViewModel.cs create mode 100644 PlotLine/Views/Locations/Merge.cshtml create mode 100644 PlotLine/Views/StoryAssets/Merge.cshtml diff --git a/PlotLine.Tests/EntityMergeTests.cs b/PlotLine.Tests/EntityMergeTests.cs new file mode 100644 index 0000000..a659f6a --- /dev/null +++ b/PlotLine.Tests/EntityMergeTests.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging.Abstractions; +using PlotLine.Services; +using PlotLine.ViewModels; + +internal static class EntityMergeTests +{ + public static void ConfirmationAndDistinctRecordsAreRequired() + { + // Null dependencies make any repository access before validation fail this test. + var locations = new LocationService(null!, null!, null!, null!, NullLogger.Instance); + var assets = new AssetService(null!, null!, null!, null!, null!, null!); + foreach (var merge in new Func[] + { + async form => { await locations.MergeAsync(form); }, + async form => { await assets.MergeAsync(form); } + }) + { + Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 2 }, "Confirm"); + Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 1, Confirm = true }, "different"); + Reject(merge, new EntityMergeForm { SourceID = 1, TargetID = 0, Confirm = true }, "different"); + Reject(merge, new EntityMergeForm { SourceID = 0, TargetID = 2, Confirm = true }, "different"); + } + } + + private static void Reject(Func merge, EntityMergeForm form, string message) + { + try { merge(form).GetAwaiter().GetResult(); } + catch (InvalidOperationException ex) when (ex.Message.Contains(message, StringComparison.Ordinal)) { return; } + throw new Exception("Invalid merge was not rejected before database access."); + } +} diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 314c8c8..f1dd120 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -19,6 +19,7 @@ using PlotLine.ViewModels; var tests = new (string Name, Action Test)[] { + ("Location and asset merges require confirmation and distinct records", EntityMergeTests.ConfirmationAndDistinctRecordsAreRequired), ("Location persisted analysis preserves contextual bedrooms and setting provenance", LocationSignificanceTests.PersistedAnalysisKeepsContextAndParentEvidence), ("Location consequence outranks capped recurrence", LocationSignificanceTests.ConsequenceOutranksFrequency), ("Location scene significance is attributed only to actual settings", LocationSignificanceTests.SceneContextBelongsToSetting), diff --git a/PlotLine.Tests/Sql/LocationAndAssetMerge.sql b/PlotLine.Tests/Sql/LocationAndAssetMerge.sql new file mode 100644 index 0000000..2d613d1 --- /dev/null +++ b/PlotLine.Tests/Sql/LocationAndAssetMerge.sql @@ -0,0 +1,227 @@ +-- Run against a migrated verification/dev database with at least one project and scene. +-- All fixture changes are rolled back, including on assertion failure. +SET XACT_ABORT ON; +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @SceneID int; + SELECT TOP (1) @ProjectID = p.ProjectID, @UserID = p.UserID, @SceneID = s.SceneID + FROM dbo.ProjectUserAccess p JOIN dbo.Books b ON b.ProjectID = p.ProjectID + JOIN dbo.Chapters c ON c.BookID = b.BookID JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID + WHERE p.IsActive = 1; + IF @SceneID IS NULL THROW 51001, 'Merge tests require a project with a scene and active user.', 1; + DECLARE @Source int, @Target int, @Child int; + INSERT dbo.Locations(ProjectID, LocationName) VALUES (@ProjectID, N'Merge test old observatory'); SET @Source = SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID, LocationName, Description) VALUES (@ProjectID, N'Merge test observatory', N'Keep target details'); SET @Target = SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID, LocationName, ParentLocationID) VALUES (@ProjectID, N'Merge test dome', @Source); SET @Child = SCOPE_IDENTITY(); + INSERT dbo.LocationAliases(LocationID, Alias) VALUES (@Source, N'North tower'), (@Target, N'North tower'), (@Source, N'Old dome'); + INSERT dbo.WordCompanionSceneLocations(SceneID, LocationID) VALUES (@SceneID, @Source), (@SceneID, @Target); + INSERT dbo.SceneLocationSuggestions(SceneID, LocationID, CreatedByUserID) VALUES (@SceneID, @Source, @UserID), (@SceneID, @Target, @UserID); + UPDATE dbo.Scenes SET PrimaryLocationID = @Source WHERE SceneID = @SceneID; + DECLARE @RelType int = (SELECT TOP (1) LocationRelationshipTypeID FROM dbo.LocationRelationshipTypes); + INSERT dbo.LocationRelationships(FromLocationID, ToLocationID, LocationRelationshipTypeID) + VALUES (@Source,@Target,@RelType),(@Source,@Child,@RelType),(@Target,@Child,@RelType); + EXEC dbo.Location_Merge @Source, @Target, @UserID; + IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Source AND IsArchived=1) THROW 51001, 'Source location was not archived.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Child AND ParentLocationID=@Target) THROW 51001, 'Child location was not moved.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.Scenes WHERE SceneID=@SceneID AND PrimaryLocationID=@Target) THROW 51001, 'Primary scene location was not moved.', 1; + IF (SELECT COUNT(*) FROM dbo.LocationAliases WHERE LocationID=@Target) <> 3 THROW 51001, 'Location names were lost or duplicated.', 1; + IF (SELECT COUNT(*) FROM dbo.WordCompanionSceneLocations WHERE SceneID=@SceneID AND LocationID=@Target) <> 1 THROW 51001, 'Duplicate location scene link.', 1; + IF (SELECT COUNT(*) FROM dbo.SceneLocationSuggestions WHERE SceneID=@SceneID AND LocationID=@Target AND Status=N'Pending') <> 1 THROW 51001, 'Duplicate location suggestion.', 1; + IF (SELECT COUNT(*) FROM dbo.LocationRelationships WHERE FromLocationID=@Target AND ToLocationID=@Child AND IsArchived=0) <> 1 THROW 51001, 'Duplicate location relationship.', 1; + IF EXISTS (SELECT 1 FROM dbo.LocationRelationships WHERE (FromLocationID=@Source OR ToLocationID=@Source) AND IsArchived=0) THROW 51001, 'Active relationship left on source.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.Locations WHERE LocationID=@Target AND Description=N'Keep target details') THROW 51001, 'Target location details changed.', 1; + + DECLARE @Kind int = (SELECT TOP (1) AssetKindID FROM dbo.AssetKinds), @Other int; + INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID) VALUES (@ProjectID,N'Merge test brass compass',@Kind); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID,Description) VALUES (@ProjectID,N'Merge test compass',@Kind,N'Keep target details'); SET @Target=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID, AssetName, AssetKindID) VALUES (@ProjectID,N'Merge test map',@Kind); SET @Other=SCOPE_IDENTITY(); + INSERT dbo.AssetAliases(StoryAssetID, Alias) VALUES (@Source,N'Navigator'),(@Target,N'Navigator'),(@Source,N'Brass guide'); + INSERT dbo.WordCompanionSceneAssets(SceneID,StoryAssetID) VALUES (@SceneID,@Source),(@SceneID,@Target); + INSERT dbo.SceneAssetSuggestions(SceneID,AssetID,CreatedByUserID) VALUES (@SceneID,@Source,@UserID),(@SceneID,@Target,@UserID); + DECLARE @EventType int=(SELECT TOP (1) AssetEventTypeID FROM dbo.AssetEventTypes), @CustodyType int=(SELECT TOP (1) AssetCustodyEventTypeID FROM dbo.AssetCustodyEventTypes); + INSERT dbo.AssetEvents(StoryAssetID,SceneID,AssetEventTypeID,EventTitle) VALUES (@Source,@SceneID,@EventType,N'Merge test event'); + DECLARE @EventID int=SCOPE_IDENTITY(); + INSERT dbo.AssetCustodyEvents(StoryAssetID,SceneID,AssetCustodyEventTypeID) VALUES (@Source,@SceneID,@CustodyType); + DECLARE @CustodyID int=SCOPE_IDENTITY(), @DepType int=(SELECT TOP (1) AssetDependencyTypeID FROM dbo.AssetDependencyTypes); + INSERT dbo.AssetDependencies(SourceAssetID,TargetAssetID,AssetDependencyTypeID) VALUES (@Source,@Target,@DepType),(@Source,@Other,@DepType),(@Target,@Other,@DepType); + EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID; + IF NOT EXISTS (SELECT 1 FROM dbo.StoryAssets WHERE StoryAssetID=@Source AND IsArchived=1) THROW 51001, 'Source asset was not archived.', 1; + IF (SELECT COUNT(*) FROM dbo.AssetAliases WHERE StoryAssetID=@Target) <> 3 THROW 51001, 'Asset names were lost or duplicated.', 1; + IF (SELECT COUNT(*) FROM dbo.WordCompanionSceneAssets WHERE SceneID=@SceneID AND StoryAssetID=@Target) <> 1 THROW 51001, 'Duplicate asset scene link.', 1; + IF (SELECT COUNT(*) FROM dbo.SceneAssetSuggestions WHERE SceneID=@SceneID AND AssetID=@Target AND Status=N'Pending') <> 1 THROW 51001, 'Duplicate asset suggestion.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.AssetEvents WHERE AssetEventID=@EventID AND StoryAssetID=@Target) THROW 51001, 'Asset event was not moved.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.AssetCustodyEvents WHERE AssetCustodyEventID=@CustodyID AND StoryAssetID=@Target) THROW 51001, 'Custody was not moved.', 1; + IF (SELECT COUNT(*) FROM dbo.AssetDependencies WHERE SourceAssetID=@Target AND TargetAssetID=@Other AND IsArchived=0) <> 1 THROW 51001, 'Duplicate asset dependency.', 1; + IF EXISTS (SELECT 1 FROM dbo.AssetDependencies WHERE (SourceAssetID=@Source OR TargetAssetID=@Source) AND IsArchived=0) THROW 51001, 'Active dependency left on source.', 1; + IF NOT EXISTS (SELECT 1 FROM dbo.StoryAssets WHERE StoryAssetID=@Target AND Description=N'Keep target details') THROW 51001, 'Target asset details changed.', 1; + ROLLBACK TRANSACTION; + SELECT N'PASS: location and asset merges; fixtures rolled back' AS Result; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; +END CATCH; +GO +-- Hierarchy failure must also leave the source unmodified (the procedure rolls back the fixtures). +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge cycle test parent'); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID,LocationName,ParentLocationID) VALUES (@ProjectID,N'Merge cycle test child',@Source); SET @Target=SCOPE_IDENTITY(); + EXEC dbo.Location_Merge @Source,@Target,@UserID; + THROW 51001, 'Hierarchy cycle was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 OR ERROR_MESSAGE() NOT LIKE '%descendants%' THROW; + SELECT N'PASS: hierarchy cycle rejected; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject same record for Location without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY(); + SET @Target=@Source; + EXEC dbo.Location_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: Location rejects same record; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject archived target for Location without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY(); + UPDATE dbo.Locations SET IsArchived=1 WHERE LocationID=@Target; + EXEC dbo.Location_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: Location rejects archived target; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject other project for Location without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY(); + INSERT dbo.Projects(ProjectName) VALUES (N'Merge test other project'); + DECLARE @OtherProjectID int=SCOPE_IDENTITY(); + UPDATE dbo.Locations SET ProjectID=@OtherProjectID WHERE LocationID=@Target; + EXEC dbo.Location_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: Location rejects other project; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject unauthorized user for Location without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation source'); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.Locations(ProjectID,LocationName) VALUES (@ProjectID,N'Merge validation target'); SET @Target=SCOPE_IDENTITY(); + SET @UserID=NULL; + EXEC dbo.Location_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: Location rejects unauthorized user; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject same record for StoryAsset without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY(); + SET @Target=@Source; + EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: StoryAsset rejects same record; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject archived target for StoryAsset without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY(); + UPDATE dbo.StoryAssets SET IsArchived=1 WHERE StoryAssetID=@Target; + EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: StoryAsset rejects archived target; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject other project for StoryAsset without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY(); + INSERT dbo.Projects(ProjectName) VALUES (N'Merge test other project'); + DECLARE @OtherProjectID int=SCOPE_IDENTITY(); + UPDATE dbo.StoryAssets SET ProjectID=@OtherProjectID WHERE StoryAssetID=@Target; + EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: StoryAsset rejects other project; fixtures rolled back' AS Result; +END CATCH; + +GO +-- Reject unauthorized user for StoryAsset without leaving fixture data behind. +BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @UserID int, @Source int, @Target int; + SELECT TOP (1) @ProjectID=ProjectID,@UserID=UserID FROM dbo.ProjectUserAccess WHERE IsActive=1; + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation source',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Source=SCOPE_IDENTITY(); + INSERT dbo.StoryAssets(ProjectID,AssetName,AssetKindID) VALUES (@ProjectID,N'Merge validation target',(SELECT TOP (1) AssetKindID FROM dbo.AssetKinds)); SET @Target=SCOPE_IDENTITY(); + SET @UserID=NULL; + EXEC dbo.StoryAsset_Merge @Source,@Target,@UserID; + THROW 51001, 'Invalid merge was accepted.', 1; +END TRY +BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + IF ERROR_NUMBER() <> 51000 THROW; + SELECT N'PASS: StoryAsset rejects unauthorized user; fixtures rolled back' AS Result; +END CATCH; diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs index 9bbd9a1..5744cc6 100644 --- a/PlotLine/Controllers/LocationsController.cs +++ b/PlotLine/Controllers/LocationsController.cs @@ -32,6 +32,36 @@ public sealed class LocationsController(ILocationService locations) : Controller return model is null ? NotFound() : View(model); } + public async Task Merge(int id) + { + var model = await locations.GetMergeAsync(id); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Merge([Bind(Prefix = "Form")] EntityMergeForm form) + { + if (ModelState.IsValid) + { + try + { + var result = await locations.MergeAsync(form); + if (result is null) return NotFound(); + TempData["LocationMessage"] = $"{result.SourceName} merged into {result.TargetName}."; + return RedirectToAction(nameof(Details), new { id = result.TargetID }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + } + } + var model = await locations.GetMergeAsync(form.SourceID); + if (model is null) return NotFound(); + model.Form = form; + return View(model); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Save(LocationEditViewModel model) diff --git a/PlotLine/Controllers/StoryAssetsController.cs b/PlotLine/Controllers/StoryAssetsController.cs index c69305e..c445ac2 100644 --- a/PlotLine/Controllers/StoryAssetsController.cs +++ b/PlotLine/Controllers/StoryAssetsController.cs @@ -32,6 +32,36 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller return model is null ? NotFound() : View(model); } + public async Task Merge(int id) + { + var model = await assets.GetMergeAsync(id); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Merge([Bind(Prefix = "Form")] EntityMergeForm form) + { + if (ModelState.IsValid) + { + try + { + var result = await assets.MergeAsync(form); + if (result is null) return NotFound(); + TempData["AssetMessage"] = $"{result.SourceName} merged into {result.TargetName}."; + return RedirectToAction(nameof(Details), new { id = result.TargetID }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + } + } + var model = await assets.GetMergeAsync(form.SourceID); + if (model is null) return NotFound(); + model.Form = form; + return View(model); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Save(StoryAssetEditViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 6453a8c..27d70e1 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -178,6 +178,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository public interface IAssetRepository { + Task MergeAsync(int sourceId, int targetId, int? userId); Task GetLookupsAsync(int? projectId = null); Task> ListAssetsAsync(int projectId); Task GetAssetAsync(int storyAssetId); @@ -260,6 +261,7 @@ public interface ICharacterRepository public interface ILocationRepository { + Task MergeAsync(int sourceId, int targetId, int? userId); Task GetLookupsAsync(); Task> ListByProjectAsync(int projectId); Task GetAsync(int locationId); @@ -486,6 +488,21 @@ public sealed class TimelineSceneOverviewRow public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository { + public async Task MergeAsync(int sourceId, int targetId, int? userId) + { + using var connection = connectionFactory.CreateConnection(); + try + { + return await connection.QuerySingleOrDefaultAsync( + "dbo.Location_Merge", new { SourceID = sourceId, TargetID = targetId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) when (ex.Number == 51000) + { + throw new InvalidOperationException(ex.Message, ex); + } + } + public async Task GetLookupsAsync() { using var connection = connectionFactory.CreateConnection(); @@ -3362,6 +3379,21 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository { + public async Task MergeAsync(int sourceId, int targetId, int? userId) + { + using var connection = connectionFactory.CreateConnection(); + try + { + return await connection.QuerySingleOrDefaultAsync( + "dbo.StoryAsset_Merge", new { SourceID = sourceId, TargetID = targetId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) when (ex.Number == 51000) + { + throw new InvalidOperationException(ex.Message, ex); + } + } + public async Task GetLookupsAsync(int? projectId = null) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/EntityMergeResult.cs b/PlotLine/Models/EntityMergeResult.cs new file mode 100644 index 0000000..0f03077 --- /dev/null +++ b/PlotLine/Models/EntityMergeResult.cs @@ -0,0 +1,9 @@ +namespace PlotLine.Models; + +public sealed class EntityMergeResult +{ + public int ProjectID { get; set; } + public int TargetID { get; set; } + public string SourceName { get; set; } = string.Empty; + public string TargetName { get; set; } = string.Empty; +} diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 98698f5..13528aa 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -189,6 +189,8 @@ public interface IPlotService public interface IAssetService { + Task GetMergeAsync(int sourceId); + Task MergeAsync(EntityMergeForm form); Task GetAssetsAsync(int projectId); Task GetCreateAssetAsync(int projectId); Task GetEditAssetAsync(int storyAssetId); @@ -208,6 +210,8 @@ public interface IAssetService public interface ILocationService { + Task GetMergeAsync(int sourceId); + Task MergeAsync(EntityMergeForm form); Task GetLocationsAsync(int projectId); Task GetCreateAsync(int projectId); Task GetEditAsync(int locationId); @@ -7910,6 +7914,40 @@ public sealed class AssetService( IVisualIdentityImageService visualIdentityImages, ICurrentUserService currentUser) : IAssetService { + public async Task GetMergeAsync(int sourceId) + { + var source = await assets.GetAssetAsync(sourceId); + if (source is null || source.IsArchived) return null; + var project = await projects.GetAsync(source.ProjectID); + if (project is null) return null; + return new EntityMergeViewModel + { + Project = project, + SourceName = source.AssetName, + Form = new EntityMergeForm { SourceID = sourceId }, + TargetOptions = (await assets.ListAssetsAsync(source.ProjectID)) + .Where(x => x.StoryAssetID != sourceId && !x.IsArchived) + .OrderBy(x => x.AssetName, StringComparer.OrdinalIgnoreCase) + .Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString())).ToList() + }; + } + + public async Task MergeAsync(EntityMergeForm form) + { + if (!form.Confirm) throw new InvalidOperationException("Confirm the merge before continuing."); + if (form.SourceID <= 0 || form.TargetID <= 0 || form.SourceID == form.TargetID) + throw new InvalidOperationException("Choose two different assets to merge."); + var source = await GetMergeAsync(form.SourceID); + if (source is null) return null; + if (!source.TargetOptions.Any(x => x.Value == form.TargetID.ToString())) + throw new InvalidOperationException("Choose an active asset from the same project."); + var result = await assets.MergeAsync(form.SourceID, form.TargetID, currentUser.UserId); + if (result is not null) + await activity.RecordAsync(result.ProjectID, "Merged", "Asset", result.TargetID, result.TargetName, + $"{result.SourceName} was merged into {result.TargetName}."); + return result; + } + public async Task GetAssetsAsync(int projectId) { var project = await projects.GetAsync(projectId); @@ -8219,6 +8257,40 @@ public sealed class LocationService( ICurrentUserService currentUser, ILogger logger) : ILocationService { + public async Task GetMergeAsync(int sourceId) + { + var source = await locations.GetAsync(sourceId); + if (source is null || source.IsArchived) return null; + var project = await projects.GetAsync(source.ProjectID); + if (project is null) return null; + return new EntityMergeViewModel + { + Project = project, + SourceName = source.LocationName, + Form = new EntityMergeForm { SourceID = sourceId }, + TargetOptions = (await locations.ListByProjectAsync(source.ProjectID)) + .Where(x => x.LocationID != sourceId && !x.IsArchived) + .OrderBy(x => x.LocationName, StringComparer.OrdinalIgnoreCase) + .Select(x => new SelectListItem(x.LocationName, x.LocationID.ToString())).ToList() + }; + } + + public async Task MergeAsync(EntityMergeForm form) + { + if (!form.Confirm) throw new InvalidOperationException("Confirm the merge before continuing."); + if (form.SourceID <= 0 || form.TargetID <= 0 || form.SourceID == form.TargetID) + throw new InvalidOperationException("Choose two different locations to merge."); + var source = await GetMergeAsync(form.SourceID); + if (source is null) return null; + if (!source.TargetOptions.Any(x => x.Value == form.TargetID.ToString())) + throw new InvalidOperationException("Choose an active location from the same project."); + var result = await locations.MergeAsync(form.SourceID, form.TargetID, currentUser.UserId); + if (result is not null) + await activity.RecordAsync(result.ProjectID, "Merged", "Location", result.TargetID, result.TargetName, + $"{result.SourceName} was merged into {result.TargetName}."); + return result; + } + public async Task GetLocationsAsync(int projectId) { var project = await projects.GetAsync(projectId); diff --git a/PlotLine/Sql/193_LocationAndAssetMerge.sql b/PlotLine/Sql/193_LocationAndAssetMerge.sql new file mode 100644 index 0000000..e5fece6 --- /dev/null +++ b/PlotLine/Sql/193_LocationAndAssetMerge.sql @@ -0,0 +1,209 @@ +-- Merge duplicate locations and story assets without deleting their archived source records. +-- Keep the selected target details; move canonical references and preserve known names. + +CREATE OR ALTER PROCEDURE dbo.Location_Merge + @SourceID int, + @TargetID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID + THROW 51000, 'Choose two different locations to merge.', 1; + + BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME(); + SELECT @ProjectID = ProjectID FROM dbo.Locations WHERE LocationID = @SourceID; + -- Serialize merges in a project before locking individual entities. + DECLARE @LockedProjectID int; + SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID; + IF @UserID IS NULL OR NOT EXISTS + (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1) + THROW 51000, 'You do not have access to this project.', 1; + SELECT @SourceName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) + WHERE LocationID = @SourceID AND IsArchived = 0; + SELECT @TargetName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) + WHERE LocationID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0; + IF @SourceName IS NULL OR @TargetName IS NULL + THROW 51000, 'Both locations must be active and belong to the same project.', 1; + + -- Merging an ancestor into its descendant would create a hierarchy cycle. + DECLARE @AncestorID int = @TargetID; + DECLARE @Visited TABLE (LocationID int PRIMARY KEY); + WHILE @AncestorID IS NOT NULL + BEGIN + IF @AncestorID = @SourceID OR EXISTS (SELECT 1 FROM @Visited WHERE LocationID = @AncestorID) + THROW 51000, 'Cannot merge a location into one of its descendants. Choose another location or change its parent first.', 1; + INSERT @Visited VALUES (@AncestorID); + SELECT @AncestorID = ParentLocationID FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) WHERE LocationID = @AncestorID; + END; + + INSERT dbo.LocationAliases (LocationID, Alias, SortOrder) + SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.LocationAliases WHERE LocationID = @TargetID), 0) + + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10 + FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue + UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.LocationAliases WHERE LocationID = @SourceID) names + WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName + AND NOT EXISTS (SELECT 1 FROM dbo.LocationAliases existing WHERE existing.LocationID = @TargetID + AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue)); + DELETE sourceRows FROM dbo.WordCompanionSceneLocations sourceRows + WHERE sourceRows.LocationID = @SourceID + AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneLocations targetRows WHERE targetRows.LocationID = @TargetID + AND targetRows.SceneID = sourceRows.SceneID); + DELETE sourceRows FROM dbo.SceneLocationSuggestions sourceRows + WHERE sourceRows.LocationID = @SourceID AND sourceRows.Status = N'Pending' + AND EXISTS (SELECT 1 FROM dbo.SceneLocationSuggestions targetRows WHERE targetRows.LocationID = @TargetID + AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status); + UPDATE dbo.Locations SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID; + UPDATE dbo.Scenes SET PrimaryLocationID = @TargetID WHERE PrimaryLocationID = @SourceID; + UPDATE dbo.SceneCharacters SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.SceneCharacters SET EntryLocationID = @TargetID WHERE EntryLocationID = @SourceID; + UPDATE dbo.SceneCharacters SET ExitLocationID = @TargetID WHERE ExitLocationID = @SourceID; + UPDATE dbo.StoryAssets SET CurrentLocationID = @TargetID WHERE CurrentLocationID = @SourceID; + UPDATE dbo.SceneAssetLocations SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.SceneFloorPlanOccupancy SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.WordCompanionSceneLocations SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.SceneLocationSuggestions SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.FloorPlans SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.FloorPlanFloors SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.FloorPlanBlocks SET LocationID = @TargetID WHERE LocationID = @SourceID; + UPDATE dbo.FloorPlanTransitions SET FromLocationID = @TargetID WHERE FromLocationID = @SourceID; + UPDATE dbo.FloorPlanTransitions SET ToLocationID = @TargetID WHERE ToLocationID = @SourceID; + UPDATE dbo.CoreImportBasicLocationCandidates SET ExistingLocationID = @TargetID WHERE ExistingLocationID = @SourceID; + UPDATE dbo.CoreImportBasicLocationCandidates SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID; + UPDATE dbo.CoreImportBasicLocationCandidates SET ResultLocationID = @TargetID WHERE ResultLocationID = @SourceID; + UPDATE dbo.CoreImportBasicLocationCandidates SET SameAsLocationID = @TargetID WHERE SameAsLocationID = @SourceID; + + -- Keep self-links archived with their original endpoints, as in character merging. + UPDATE dbo.LocationRelationships SET IsArchived = 1, UpdatedDate = @Now + WHERE (FromLocationID = @SourceID AND ToLocationID = @TargetID) OR (FromLocationID = @TargetID AND ToLocationID = @SourceID); + UPDATE dbo.LocationRelationships SET FromLocationID = CASE WHEN FromLocationID = @SourceID THEN @TargetID ELSE FromLocationID END, + ToLocationID = CASE WHEN ToLocationID = @SourceID THEN @TargetID ELSE ToLocationID END, UpdatedDate = @Now + WHERE IsArchived = 0 AND (FromLocationID = @SourceID OR ToLocationID = @SourceID); + -- Collapse identical active links while retaining links with different evidence/settings. + UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now + FROM dbo.LocationRelationships duplicate + WHERE duplicate.IsArchived = 0 AND (duplicate.FromLocationID = @TargetID OR duplicate.ToLocationID = @TargetID) + AND EXISTS (SELECT 1 FROM dbo.LocationRelationships kept WHERE kept.IsArchived = 0 + AND kept.LocationRelationshipID < duplicate.LocationRelationshipID + AND kept.FromLocationID = duplicate.FromLocationID AND kept.ToLocationID = duplicate.ToLocationID + AND (kept.LocationRelationshipTypeID = duplicate.LocationRelationshipTypeID OR (kept.LocationRelationshipTypeID IS NULL AND duplicate.LocationRelationshipTypeID IS NULL)) + AND (kept.IsBidirectional = duplicate.IsBidirectional OR (kept.IsBidirectional IS NULL AND duplicate.IsBidirectional IS NULL)) + AND (kept.Strength = duplicate.Strength OR (kept.Strength IS NULL AND duplicate.Strength IS NULL)) + AND (kept.CanHearNormalSpeech = duplicate.CanHearNormalSpeech OR (kept.CanHearNormalSpeech IS NULL AND duplicate.CanHearNormalSpeech IS NULL)) + AND (kept.CanHearLoudNoise = duplicate.CanHearLoudNoise OR (kept.CanHearLoudNoise IS NULL AND duplicate.CanHearLoudNoise IS NULL)) + AND (kept.CanSeeMovement = duplicate.CanSeeMovement OR (kept.CanSeeMovement IS NULL AND duplicate.CanSeeMovement IS NULL)) + AND (kept.CanSeeClearly = duplicate.CanSeeClearly OR (kept.CanSeeClearly IS NULL AND duplicate.CanSeeClearly IS NULL)) + AND (kept.CanTravelDirectly = duplicate.CanTravelDirectly OR (kept.CanTravelDirectly IS NULL AND duplicate.CanTravelDirectly IS NULL)) + AND (kept.Notes = duplicate.Notes OR (kept.Notes IS NULL AND duplicate.Notes IS NULL))); + + UPDATE dbo.Locations + SET IsArchived = 1, ArchivedDate = @Now, + ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (LocationID ', @TargetID, N').'), + UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now + WHERE LocationID = @SourceID; + COMMIT TRANSACTION; + SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryAsset_Merge + @SourceID int, + @TargetID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID + THROW 51000, 'Choose two different assets to merge.', 1; + + BEGIN TRY + BEGIN TRANSACTION; + DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME(); + SELECT @ProjectID = ProjectID FROM dbo.StoryAssets WHERE StoryAssetID = @SourceID; + -- Serialize merges in a project before locking individual entities. + DECLARE @LockedProjectID int; + SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID; + IF @UserID IS NULL OR NOT EXISTS + (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1) + THROW 51000, 'You do not have access to this project.', 1; + SELECT @SourceName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK) + WHERE StoryAssetID = @SourceID AND IsArchived = 0; + SELECT @TargetName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK) + WHERE StoryAssetID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0; + IF @SourceName IS NULL OR @TargetName IS NULL + THROW 51000, 'Both assets must be active and belong to the same project.', 1; + + INSERT dbo.AssetAliases (StoryAssetID, Alias, SortOrder) + SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.AssetAliases WHERE StoryAssetID = @TargetID), 0) + + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10 + FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue + UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.AssetAliases WHERE StoryAssetID = @SourceID) names + WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName + AND NOT EXISTS (SELECT 1 FROM dbo.AssetAliases existing WHERE existing.StoryAssetID = @TargetID + AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue)); + DELETE sourceRows FROM dbo.WordCompanionSceneAssets sourceRows + WHERE sourceRows.StoryAssetID = @SourceID + AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneAssets targetRows WHERE targetRows.StoryAssetID = @TargetID + AND targetRows.SceneID = sourceRows.SceneID); + DELETE sourceRows FROM dbo.SceneAssetSuggestions sourceRows + WHERE sourceRows.AssetID = @SourceID AND sourceRows.Status = N'Pending' + AND EXISTS (SELECT 1 FROM dbo.SceneAssetSuggestions targetRows WHERE targetRows.AssetID = @TargetID + AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status); + DELETE sourceRows FROM dbo.SceneFloorPlanOccupancy sourceRows + WHERE sourceRows.StoryAssetID = @SourceID + AND EXISTS (SELECT 1 FROM dbo.SceneFloorPlanOccupancy targetRows WHERE targetRows.StoryAssetID = @TargetID + AND targetRows.SceneID = sourceRows.SceneID); + DELETE sourceRows FROM dbo.ContinuityWarningAcknowledgements sourceRows + WHERE sourceRows.AssetID = @SourceID + AND EXISTS (SELECT 1 FROM dbo.ContinuityWarningAcknowledgements targetRows WHERE targetRows.AssetID = @TargetID + AND targetRows.ProjectID = sourceRows.ProjectID AND targetRows.WarningType = sourceRows.WarningType AND targetRows.SceneIDKey = sourceRows.SceneIDKey AND targetRows.CharacterIDKey = sourceRows.CharacterIDKey); + UPDATE dbo.AssetEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.AssetCustodyEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.CharacterKnowledge SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.SceneAssetLocations SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.SceneFloorPlanOccupancy SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.WordCompanionSceneAssets SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; + UPDATE dbo.SceneAssetSuggestions SET AssetID = @TargetID WHERE AssetID = @SourceID; + UPDATE dbo.ContinuityWarningAcknowledgements SET AssetID = @TargetID WHERE AssetID = @SourceID; + + -- Keep self-links archived with their original endpoints, as in character merging. + UPDATE dbo.AssetDependencies SET IsArchived = 1, UpdatedDate = @Now + WHERE (SourceAssetID = @SourceID AND TargetAssetID = @TargetID) OR (SourceAssetID = @TargetID AND TargetAssetID = @SourceID); + UPDATE dbo.AssetDependencies SET SourceAssetID = CASE WHEN SourceAssetID = @SourceID THEN @TargetID ELSE SourceAssetID END, + TargetAssetID = CASE WHEN TargetAssetID = @SourceID THEN @TargetID ELSE TargetAssetID END, UpdatedDate = @Now + WHERE IsArchived = 0 AND (SourceAssetID = @SourceID OR TargetAssetID = @SourceID); + -- Collapse identical active links while retaining links with different evidence/settings. + UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now + FROM dbo.AssetDependencies duplicate + WHERE duplicate.IsArchived = 0 AND (duplicate.SourceAssetID = @TargetID OR duplicate.TargetAssetID = @TargetID) + AND EXISTS (SELECT 1 FROM dbo.AssetDependencies kept WHERE kept.IsArchived = 0 + AND kept.AssetDependencyID < duplicate.AssetDependencyID + AND kept.SourceAssetID = duplicate.SourceAssetID AND kept.TargetAssetID = duplicate.TargetAssetID + AND (kept.AssetDependencyTypeID = duplicate.AssetDependencyTypeID OR (kept.AssetDependencyTypeID IS NULL AND duplicate.AssetDependencyTypeID IS NULL)) + AND (kept.SourceRequiredStateID = duplicate.SourceRequiredStateID OR (kept.SourceRequiredStateID IS NULL AND duplicate.SourceRequiredStateID IS NULL)) + AND (kept.TargetBlockedStateID = duplicate.TargetBlockedStateID OR (kept.TargetBlockedStateID IS NULL AND duplicate.TargetBlockedStateID IS NULL)) + AND (kept.Description = duplicate.Description OR (kept.Description IS NULL AND duplicate.Description IS NULL))); + + UPDATE dbo.StoryAssets + SET IsArchived = 1, ArchivedDate = @Now, + ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (StoryAssetID ', @TargetID, N').'), + UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now + WHERE StoryAssetID = @SourceID; + COMMIT TRANSACTION; + SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName; + END TRY + BEGIN CATCH + IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; + THROW; + END CATCH; +END; +GO diff --git a/PlotLine/ViewModels/EntityMergeViewModel.cs b/PlotLine/ViewModels/EntityMergeViewModel.cs new file mode 100644 index 0000000..e537230 --- /dev/null +++ b/PlotLine/ViewModels/EntityMergeViewModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.Rendering; +using PlotLine.Models; + +namespace PlotLine.ViewModels; + +public sealed class EntityMergeViewModel +{ + public Project Project { get; set; } = new(); + public string SourceName { get; set; } = string.Empty; + public EntityMergeForm Form { get; set; } = new(); + public IReadOnlyList TargetOptions { get; set; } = []; +} + +public sealed class EntityMergeForm +{ + [Range(1, int.MaxValue)] + public int SourceID { get; set; } + [Range(1, int.MaxValue, ErrorMessage = "Choose a record to merge into.")] + public int TargetID { get; set; } + public bool Confirm { get; set; } +} diff --git a/PlotLine/Views/Locations/Details.cshtml b/PlotLine/Views/Locations/Details.cshtml index 75ac6f0..16e5640 100644 --- a/PlotLine/Views/Locations/Details.cshtml +++ b/PlotLine/Views/Locations/Details.cshtml @@ -30,13 +30,20 @@

@Model.Location.LocationPath

- Edit All locations -
- - - -
+
diff --git a/PlotLine/Views/Locations/Merge.cshtml b/PlotLine/Views/Locations/Merge.cshtml new file mode 100644 index 0000000..d5d68f9 --- /dev/null +++ b/PlotLine/Views/Locations/Merge.cshtml @@ -0,0 +1,53 @@ +@model EntityMergeViewModel +@{ + ViewData["Title"] = $"Merge {Model.SourceName}"; + ViewData["ProjectSection"] = "Locations"; +} + + + + + +
+
+

Location identity

+

Merge @Model.SourceName

+

Known names, scene references, child locations, relationships and floor plan links move to the selected location. The source location is archived. The selected location keeps its details. A location cannot be merged into one of its descendants.

+
+ Cancel +
+ +@if (!Model.TargetOptions.Any()) +{ +
There are no other active locations in this project to merge with.
+} + +
+ +
+
+
+ + +
+
+ +
+
+ +
+ +@section Scripts { + +} diff --git a/PlotLine/Views/StoryAssets/Details.cshtml b/PlotLine/Views/StoryAssets/Details.cshtml index 96a743a..47c5cac 100644 --- a/PlotLine/Views/StoryAssets/Details.cshtml +++ b/PlotLine/Views/StoryAssets/Details.cshtml @@ -25,15 +25,27 @@
Story assets - Edit asset -
- - - -
+
+@if (TempData["AssetMessage"] is string assetMessage) +{ +
@assetMessage
+} +

Current state

diff --git a/PlotLine/Views/StoryAssets/Merge.cshtml b/PlotLine/Views/StoryAssets/Merge.cshtml new file mode 100644 index 0000000..d2ef260 --- /dev/null +++ b/PlotLine/Views/StoryAssets/Merge.cshtml @@ -0,0 +1,53 @@ +@model EntityMergeViewModel +@{ + ViewData["Title"] = $"Merge {Model.SourceName}"; + ViewData["ProjectSection"] = "Assets"; +} + + + + + +
+
+

Asset identity

+

Merge @Model.SourceName

+

Known names, scene references, events, custody, knowledge and dependencies move to the selected asset. The source asset is archived. The selected asset keeps its details.

+
+ Cancel +
+ +@if (!Model.TargetOptions.Any()) +{ +
There are no other active assets in this project to merge with.
+} + +
+ +
+
+
+ + +
+
+ +
+
+ +
+ +@section Scripts { + +}