Add location and asset merge tools with reference migration

This commit is contained in:
Nick Beckley 2026-09-09 21:59:07 +00:00
parent 54eced0ad1
commit a666696a9f
14 changed files with 800 additions and 12 deletions

View 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.");
}
}

View File

@ -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),

View 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;

View File

@ -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)

View File

@ -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)

View File

@ -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();

View 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;
}

View File

@ -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);

View 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

View 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; }
}

View File

@ -30,15 +30,22 @@
<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>
<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?&#10;&#10;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>
<button class="dropdown-item text-danger" type="submit">Archive Location</button>
</form>
</div>
</div>
</div>
</div>
<section class="edit-panel">
<div class="row g-3">

View 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" />
}

View File

@ -25,14 +25,26 @@
</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>
<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?&#10;&#10;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>
<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">

View 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" />
}