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)[]
|
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 persisted analysis preserves contextual bedrooms and setting provenance", LocationSignificanceTests.PersistedAnalysisKeepsContextAndParentEvidence),
|
||||||
("Location consequence outranks capped recurrence", LocationSignificanceTests.ConsequenceOutranksFrequency),
|
("Location consequence outranks capped recurrence", LocationSignificanceTests.ConsequenceOutranksFrequency),
|
||||||
("Location scene significance is attributed only to actual settings", LocationSignificanceTests.SceneContextBelongsToSetting),
|
("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);
|
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]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Save(LocationEditViewModel model)
|
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);
|
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]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Save(StoryAssetEditViewModel model)
|
public async Task<IActionResult> Save(StoryAssetEditViewModel model)
|
||||||
|
|||||||
@ -178,6 +178,7 @@ public interface IStoryIntelligenceNarrativeCandidateRepository
|
|||||||
|
|
||||||
public interface IAssetRepository
|
public interface IAssetRepository
|
||||||
{
|
{
|
||||||
|
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
|
||||||
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
|
Task<AssetLookupData> GetLookupsAsync(int? projectId = null);
|
||||||
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
|
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
|
||||||
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
|
Task<StoryAsset?> GetAssetAsync(int storyAssetId);
|
||||||
@ -260,6 +261,7 @@ public interface ICharacterRepository
|
|||||||
|
|
||||||
public interface ILocationRepository
|
public interface ILocationRepository
|
||||||
{
|
{
|
||||||
|
Task<EntityMergeResult?> MergeAsync(int sourceId, int targetId, int? userId);
|
||||||
Task<LocationLookupData> GetLookupsAsync();
|
Task<LocationLookupData> GetLookupsAsync();
|
||||||
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
|
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
|
||||||
Task<LocationItem?> GetAsync(int locationId);
|
Task<LocationItem?> GetAsync(int locationId);
|
||||||
@ -486,6 +488,21 @@ public sealed class TimelineSceneOverviewRow
|
|||||||
|
|
||||||
public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository
|
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()
|
public async Task<LocationLookupData> GetLookupsAsync()
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
@ -3362,6 +3379,21 @@ public sealed class StoryIntelligenceNarrativeCandidateRepository(ISqlConnection
|
|||||||
|
|
||||||
public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : IAssetRepository
|
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)
|
public async Task<AssetLookupData> GetLookupsAsync(int? projectId = null)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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
|
public interface IAssetService
|
||||||
{
|
{
|
||||||
|
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
|
||||||
|
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
|
||||||
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
|
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
|
||||||
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
|
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
|
||||||
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
|
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
|
||||||
@ -208,6 +210,8 @@ public interface IAssetService
|
|||||||
|
|
||||||
public interface ILocationService
|
public interface ILocationService
|
||||||
{
|
{
|
||||||
|
Task<EntityMergeViewModel?> GetMergeAsync(int sourceId);
|
||||||
|
Task<EntityMergeResult?> MergeAsync(EntityMergeForm form);
|
||||||
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
|
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
|
||||||
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
|
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
|
||||||
Task<LocationEditViewModel?> GetEditAsync(int locationId);
|
Task<LocationEditViewModel?> GetEditAsync(int locationId);
|
||||||
@ -7910,6 +7914,40 @@ public sealed class AssetService(
|
|||||||
IVisualIdentityImageService visualIdentityImages,
|
IVisualIdentityImageService visualIdentityImages,
|
||||||
ICurrentUserService currentUser) : IAssetService
|
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)
|
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
|
||||||
{
|
{
|
||||||
var project = await projects.GetAsync(projectId);
|
var project = await projects.GetAsync(projectId);
|
||||||
@ -8219,6 +8257,40 @@ public sealed class LocationService(
|
|||||||
ICurrentUserService currentUser,
|
ICurrentUserService currentUser,
|
||||||
ILogger<LocationService> logger) : ILocationService
|
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)
|
public async Task<LocationListViewModel?> GetLocationsAsync(int projectId)
|
||||||
{
|
{
|
||||||
var project = await projects.GetAsync(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,15 +30,22 @@
|
|||||||
<p class="muted">@Model.Location.LocationPath</p>
|
<p class="muted">@Model.Location.LocationPath</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<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>
|
<a class="btn btn-outline-primary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">All locations</a>
|
||||||
|
<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.">
|
<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="id" value="@Model.Location.LocationID" />
|
||||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||||
<button class="btn btn-outline-danger" type="submit">Archive Location</button>
|
<button class="dropdown-item text-danger" type="submit">Archive Location</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="edit-panel">
|
<section class="edit-panel">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
|||||||
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>
|
||||||
<div class="button-row">
|
<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-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>
|
<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.">
|
<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="id" value="@Model.Asset.StoryAssetID" />
|
||||||
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
|
||||||
<button class="btn btn-outline-danger" type="submit">Archive Asset</button>
|
<button class="dropdown-item text-danger" type="submit">Archive Asset</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (TempData["AssetMessage"] is string assetMessage)
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">@assetMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
<section class="asset-detail-grid">
|
<section class="asset-detail-grid">
|
||||||
<article class="plain-card">
|
<article class="plain-card">
|
||||||
<h2>Current state <help-icon key="storyAssets.currentState" /></h2>
|
<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