Add preferred name alias semantics

This commit is contained in:
Nick Beckley 2026-08-31 15:30:09 +00:00
parent 3c016c904e
commit bb62762b78
11 changed files with 595 additions and 12 deletions

View File

@ -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</label>", StringComparison.Ordinal), "Character edit terminology should avoid alias-chain wording.");
Assert(locationEdit.Contains("Known names", StringComparison.Ordinal) && !locationEdit.Contains(">Aliases</label>", StringComparison.Ordinal), "Location edit terminology should avoid alias-chain wording.");
var characterId = 28;
var characterNames = new HashSet<string>(["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<string>(["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");

View File

@ -134,6 +134,23 @@ public sealed class CharactersController(ICharacterService characters) : Control
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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)

View File

@ -61,6 +61,23 @@ public sealed class LocationsController(ILocationService locations) : Controller
return RedirectToAction(nameof(Details), new { id = locationId });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> Archive(int id, int projectId)

View File

@ -218,6 +218,7 @@ public interface ICharacterRepository
Task<int> 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<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId);
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
@ -267,6 +268,7 @@ public interface ILocationRepository
Task<int> 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<IReadOnlyList<LocationRelationship>> ListRelationshipsByProjectAsync(int projectId);
Task<IReadOnlyList<LocationRelationship>> 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<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -13,9 +13,11 @@ namespace PlotLine.Services;
internal static class AliasInput
{
public static string CleanOne(string? alias) => (alias ?? string.Empty).Trim();
public static IReadOnlyList<string> Clean(IEnumerable<string>? 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<LocationEditViewModel?> GetEditAsync(int locationId);
Task<LocationDetailViewModel?> GetDetailAsync(int locationId);
Task<int> SaveAsync(LocationEditViewModel model);
Task SetPreferredNameAsync(int locationId, string preferredName);
Task ArchiveAsync(int locationId);
Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model);
Task ArchiveRelationshipAsync(int locationRelationshipId);
@ -265,6 +268,7 @@ public interface ICharacterService
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task SetPreferredNameAsync(int characterId, string preferredName);
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
Task<VisualIdentityImageResult> 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<string> postedAliases)
private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> 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<CharacterMergeResult?> MergeAsync(CharacterMergeForm form)
{
if (!form.Confirm)
@ -9407,9 +9454,9 @@ public sealed class CharacterService(
return result;
}
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> 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);

View File

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

View File

@ -2160,6 +2160,7 @@ public sealed class LocationDetailViewModel
{
public Project Project { get; set; } = new();
public LocationItem Location { get; set; } = new();
public IReadOnlyList<LocationAlias> Aliases { get; set; } = [];
public IReadOnlyList<LocationItem> ChildLocations { get; set; } = [];
public IReadOnlyList<LocationRelationship> Relationships { get; set; } = [];
public IReadOnlyList<Scene> Scenes { get; set; } = [];

View File

@ -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();
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -81,13 +89,37 @@
<section id="character-overview" class="asset-detail-grid">
<article class="plain-card">
<h2>Profile</h2>
<p><strong>Aliases:</strong> @(Model.Aliases.Any() ? string.Join(", ", Model.Aliases.Select(x => x.Alias)) : "None")</p>
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
<p><strong>Sex:</strong> @Model.Character.Sex</p>
<p><strong>Age:</strong> @Model.DisplayAge</p>
<p><strong>Height:</strong> @Model.Character.Height</p>
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
<p><strong>Story importance:</strong> @storyImportance</p>
</article>
<article class="plain-card">
<h2>Known names</h2>
<div class="asset-mini-list">
@foreach (var name in knownNames)
{
var isPreferred = string.Equals(name, Model.Character.CharacterName, StringComparison.OrdinalIgnoreCase);
<div class="asset-mini-item">
<strong>@name</strong>
@if (isPreferred)
{
<span class="status-pill">Preferred</span>
}
else
{
<form asp-action="SetPreferredName" method="post">
<input type="hidden" name="id" value="@Model.Character.CharacterID" />
<input type="hidden" name="preferredName" value="@name" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Set as preferred</button>
</form>
}
</div>
}
</div>
</article>
<article class="plain-card">
<h2>Initial Relationships <help-icon key="characters.initialRelationships" /></h2>
@if (!Model.InitialRelationships.Any())

View File

@ -31,7 +31,7 @@
<span asp-validation-for="CharacterName" class="text-danger"></span>
</div>
<div class="col-12">
<label class="form-label">Aliases</label>
<label class="form-label">Known names</label>
<div class="d-flex flex-wrap gap-2 mb-2" data-alias-list>
@for (var i = 0; i < Model.Aliases.Count; i++)
{
@ -43,7 +43,7 @@
}
</div>
<div class="input-group">
<input class="form-control" type="text" maxlength="200" placeholder="Add an alias" data-alias-input />
<input class="form-control" type="text" maxlength="200" placeholder="Add another name" data-alias-input />
<button class="btn btn-outline-secondary" type="button" data-add-alias>Add</button>
</div>
</div>

View File

@ -3,6 +3,14 @@
ViewData["Title"] = Model.Location.LocationName;
ViewData["ProjectSection"] = "Locations";
var firstScene = Model.Scenes.FirstOrDefault();
var knownNames = Model.Aliases
.Select(x => x.Alias)
.Append(Model.Location.LocationName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => !string.Equals(name, Model.Location.LocationName, StringComparison.OrdinalIgnoreCase))
.ThenBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -49,6 +57,36 @@
</div>
</section>
@if (TempData["LocationMessage"] is string locationMessage)
{
<div class="alert alert-info">@locationMessage</div>
}
<section class="edit-panel">
<h2>Known names</h2>
<div class="asset-mini-list">
@foreach (var name in knownNames)
{
var isPreferred = string.Equals(name, Model.Location.LocationName, StringComparison.OrdinalIgnoreCase);
<div class="asset-mini-item">
<strong>@name</strong>
@if (isPreferred)
{
<span class="status-pill">Preferred</span>
}
else
{
<form asp-action="SetPreferredName" method="post">
<input type="hidden" name="id" value="@Model.Location.LocationID" />
<input type="hidden" name="preferredName" value="@name" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Set as preferred</button>
</form>
}
</div>
}
</div>
</section>
<section class="edit-panel">
<h2>Child locations <help-icon key="locations.childLocations" /></h2>
@if (!Model.ChildLocations.Any())

View File

@ -34,7 +34,7 @@
<span asp-validation-for="LocationName" class="text-danger"></span>
</div>
<div class="col-12">
<label class="form-label">Aliases</label>
<label class="form-label">Known names</label>
<div class="d-flex flex-wrap gap-2 mb-2" data-alias-list>
@for (var i = 0; i < Model.Aliases.Count; i++)
{
@ -46,7 +46,7 @@
}
</div>
<div class="input-group">
<input class="form-control" type="text" maxlength="200" placeholder="Add an alias" data-alias-input />
<input class="form-control" type="text" maxlength="200" placeholder="Add another name" data-alias-input />
<button class="btn btn-outline-secondary" type="button" data-add-alias>Add</button>
</div>
</div>