Add preferred name alias semantics
This commit is contained in:
parent
3c016c904e
commit
bb62762b78
@ -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");
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
353
PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql
Normal file
353
PlotLine/Sql/180_Phase27G_EntityPreferredNameAliases.sql
Normal 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
|
||||
@ -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; } = [];
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user