Add location and asset merge tools with reference migration
This commit is contained in:
parent
54eced0ad1
commit
a666696a9f
31
PlotLine.Tests/EntityMergeTests.cs
Normal file
31
PlotLine.Tests/EntityMergeTests.cs
Normal file
@ -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<LocationService>.Instance);
|
||||
var assets = new AssetService(null!, null!, null!, null!, null!, null!);
|
||||
foreach (var merge in new Func<EntityMergeForm, Task>[]
|
||||
{
|
||||
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<EntityMergeForm, Task> 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.");
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
227
PlotLine.Tests/Sql/LocationAndAssetMerge.sql
Normal file
227
PlotLine.Tests/Sql/LocationAndAssetMerge.sql
Normal file
@ -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;
|
||||
@ -32,6 +32,36 @@ public sealed class LocationsController(ILocationService locations) : Controller
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Merge(int id)
|
||||
{
|
||||
var model = await locations.GetMergeAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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<IActionResult> Save(LocationEditViewModel model)
|
||||
|
||||
@ -32,6 +32,36 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Merge(int id)
|
||||
{
|
||||
var model = await assets.GetMergeAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> 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<IActionResult> Save(StoryAssetEditViewModel model)
|
||||
|
||||
@ -178,6 +178,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository
|
||||
|
||||
public interface IAssetRepository
|
||||
{
|
||||
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
|
||||
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
|
||||
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
|
||||
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
|
||||
@ -260,6 +261,7 @@ public interface ICharacterRepository
|
||||
|
||||
public interface ILocationRepository
|
||||
{
|
||||
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
|
||||
Task<LocationLookupData> GetLookupsAsync();
|
||||
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
|
||||
Task<LocationItem?> GetAsync(int locationId);
|
||||
@ -486,6 +488,21 @@ public sealed class TimelineSceneOverviewRow
|
||||
|
||||
public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository
|
||||
{
|
||||
public async Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
try
|
||||
{
|
||||
return await connection.QuerySingleOrDefaultAsync<EntityMergeResult>(
|
||||
"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<LocationLookupData> 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<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
try
|
||||
{
|
||||
return await connection.QuerySingleOrDefaultAsync<EntityMergeResult>(
|
||||
"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<AssetLookupData> GetLookupsAsync(int? projectId = null)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
9
PlotLine/Models/EntityMergeResult.cs
Normal file
9
PlotLine/Models/EntityMergeResult.cs
Normal file
@ -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;
|
||||
}
|
||||
@ -189,6 +189,8 @@ public interface IPlotService
|
||||
|
||||
public interface IAssetService
|
||||
{
|
||||
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
|
||||
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
|
||||
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
|
||||
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
|
||||
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
|
||||
@ -208,6 +210,8 @@ public interface IAssetService
|
||||
|
||||
public interface ILocationService
|
||||
{
|
||||
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
|
||||
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
|
||||
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
|
||||
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
|
||||
Task<LocationEditViewModel?> GetEditAsync(int locationId);
|
||||
@ -7910,6 +7914,40 @@ public sealed class AssetService(
|
||||
IVisualIdentityImageService visualIdentityImages,
|
||||
ICurrentUserService currentUser) : IAssetService
|
||||
{
|
||||
public async Task<EntityMergeViewModel?> 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<EntityMergeResult?> 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<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
|
||||
{
|
||||
var project = await projects.GetAsync(projectId);
|
||||
@ -8219,6 +8257,40 @@ public sealed class LocationService(
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<LocationService> logger) : ILocationService
|
||||
{
|
||||
public async Task<EntityMergeViewModel?> 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<EntityMergeResult?> 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<LocationListViewModel?> GetLocationsAsync(int projectId)
|
||||
{
|
||||
var project = await projects.GetAsync(projectId);
|
||||
|
||||
209
PlotLine/Sql/193_LocationAndAssetMerge.sql
Normal file
209
PlotLine/Sql/193_LocationAndAssetMerge.sql
Normal file
@ -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
|
||||
22
PlotLine/ViewModels/EntityMergeViewModel.cs
Normal file
22
PlotLine/ViewModels/EntityMergeViewModel.cs
Normal file
@ -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<SelectListItem> 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; }
|
||||
}
|
||||
@ -30,13 +30,20 @@
|
||||
<p class="muted">@Model.Location.LocationPath</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-action="Edit" asp-route-id="@Model.Location.LocationID">Edit</a>
|
||||
<a class="btn btn-outline-primary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">All locations</a>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this location? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Location.LocationID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger" type="submit">Archive Location</button>
|
||||
</form>
|
||||
<div class="dropdown pw-actions">
|
||||
<button class="btn pw-actions__toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Tools</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Location.LocationID">Edit location</a>
|
||||
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Location.LocationID">Merge with another location</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this location? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Location.LocationID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="dropdown-item text-danger" type="submit">Archive Location</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
53
PlotLine/Views/Locations/Merge.cshtml
Normal file
53
PlotLine/Views/Locations/Merge.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@model EntityMergeViewModel
|
||||
@{
|
||||
ViewData["Title"] = $"Merge {Model.SourceName}";
|
||||
ViewData["ProjectSection"] = "Locations";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
|
||||
<a asp-action="Details" asp-route-id="@Model.Form.SourceID">@Model.SourceName</a>
|
||||
<span>Merge</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Location identity</p>
|
||||
<h1>Merge @Model.SourceName</h1>
|
||||
<p class="lead-text">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.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.Form.SourceID">Cancel</a>
|
||||
</div>
|
||||
|
||||
@if (!Model.TargetOptions.Any())
|
||||
{
|
||||
<div class="alert alert-info">There are no other active locations in this project to merge with.</div>
|
||||
}
|
||||
|
||||
<form asp-action="Merge" method="post" class="edit-panel">
|
||||
<input asp-for="Form.SourceID" type="hidden" />
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label asp-for="Form.TargetID" class="form-label">Canonical location</label>
|
||||
<select asp-for="Form.TargetID" asp-items="Model.TargetOptions" class="form-select">
|
||||
<option value="">Choose location</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-check">
|
||||
<input asp-for="Form.Confirm" class="form-check-input" />
|
||||
<span class="form-check-label">I understand this will archive @Model.SourceName and move references to the selected location.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button disabled="@(!Model.TargetOptions.Any())" class="btn btn-danger mt-3" type="submit">Merge location</button>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
@ -25,15 +25,27 @@
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Story assets</a>
|
||||
<a class="btn btn-outline-primary" asp-action="Edit" asp-route-id="@Model.Asset.StoryAssetID">Edit asset</a>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this asset? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Asset.StoryAssetID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="btn btn-outline-danger" type="submit">Archive Asset</button>
|
||||
</form>
|
||||
<div class="dropdown pw-actions">
|
||||
<button class="btn pw-actions__toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Tools</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Asset.StoryAssetID">Edit asset</a>
|
||||
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Asset.StoryAssetID">Merge with another asset</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this asset? This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
|
||||
<input type="hidden" name="id" value="@Model.Asset.StoryAssetID" />
|
||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||
<button class="dropdown-item text-danger" type="submit">Archive Asset</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["AssetMessage"] is string assetMessage)
|
||||
{
|
||||
<div class="alert alert-info">@assetMessage</div>
|
||||
}
|
||||
|
||||
<section class="asset-detail-grid">
|
||||
<article class="plain-card">
|
||||
<h2>Current state <help-icon key="storyAssets.currentState" /></h2>
|
||||
|
||||
53
PlotLine/Views/StoryAssets/Merge.cshtml
Normal file
53
PlotLine/Views/StoryAssets/Merge.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@model EntityMergeViewModel
|
||||
@{
|
||||
ViewData["Title"] = $"Merge {Model.SourceName}";
|
||||
ViewData["ProjectSection"] = "Assets";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
|
||||
<a asp-action="Details" asp-route-id="@Model.Form.SourceID">@Model.SourceName</a>
|
||||
<span>Merge</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Asset identity</p>
|
||||
<h1>Merge @Model.SourceName</h1>
|
||||
<p class="lead-text">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.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.Form.SourceID">Cancel</a>
|
||||
</div>
|
||||
|
||||
@if (!Model.TargetOptions.Any())
|
||||
{
|
||||
<div class="alert alert-info">There are no other active assets in this project to merge with.</div>
|
||||
}
|
||||
|
||||
<form asp-action="Merge" method="post" class="edit-panel">
|
||||
<input asp-for="Form.SourceID" type="hidden" />
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label asp-for="Form.TargetID" class="form-label">Canonical asset</label>
|
||||
<select asp-for="Form.TargetID" asp-items="Model.TargetOptions" class="form-select">
|
||||
<option value="">Choose asset</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-check">
|
||||
<input asp-for="Form.Confirm" class="form-check-input" />
|
||||
<span class="form-check-label">I understand this will archive @Model.SourceName and move references to the selected asset.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button disabled="@(!Model.TargetOptions.Any())" class="btn btn-danger mt-3" type="submit">Merge asset</button>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user