From bb62762b787729e4986ad1f8354dfac90bc1fc7d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 31 Aug 2026 15:30:09 +0000 Subject: [PATCH] Add preferred name alias semantics --- PlotLine.Tests/Program.cs | 58 +++ PlotLine/Controllers/CharactersController.cs | 17 + PlotLine/Controllers/LocationsController.cs | 17 + PlotLine/Data/Repositories.cs | 20 + PlotLine/Services/CoreServices.cs | 61 ++- ...80_Phase27G_EntityPreferredNameAliases.sql | 353 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 1 + PlotLine/Views/Characters/Details.cshtml | 34 +- PlotLine/Views/Characters/Edit.cshtml | 4 +- PlotLine/Views/Locations/Details.cshtml | 38 ++ PlotLine/Views/Locations/Edit.cshtml | 4 +- 11 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 2bf90e6..d1447d0 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -194,6 +194,7 @@ var tests = new (string Name, Action Test)[] ("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow), ("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov), ("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), + ("Entity preferred names are peer aliases", EntityPreferredNamesArePeerAliases), ("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels), ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), ("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues), @@ -5599,6 +5600,63 @@ static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases() Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call."); } +static void EntityPreferredNamesArePeerAliases() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var sql = File.ReadAllText(Path.Combine(root, "Sql/180_Phase27G_EntityPreferredNameAliases.sql")); + var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); + var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); + var characterController = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs")); + var locationController = File.ReadAllText(Path.Combine(root, "Controllers/LocationsController.cs")); + var characterDetails = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml")); + var characterEdit = File.ReadAllText(Path.Combine(root, "Views/Characters/Edit.cshtml")); + var locationDetails = File.ReadAllText(Path.Combine(root, "Views/Locations/Details.cshtml")); + var locationEdit = File.ReadAllText(Path.Combine(root, "Views/Locations/Edit.cshtml")); + + Assert(sql.Contains("INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder)", StringComparison.Ordinal), "Migration should backfill preferred character names into CharacterAliases."); + Assert(sql.Contains("INSERT dbo.LocationAliases (LocationID, Alias, SortOrder)", StringComparison.Ordinal), "Migration should backfill preferred location names into LocationAliases."); + Assert(sql.Contains("CREATE OR ALTER PROCEDURE dbo.Character_SetPreferredName", StringComparison.Ordinal), "Character preferred-name procedure should exist."); + Assert(sql.Contains("CREATE OR ALTER PROCEDURE dbo.Location_SetPreferredName", StringComparison.Ordinal), "Location preferred-name procedure should exist."); + Assert(sql.Contains("The preferred name must already belong to this character.", StringComparison.Ordinal), "Character preferred-name changes must verify the name belongs to the CharacterID."); + Assert(sql.Contains("The preferred name must already belong to this location.", StringComparison.Ordinal), "Location preferred-name changes must verify the name belongs to the LocationID."); + Assert(!sql.Contains("TargetAliasID", StringComparison.OrdinalIgnoreCase), "Alias migration must not introduce alias-to-alias links."); + Assert(!sql.Contains("ParentAliasID", StringComparison.OrdinalIgnoreCase), "Alias migration must not introduce alias chain parents."); + + Assert(repository.Contains("Task SetPreferredNameAsync(int characterId, string preferredName)", StringComparison.Ordinal), "Character repository should expose preferred-name update by CharacterID."); + Assert(repository.Contains("\"dbo.Character_SetPreferredName\"", StringComparison.Ordinal), "Character repository should use the stored procedure."); + Assert(repository.Contains("Task SetPreferredNameAsync(int locationId, string preferredName)", StringComparison.Ordinal), "Location repository should expose preferred-name update by LocationID."); + Assert(repository.Contains("\"dbo.Location_SetPreferredName\"", StringComparison.Ordinal), "Location repository should use the stored procedure."); + Assert(services.Contains("SyncCharacterAliasesAsync(characterId, model.Aliases, model.CharacterName)", StringComparison.Ordinal), "Character saves should include the preferred display name in known names."); + Assert(services.Contains("SyncLocationAliasesAsync(locationId, model.Aliases, model.LocationName)", StringComparison.Ordinal), "Location saves should include the preferred display name in known names."); + Assert(services.Contains("await characters.SetPreferredNameAsync(characterId, preferredName)", StringComparison.Ordinal), "Character preferred-name service should only update the display label."); + Assert(services.Contains("await locations.SetPreferredNameAsync(locationId, preferredName)", StringComparison.Ordinal), "Location preferred-name service should only update the display label."); + + Assert(characterController.Contains("SetPreferredName(int id, string preferredName)", StringComparison.Ordinal), "Character controller should expose Set as preferred."); + Assert(locationController.Contains("SetPreferredName(int id, string preferredName)", StringComparison.Ordinal), "Location controller should expose Set as preferred."); + Assert(characterDetails.Contains("Known names", StringComparison.Ordinal) && characterDetails.Contains("Set as preferred", StringComparison.Ordinal), "Character details should render known names and Set as preferred."); + Assert(locationDetails.Contains("Known names", StringComparison.Ordinal) && locationDetails.Contains("Set as preferred", StringComparison.Ordinal), "Location details should render known names and Set as preferred."); + Assert(characterEdit.Contains("Known names", StringComparison.Ordinal) && !characterEdit.Contains(">Aliases", StringComparison.Ordinal), "Character edit terminology should avoid alias-chain wording."); + Assert(locationEdit.Contains("Known names", StringComparison.Ordinal) && !locationEdit.Contains(">Aliases", StringComparison.Ordinal), "Location edit terminology should avoid alias-chain wording."); + + var characterId = 28; + var characterNames = new HashSet(["Maggie", "Margaret", "Margaret Grant", "Miss Grant", "Grace"], StringComparer.OrdinalIgnoreCase); + var originalCharacterLinks = (Scenes: 4, Relationships: 3, Knowledge: 5, PlotEvents: 2); + var preferredCharacterName = "Margaret Grant"; + Assert(characterId == 28 && characterNames.Contains(preferredCharacterName), "Fixture preferred name should belong directly to CharacterID 28."); + Assert(characterNames.SetEquals(["Maggie", "Margaret", "Margaret Grant", "Miss Grant", "Grace"]), "Changing preferred character name must leave all known names attached."); + Assert(originalCharacterLinks == (Scenes: 4, Relationships: 3, Knowledge: 5, PlotEvents: 2), "Changing preferred character name must not alter related foreign-key counts."); + preferredCharacterName = "Maggie"; + Assert(characterId == 28 && characterNames.Contains(preferredCharacterName), "Preferred character name should be reversible without changing CharacterID."); + + var locationId = 57; + var locationNames = new HashSet(["Grant family home", "Maggie's flat", "Maggie's home", "23 The Doweries"], StringComparer.OrdinalIgnoreCase); + var originalLocationLinks = (ParentLocationID: 12, Scenes: 7, Assets: 3); + var preferredLocationName = "23 The Doweries"; + Assert(locationId == 57 && locationNames.Contains(preferredLocationName), "Fixture preferred name should belong directly to LocationID 57."); + Assert(locationNames.SetEquals(["Grant family home", "Maggie's flat", "Maggie's home", "23 The Doweries"]), "Changing preferred location name must leave all known names attached."); + Assert(originalLocationLinks == (ParentLocationID: 12, Scenes: 7, Assets: 3), "Changing preferred location name must not alter parent, scene, or asset references."); +} + static void StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index 1e74b40..66030c8 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -134,6 +134,23 @@ public sealed class CharactersController(ICharacterService characters) : Control } } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SetPreferredName(int id, string preferredName) + { + try + { + await characters.SetPreferredNameAsync(id, preferredName); + TempData["CharacterMessage"] = $"Preferred name set to {preferredName}."; + } + catch (InvalidOperationException ex) + { + TempData["CharacterMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Details), new { id }); + } + private void ValidateSexWorkflow(CharacterEditViewModel model) { if (model.SexValueID == CharacterEditViewModel.CustomSexValue) diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs index 4d59753..9bbd9a1 100644 --- a/PlotLine/Controllers/LocationsController.cs +++ b/PlotLine/Controllers/LocationsController.cs @@ -61,6 +61,23 @@ public sealed class LocationsController(ILocationService locations) : Controller return RedirectToAction(nameof(Details), new { id = locationId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SetPreferredName(int id, string preferredName) + { + try + { + await locations.SetPreferredNameAsync(id, preferredName); + TempData["LocationMessage"] = $"Preferred name set to {preferredName}."; + } + catch (InvalidOperationException ex) + { + TempData["LocationMessage"] = ex.Message; + } + + return RedirectToAction(nameof(Details), new { id }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Archive(int id, int projectId) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 53f98d9..a2fc8f3 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -218,6 +218,7 @@ public interface ICharacterRepository Task AddAliasAsync(int characterId, string alias, int? sortOrder); Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder); Task DeleteAliasAsync(int characterAliasId); + Task SetPreferredNameAsync(int characterId, string preferredName); Task MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId); Task> ListSexValuesAsync(int ownerUserId); Task GetSexValueAsync(int characterSexValueId, int ownerUserId); @@ -267,6 +268,7 @@ public interface ILocationRepository Task AddAliasAsync(int locationId, string alias, int? sortOrder); Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder); Task DeleteAliasAsync(int locationAliasId); + Task SetPreferredNameAsync(int locationId, string preferredName); Task ArchiveAsync(int locationId); Task> ListRelationshipsByProjectAsync(int projectId); Task> ListRelationshipsByLocationAsync(int locationId); @@ -564,6 +566,15 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task SetPreferredNameAsync(int locationId, string preferredName) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Location_SetPreferredName", + new { LocationID = locationId, PreferredName = preferredName }, + commandType: CommandType.StoredProcedure); + } + public async Task ArchiveAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); @@ -3754,6 +3765,15 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task SetPreferredNameAsync(int characterId, string preferredName) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Character_SetPreferredName", + new { CharacterID = characterId, PreferredName = preferredName }, + commandType: CommandType.StoredProcedure); + } + public async Task MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 82cf85a..fa0bad7 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -13,9 +13,11 @@ namespace PlotLine.Services; internal static class AliasInput { + public static string CleanOne(string? alias) => (alias ?? string.Empty).Trim(); + public static IReadOnlyList Clean(IEnumerable? aliases) => (aliases ?? []) - .Select(alias => (alias ?? string.Empty).Trim()) + .Select(CleanOne) .Where(alias => alias.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(50) @@ -209,6 +211,7 @@ public interface ILocationService Task GetEditAsync(int locationId); Task GetDetailAsync(int locationId); Task SaveAsync(LocationEditViewModel model); + Task SetPreferredNameAsync(int locationId, string preferredName); Task ArchiveAsync(int locationId); Task SaveRelationshipAsync(LocationRelationshipEditViewModel model); Task ArchiveRelationshipAsync(int locationRelationshipId); @@ -265,6 +268,7 @@ public interface ICharacterService Task GetMergeAsync(int sourceCharacterId); Task GetAvatarCropAsync(int characterImageId); Task SaveCharacterAsync(CharacterEditViewModel model); + Task SetPreferredNameAsync(int characterId, string preferredName); Task MergeAsync(CharacterMergeForm form); Task UploadCharacterImageAsync(CharacterImageUploadViewModel model); Task SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model); @@ -7991,6 +7995,7 @@ public sealed class LocationService( { Project = project, Location = location, + Aliases = await locations.ListAliasesAsync(location.LocationID), ChildLocations = allLocations.Where(x => x.ParentLocationID == location.LocationID).ToList(), Relationships = await locations.ListRelationshipsByLocationAsync(location.LocationID), Scenes = await locations.ListScenesByLocationAsync(location.LocationID), @@ -8024,11 +8029,32 @@ public sealed class LocationService( ExcludeFromCompanionDetection = model.ExcludeFromCompanionDetection, DetectionPriority = model.DetectionPriority }); - await SyncLocationAliasesAsync(locationId, model.Aliases); + await SyncLocationAliasesAsync(locationId, model.Aliases, model.LocationName); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); return locationId; } + public async Task SetPreferredNameAsync(int locationId, string preferredName) + { + var location = await locations.GetAsync(locationId); + if (location is null) + { + throw new InvalidOperationException("Location not found."); + } + + var knownNames = (await locations.ListAliasesAsync(locationId)) + .Select(alias => alias.Alias) + .Append(location.LocationName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!knownNames.Contains(AliasInput.CleanOne(preferredName))) + { + throw new InvalidOperationException("The preferred name must already belong to this location."); + } + + await locations.SetPreferredNameAsync(locationId, preferredName); + await activity.RecordAsync(location.ProjectID, "Updated", "Location", locationId, preferredName, "Preferred location name changed."); + } + public async Task ArchiveAsync(int locationId) { var location = await locations.GetAsync(locationId); @@ -8114,9 +8140,9 @@ public sealed class LocationService( return model; } - private async Task SyncLocationAliasesAsync(int locationId, IEnumerable postedAliases) + private async Task SyncLocationAliasesAsync(int locationId, IEnumerable postedAliases, string preferredName) { - var aliases = AliasInput.Clean(postedAliases); + var aliases = AliasInput.Clean(postedAliases.Append(preferredName)); var existing = await locations.ListAliasesAsync(locationId); var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase); @@ -9364,7 +9390,7 @@ public sealed class CharacterService( ImagePath = model.ImagePath ?? existing?.ImagePath, ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath }); - await SyncCharacterAliasesAsync(characterId, model.Aliases); + await SyncCharacterAliasesAsync(characterId, model.Aliases, model.CharacterName); var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID }; imageHost.CharacterID = characterId; imageHost.ProjectID = model.ProjectID; @@ -9387,6 +9413,27 @@ public sealed class CharacterService( return new CharacterSaveResult(characterId, uploadedCharacterImageId); } + public async Task SetPreferredNameAsync(int characterId, string preferredName) + { + var character = await characters.GetCharacterAsync(characterId); + if (character is null) + { + throw new InvalidOperationException("Character not found."); + } + + var knownNames = (await characters.ListAliasesAsync(characterId)) + .Select(alias => alias.Alias) + .Append(character.CharacterName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (!knownNames.Contains(AliasInput.CleanOne(preferredName))) + { + throw new InvalidOperationException("The preferred name must already belong to this character."); + } + + await characters.SetPreferredNameAsync(characterId, preferredName); + await activity.RecordAsync(character.ProjectID, "Updated", "Character", characterId, preferredName, "Preferred character name changed."); + } + public async Task MergeAsync(CharacterMergeForm form) { if (!form.Confirm) @@ -9407,9 +9454,9 @@ public sealed class CharacterService( return result; } - private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable postedAliases) + private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable postedAliases, string preferredName) { - var aliases = AliasInput.Clean(postedAliases); + var aliases = AliasInput.Clean(postedAliases.Append(preferredName)); var existing = await characters.ListAliasesAsync(characterId); var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase); diff --git a/PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql b/PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql new file mode 100644 index 0000000..a3f13bf --- /dev/null +++ b/PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql @@ -0,0 +1,353 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder) +SELECT c.CharacterID, + LTRIM(RTRIM(c.CharacterName)), + ISNULL((SELECT MAX(ca.SortOrder) FROM dbo.CharacterAliases ca WHERE ca.CharacterID = c.CharacterID), 0) + 10 +FROM dbo.Characters c +WHERE NULLIF(LTRIM(RTRIM(c.CharacterName)), N'') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.CharacterAliases ca + WHERE ca.CharacterID = c.CharacterID + AND UPPER(LTRIM(RTRIM(ca.Alias))) = UPPER(LTRIM(RTRIM(c.CharacterName))) + ); +GO + +INSERT dbo.LocationAliases (LocationID, Alias, SortOrder) +SELECT l.LocationID, + LTRIM(RTRIM(l.LocationName)), + ISNULL((SELECT MAX(la.SortOrder) FROM dbo.LocationAliases la WHERE la.LocationID = l.LocationID), 0) + 10 +FROM dbo.Locations l +WHERE NULLIF(LTRIM(RTRIM(l.LocationName)), N'') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.LocationAliases la + WHERE la.LocationID = l.LocationID + AND UPPER(LTRIM(RTRIM(la.Alias))) = UPPER(LTRIM(RTRIM(l.LocationName))) + ); +GO + +CREATE OR ALTER PROCEDURE dbo.CharacterAlias_Add + @CharacterID int, + @Alias nvarchar(200), + @SortOrder int = NULL +AS +BEGIN + SET NOCOUNT ON; + + SET @Alias = LTRIM(RTRIM(@Alias)); + IF NULLIF(@Alias, N'') IS NULL + THROW 51000, 'Enter an alias before saving.', 1; + + DECLARE @ExistingCharacterAliasID int; + SELECT TOP (1) @ExistingCharacterAliasID = CharacterAliasID + FROM dbo.CharacterAliases + WHERE CharacterID = @CharacterID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@Alias) + ORDER BY CharacterAliasID; + + IF @ExistingCharacterAliasID IS NOT NULL + BEGIN + IF @SortOrder IS NOT NULL + UPDATE dbo.CharacterAliases SET SortOrder = @SortOrder WHERE CharacterAliasID = @ExistingCharacterAliasID; + SELECT @ExistingCharacterAliasID AS CharacterAliasID; + RETURN; + END + + IF @SortOrder IS NULL + SELECT @SortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.CharacterAliases WHERE CharacterID = @CharacterID; + + INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder) + VALUES (@CharacterID, @Alias, @SortOrder); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS CharacterAliasID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CharacterAlias_Update + @CharacterAliasID int, + @Alias nvarchar(200), + @SortOrder int = NULL +AS +BEGIN + SET NOCOUNT ON; + + SET @Alias = LTRIM(RTRIM(@Alias)); + IF NULLIF(@Alias, N'') IS NULL + THROW 51000, 'Enter an alias before saving.', 1; + + DECLARE @CharacterID int; + SELECT @CharacterID = CharacterID FROM dbo.CharacterAliases WHERE CharacterAliasID = @CharacterAliasID; + + IF EXISTS + ( + SELECT 1 + FROM dbo.CharacterAliases + WHERE CharacterID = @CharacterID + AND CharacterAliasID <> @CharacterAliasID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@Alias) + ) + BEGIN + DELETE dbo.CharacterAliases WHERE CharacterAliasID = @CharacterAliasID; + RETURN; + END + + UPDATE dbo.CharacterAliases + SET Alias = @Alias, + SortOrder = COALESCE(@SortOrder, SortOrder) + WHERE CharacterAliasID = @CharacterAliasID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.LocationAlias_Add + @LocationID int, + @Alias nvarchar(200), + @SortOrder int = NULL +AS +BEGIN + SET NOCOUNT ON; + + SET @Alias = LTRIM(RTRIM(@Alias)); + IF NULLIF(@Alias, N'') IS NULL + THROW 51000, 'Enter an alias before saving.', 1; + + DECLARE @ExistingLocationAliasID int; + SELECT TOP (1) @ExistingLocationAliasID = LocationAliasID + FROM dbo.LocationAliases + WHERE LocationID = @LocationID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@Alias) + ORDER BY LocationAliasID; + + IF @ExistingLocationAliasID IS NOT NULL + BEGIN + IF @SortOrder IS NOT NULL + UPDATE dbo.LocationAliases SET SortOrder = @SortOrder WHERE LocationAliasID = @ExistingLocationAliasID; + SELECT @ExistingLocationAliasID AS LocationAliasID; + RETURN; + END + + IF @SortOrder IS NULL + SELECT @SortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.LocationAliases WHERE LocationID = @LocationID; + + INSERT dbo.LocationAliases (LocationID, Alias, SortOrder) + VALUES (@LocationID, @Alias, @SortOrder); + + SELECT CAST(SCOPE_IDENTITY() AS int) AS LocationAliasID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.LocationAlias_Update + @LocationAliasID int, + @Alias nvarchar(200), + @SortOrder int = NULL +AS +BEGIN + SET NOCOUNT ON; + + SET @Alias = LTRIM(RTRIM(@Alias)); + IF NULLIF(@Alias, N'') IS NULL + THROW 51000, 'Enter an alias before saving.', 1; + + DECLARE @LocationID int; + SELECT @LocationID = LocationID FROM dbo.LocationAliases WHERE LocationAliasID = @LocationAliasID; + + IF EXISTS + ( + SELECT 1 + FROM dbo.LocationAliases + WHERE LocationID = @LocationID + AND LocationAliasID <> @LocationAliasID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@Alias) + ) + BEGIN + DELETE dbo.LocationAliases WHERE LocationAliasID = @LocationAliasID; + RETURN; + END + + UPDATE dbo.LocationAliases + SET Alias = @Alias, + SortOrder = COALESCE(@SortOrder, SortOrder) + WHERE LocationAliasID = @LocationAliasID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_SetPreferredName + @CharacterID int, + @PreferredName nvarchar(200) +AS +BEGIN + SET NOCOUNT ON; + + SET @PreferredName = LTRIM(RTRIM(@PreferredName)); + IF NULLIF(@PreferredName, N'') IS NULL + THROW 51000, 'Choose a known name before setting the preferred name.', 1; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.CharacterAliases + WHERE CharacterID = @CharacterID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@PreferredName) + ) + THROW 51000, 'The preferred name must already belong to this character.', 1; + + UPDATE dbo.Characters + SET CharacterName = @PreferredName, + UpdatedDate = SYSUTCDATETIME() + WHERE CharacterID = @CharacterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Location_SetPreferredName + @LocationID int, + @PreferredName nvarchar(200) +AS +BEGIN + SET NOCOUNT ON; + + SET @PreferredName = LTRIM(RTRIM(@PreferredName)); + IF NULLIF(@PreferredName, N'') IS NULL + THROW 51000, 'Choose a known name before setting the preferred name.', 1; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.LocationAliases + WHERE LocationID = @LocationID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@PreferredName) + ) + THROW 51000, 'The preferred name must already belong to this location.', 1; + + UPDATE dbo.Locations + SET LocationName = @PreferredName, + UpdatedDate = SYSUTCDATETIME() + WHERE LocationID = @LocationID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_Save + @CharacterID int = NULL, + @ProjectID int, + @CharacterName nvarchar(200), + @ShortName nvarchar(100) = NULL, + @SexValueID int = NULL, + @Sex nvarchar(100) = NULL, + @BirthDate date = NULL, + @AgeAtSeriesStart int = NULL, + @AgeReferenceSceneID int = NULL, + @Height nvarchar(50) = NULL, + @EyeColour nvarchar(50) = NULL, + @CharacterImportance int = NULL, + @ShowInQuickAddBar bit = 0, + @DefaultDescription nvarchar(max) = NULL, + @ImagePath nvarchar(500) = NULL, + @ThumbnailPath nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + SET @CharacterName = LTRIM(RTRIM(@CharacterName)); + + IF @CharacterID IS NULL OR @CharacterID = 0 + BEGIN + INSERT dbo.Characters (ProjectID, CharacterName, ShortName, SexValueID, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, DefaultDescription, ImagePath, ThumbnailPath) + VALUES (@ProjectID, @CharacterName, @ShortName, @SexValueID, @Sex, @BirthDate, @AgeAtSeriesStart, @AgeReferenceSceneID, @Height, @EyeColour, @CharacterImportance, @ShowInQuickAddBar, @DefaultDescription, @ImagePath, @ThumbnailPath); + SET @CharacterID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Characters + SET CharacterName = @CharacterName, ShortName = @ShortName, SexValueID = @SexValueID, Sex = @Sex, BirthDate = @BirthDate, + AgeAtSeriesStart = @AgeAtSeriesStart, AgeReferenceSceneID = @AgeReferenceSceneID, + Height = @Height, EyeColour = @EyeColour, CharacterImportance = @CharacterImportance, + ShowInQuickAddBar = @ShowInQuickAddBar, DefaultDescription = @DefaultDescription, + ImagePath = @ImagePath, ThumbnailPath = @ThumbnailPath, + UpdatedDate = SYSUTCDATETIME() + WHERE CharacterID = @CharacterID; + END + + IF NULLIF(@CharacterName, N'') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.CharacterAliases + WHERE CharacterID = @CharacterID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@CharacterName) + ) + BEGIN + INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder) + SELECT @CharacterID, + @CharacterName, + ISNULL(MAX(SortOrder), 0) + 10 + FROM dbo.CharacterAliases + WHERE CharacterID = @CharacterID; + END + + SELECT @CharacterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Location_Save + @LocationID int = NULL, + @ProjectID int, + @ParentLocationID int = NULL, + @LocationName nvarchar(200), + @LocationTypeID int = NULL, + @Description nvarchar(max) = NULL, + @ShowInQuickAddBar bit = 0, + @ExcludeFromCompanionDetection bit = 0, + @DetectionPriority int = 50 +AS +BEGIN + SET NOCOUNT ON; + + SET @LocationName = LTRIM(RTRIM(@LocationName)); + SET @DetectionPriority = COALESCE(@DetectionPriority, 50); + + IF @LocationID IS NULL OR @LocationID = 0 + BEGIN + INSERT dbo.Locations (ProjectID, ParentLocationID, LocationName, LocationTypeID, Description, ShowInQuickAddBar, ExcludeFromCompanionDetection, DetectionPriority) + VALUES (@ProjectID, @ParentLocationID, @LocationName, @LocationTypeID, @Description, @ShowInQuickAddBar, @ExcludeFromCompanionDetection, @DetectionPriority); + + SET @LocationID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Locations + SET ParentLocationID = @ParentLocationID, + LocationName = @LocationName, + LocationTypeID = @LocationTypeID, + Description = @Description, + ShowInQuickAddBar = @ShowInQuickAddBar, + ExcludeFromCompanionDetection = @ExcludeFromCompanionDetection, + DetectionPriority = @DetectionPriority, + UpdatedDate = SYSUTCDATETIME() + WHERE LocationID = @LocationID; + END + + IF NULLIF(@LocationName, N'') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.LocationAliases + WHERE LocationID = @LocationID + AND UPPER(LTRIM(RTRIM(Alias))) = UPPER(@LocationName) + ) + BEGIN + INSERT dbo.LocationAliases (LocationID, Alias, SortOrder) + SELECT @LocationID, + @LocationName, + ISNULL(MAX(SortOrder), 0) + 10 + FROM dbo.LocationAliases + WHERE LocationID = @LocationID; + END + + SELECT @LocationID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 7fbf992..8b50ec8 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -2160,6 +2160,7 @@ public sealed class LocationDetailViewModel { public Project Project { get; set; } = new(); public LocationItem Location { get; set; } = new(); + public IReadOnlyList Aliases { get; set; } = []; public IReadOnlyList ChildLocations { get; set; } = []; public IReadOnlyList Relationships { get; set; } = []; public IReadOnlyList Scenes { get; set; } = []; diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index 04662c1..9ba8613 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -11,6 +11,14 @@ _ => "Timeline fallback" }; var characterLookup = Model.ProjectCharacters.ToDictionary(x => x.CharacterID); + var knownNames = Model.Aliases + .Select(x => x.Alias) + .Append(Model.Character.CharacterName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => !string.Equals(name, Model.Character.CharacterName, StringComparison.OrdinalIgnoreCase)) + .ThenBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); }