Phase 16A - Base Identity Model Cleanup and Chapter/Scene Foundation

This commit is contained in:
Nick Beckley 2026-06-27 18:59:31 +01:00
parent 62bf231f65
commit 3c48301732
17 changed files with 1120 additions and 22 deletions

View File

@ -67,6 +67,7 @@ public sealed class BooksController(IBookService books) : Controller
createModel.WritingStartDate = model.WritingStartDate; createModel.WritingStartDate = model.WritingStartDate;
createModel.TargetCompletionDate = model.TargetCompletionDate; createModel.TargetCompletionDate = model.TargetCompletionDate;
createModel.PublicationDate = model.PublicationDate; createModel.PublicationDate = model.PublicationDate;
createModel.UsesExplicitScenes = model.UsesExplicitScenes;
await books.PopulateEditContextAsync(createModel); await books.PopulateEditContextAsync(createModel);
return View("Edit", createModel); return View("Edit", createModel);
} }

View File

@ -118,7 +118,7 @@ public sealed class CharactersController(ICharacterService characters) : Control
private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target) private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target)
{ {
target.CharacterName = source.CharacterName; target.CharacterName = source.CharacterName;
target.ShortName = source.ShortName; target.Aliases = source.Aliases;
target.SexValueID = source.SexValueID; target.SexValueID = source.SexValueID;
target.Sex = source.Sex; target.Sex = source.Sex;
target.CustomSex = source.CustomSex; target.CustomSex = source.CustomSex;

View File

@ -38,7 +38,21 @@ public sealed class LocationsController(ILocationService locations) : Controller
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return View("Edit", model); var editModel = model.LocationID == 0
? await locations.GetCreateAsync(model.ProjectID)
: await locations.GetEditAsync(model.LocationID);
if (editModel is null)
{
return NotFound();
}
editModel.ParentLocationID = model.ParentLocationID;
editModel.LocationName = model.LocationName;
editModel.Aliases = model.Aliases;
editModel.LocationTypeID = model.LocationTypeID;
editModel.Description = model.Description;
editModel.ShowInQuickAddBar = model.ShowInQuickAddBar;
return View("Edit", editModel);
} }
var locationId = await locations.SaveAsync(model); var locationId = await locations.SaveAsync(model);

View File

@ -88,7 +88,16 @@ public sealed class ScenesController(
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id, int chapterId) public async Task<IActionResult> Archive(int id, int chapterId)
{ {
await scenes.ArchiveAsync(id); try
{
await scenes.ArchiveAsync(id);
}
catch (InvalidOperationException ex)
{
TempData["ArchiveMessage"] = ex.Message;
return RedirectToAction("Details", "Chapters", new { id = chapterId });
}
TempData["ArchiveMessage"] = "Scene archived. You can restore it from Archived Items."; TempData["ArchiveMessage"] = "Scene archived. You can restore it from Archived Items.";
if (TryGetLocalReturnUrl(out var returnUrl)) if (TryGetLocalReturnUrl(out var returnUrl))
{ {

View File

@ -110,6 +110,7 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller
} }
model.AssetName = posted.AssetName; model.AssetName = posted.AssetName;
model.Aliases = posted.Aliases;
model.AssetKindID = posted.AssetKindID; model.AssetKindID = posted.AssetKindID;
model.Description = posted.Description; model.Description = posted.Description;
model.Importance = posted.Importance; model.Importance = posted.Importance;

View File

@ -287,15 +287,31 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
chaptersCreated++; chaptersCreated++;
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) var orderedScenes = chapter.Scenes.OrderBy(x => x.Order).ToList();
var defaultSceneId = 0;
if (orderedScenes.Count > 0)
{ {
defaultSceneId = (await connection.QueryAsync<Scene>(
"dbo.Scene_ListByChapter",
new { ChapterID = chapterId },
transaction,
commandType: CommandType.StoredProcedure))
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.SceneID)
.Select(x => x.SceneID)
.FirstOrDefault();
}
for (var sceneIndex = 0; sceneIndex < orderedScenes.Count; sceneIndex++)
{
var scene = orderedScenes[sceneIndex];
var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations); var primaryLocationId = ResolveId(locationMap, scene.LocationName, package.Aliases.Locations);
var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters); var povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters);
var sceneId = await connection.QuerySingleAsync<int>( var sceneId = await connection.QuerySingleAsync<int>(
"dbo.Scene_Save", "dbo.Scene_Save",
new new
{ {
SceneID = 0, SceneID = sceneIndex == 0 ? defaultSceneId : 0,
ChapterID = chapterId, ChapterID = chapterId,
scene.SceneNumber, scene.SceneNumber,
SceneTitle = Clean(scene.Title), SceneTitle = Clean(scene.Title),

View File

@ -1,5 +1,6 @@
using System.Data; using System.Data;
using Dapper; using Dapper;
using Microsoft.Data.SqlClient;
using PlotLine.Models; using PlotLine.Models;
namespace PlotLine.Data; namespace PlotLine.Data;
@ -159,6 +160,10 @@ public interface IAssetRepository
Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId); Task<IReadOnlyList<StoryAsset>> ListAssetsAsync(int projectId);
Task<StoryAsset?> GetAssetAsync(int storyAssetId); Task<StoryAsset?> GetAssetAsync(int storyAssetId);
Task<int> SaveAssetAsync(StoryAsset asset); Task<int> SaveAssetAsync(StoryAsset asset);
Task<IReadOnlyList<AssetAlias>> ListAliasesAsync(int storyAssetId);
Task<int> AddAliasAsync(int storyAssetId, string alias, int? sortOrder);
Task UpdateAliasAsync(int assetAliasId, string alias, int? sortOrder);
Task DeleteAliasAsync(int assetAliasId);
Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath); Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath);
Task ArchiveAssetAsync(int storyAssetId); Task ArchiveAssetAsync(int storyAssetId);
Task<IReadOnlyList<AssetEvent>> ListEventsBySceneAsync(int sceneId); Task<IReadOnlyList<AssetEvent>> ListEventsBySceneAsync(int sceneId);
@ -183,6 +188,10 @@ public interface ICharacterRepository
Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId); Task<IReadOnlyList<Character>> ListCharactersAsync(int projectId);
Task<Character?> GetCharacterAsync(int characterId); Task<Character?> GetCharacterAsync(int characterId);
Task<int> SaveCharacterAsync(Character character); Task<int> SaveCharacterAsync(Character character);
Task<IReadOnlyList<CharacterAlias>> ListAliasesAsync(int characterId);
Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder);
Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder);
Task DeleteAliasAsync(int characterAliasId);
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId); Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId); Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName); Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName);
@ -223,6 +232,10 @@ public interface ILocationRepository
Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId); Task<IReadOnlyList<LocationItem>> ListByProjectAsync(int projectId);
Task<LocationItem?> GetAsync(int locationId); Task<LocationItem?> GetAsync(int locationId);
Task<int> SaveAsync(LocationItem location); Task<int> SaveAsync(LocationItem location);
Task<IReadOnlyList<LocationAlias>> ListAliasesAsync(int locationId);
Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder);
Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder);
Task DeleteAliasAsync(int locationAliasId);
Task ArchiveAsync(int locationId); Task ArchiveAsync(int locationId);
Task<IReadOnlyList<LocationRelationship>> ListRelationshipsByProjectAsync(int projectId); Task<IReadOnlyList<LocationRelationship>> ListRelationshipsByProjectAsync(int projectId);
Task<IReadOnlyList<LocationRelationship>> ListRelationshipsByLocationAsync(int locationId); Task<IReadOnlyList<LocationRelationship>> ListRelationshipsByLocationAsync(int locationId);
@ -477,6 +490,43 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<IReadOnlyList<LocationAlias>> ListAliasesAsync(int locationId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<LocationAlias>(
"dbo.LocationAlias_ListByLocation",
new { LocationID = locationId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.LocationAlias_Add",
new { LocationID = locationId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.LocationAlias_Update",
new { LocationAliasID = locationAliasId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task DeleteAliasAsync(int locationAliasId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.LocationAlias_Delete",
new { LocationAliasID = locationAliasId },
commandType: CommandType.StoredProcedure);
}
public async Task ArchiveAsync(int locationId) public async Task ArchiveAsync(int locationId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -2178,7 +2228,8 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
book.TargetWordCount, book.TargetWordCount,
book.WritingStartDate, book.WritingStartDate,
book.TargetCompletionDate, book.TargetCompletionDate,
book.PublicationDate book.PublicationDate,
book.UsesExplicitScenes
}, },
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
@ -2337,7 +2388,14 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
public async Task ArchiveAsync(int sceneId) public async Task ArchiveAsync(int sceneId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync("dbo.Scene_Archive", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); try
{
await connection.ExecuteAsync("dbo.Scene_Archive", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure);
}
catch (SqlException ex) when (ex.Number == 51000)
{
throw new InvalidOperationException(ex.Message, ex);
}
} }
public async Task MoveAsync(int sceneId, string direction) public async Task MoveAsync(int sceneId, string direction)
@ -2977,6 +3035,43 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<IReadOnlyList<AssetAlias>> ListAliasesAsync(int storyAssetId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<AssetAlias>(
"dbo.AssetAlias_ListByAsset",
new { StoryAssetID = storyAssetId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> AddAliasAsync(int storyAssetId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.AssetAlias_Add",
new { StoryAssetID = storyAssetId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateAliasAsync(int assetAliasId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.AssetAlias_Update",
new { AssetAliasID = assetAliasId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task DeleteAliasAsync(int assetAliasId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.AssetAlias_Delete",
new { AssetAliasID = assetAliasId },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath) public async Task UpdateAssetImageAsync(int storyAssetId, string? imagePath, string? thumbnailPath)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -3228,6 +3323,43 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<IReadOnlyList<CharacterAlias>> ListAliasesAsync(int characterId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<CharacterAlias>(
"dbo.CharacterAlias_ListByCharacter",
new { CharacterID = characterId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.CharacterAlias_Add",
new { CharacterID = characterId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterAlias_Update",
new { CharacterAliasID = characterAliasId, Alias = alias, SortOrder = sortOrder },
commandType: CommandType.StoredProcedure);
}
public async Task DeleteAliasAsync(int characterAliasId)
{
using var connection = connectionFactory.CreateConnection();
await connection.ExecuteAsync(
"dbo.CharacterAlias_Delete",
new { CharacterAliasID = characterAliasId },
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId) public async Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -238,6 +238,7 @@ public sealed class Book
public DateTime? WritingStartDate { get; set; } public DateTime? WritingStartDate { get; set; }
public DateTime? TargetCompletionDate { get; set; } public DateTime? TargetCompletionDate { get; set; }
public DateTime? PublicationDate { get; set; } public DateTime? PublicationDate { get; set; }
public bool UsesExplicitScenes { get; set; }
public string? CoverThumbnailPath { get; set; } public string? CoverThumbnailPath { get; set; }
public string? CoverStandardPath { get; set; } public string? CoverStandardPath { get; set; }
public int CurrentWordCount { get; set; } public int CurrentWordCount { get; set; }
@ -254,6 +255,33 @@ public sealed class Book
: null; : null;
} }
public sealed class CharacterAlias
{
public int CharacterAliasID { get; set; }
public int CharacterID { get; set; }
public string Alias { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class AssetAlias
{
public int AssetAliasID { get; set; }
public int StoryAssetID { get; set; }
public string Alias { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class LocationAlias
{
public int LocationAliasID { get; set; }
public int LocationID { get; set; }
public string Alias { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTime CreatedUtc { get; set; }
}
public static class BookStatuses public static class BookStatuses
{ {
public const string Planning = "Planning"; public const string Planning = "Planning";

View File

@ -10,6 +10,17 @@ using PlotLine.ViewModels;
namespace PlotLine.Services; namespace PlotLine.Services;
internal static class AliasInput
{
public static IReadOnlyList<string> Clean(IEnumerable<string>? aliases) =>
(aliases ?? [])
.Select(alias => (alias ?? string.Empty).Trim())
.Where(alias => alias.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(50)
.ToList();
}
public interface IProjectService public interface IProjectService
{ {
Task<ProjectIndexViewModel> ListAsync(); Task<ProjectIndexViewModel> ListAsync();
@ -1181,6 +1192,7 @@ public sealed class BookService(
WritingStartDate = book.WritingStartDate, WritingStartDate = book.WritingStartDate,
TargetCompletionDate = book.TargetCompletionDate, TargetCompletionDate = book.TargetCompletionDate,
PublicationDate = book.PublicationDate, PublicationDate = book.PublicationDate,
UsesExplicitScenes = book.UsesExplicitScenes,
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath, CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
CoverStandardPath = book.CoverStandardPath, CoverStandardPath = book.CoverStandardPath,
Project = await projects.GetAsync(book.ProjectID), Project = await projects.GetAsync(book.ProjectID),
@ -1227,7 +1239,8 @@ public sealed class BookService(
TargetWordCount = model.TargetWordCount, TargetWordCount = model.TargetWordCount,
WritingStartDate = model.WritingStartDate, WritingStartDate = model.WritingStartDate,
TargetCompletionDate = model.TargetCompletionDate, TargetCompletionDate = model.TargetCompletionDate,
PublicationDate = model.PublicationDate PublicationDate = model.PublicationDate,
UsesExplicitScenes = model.UsesExplicitScenes
}); });
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle);
@ -7479,6 +7492,7 @@ public sealed class AssetService(
StoryAssetID = asset.StoryAssetID, StoryAssetID = asset.StoryAssetID,
ProjectID = asset.ProjectID, ProjectID = asset.ProjectID,
AssetName = asset.AssetName, AssetName = asset.AssetName,
Aliases = (await assets.ListAliasesAsync(asset.StoryAssetID)).Select(x => x.Alias).ToList(),
AssetKindID = asset.AssetKindID, AssetKindID = asset.AssetKindID,
Description = asset.Description, Description = asset.Description,
Importance = asset.Importance, Importance = asset.Importance,
@ -7549,6 +7563,7 @@ public sealed class AssetService(
ImagePath = model.ImagePath ?? existing?.ImagePath, ImagePath = model.ImagePath ?? existing?.ImagePath,
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
}); });
await SyncAssetAliasesAsync(assetId, model.Aliases);
var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID }; var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID };
imageHost.StoryAssetID = assetId; imageHost.StoryAssetID = assetId;
imageHost.ProjectID = model.ProjectID; imageHost.ProjectID = model.ProjectID;
@ -7664,6 +7679,33 @@ public sealed class AssetService(
return model; return model;
} }
private async Task SyncAssetAliasesAsync(int storyAssetId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await assets.ListAliasesAsync(storyAssetId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await assets.UpdateAliasAsync(row.AssetAliasID, alias, sortOrder);
}
else
{
await assets.AddAliasAsync(storyAssetId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await assets.DeleteAliasAsync(alias.AssetAliasID);
}
}
private static IReadOnlyList<SelectListItem> OptionalStateList(IEnumerable<AssetState> states) private static IReadOnlyList<SelectListItem> OptionalStateList(IEnumerable<AssetState> states)
=> states.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList(); => states.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList();
@ -7710,6 +7752,7 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
ProjectID = location.ProjectID, ProjectID = location.ProjectID,
ParentLocationID = location.ParentLocationID, ParentLocationID = location.ParentLocationID,
LocationName = location.LocationName, LocationName = location.LocationName,
Aliases = (await locations.ListAliasesAsync(location.LocationID)).Select(x => x.Alias).ToList(),
LocationTypeID = location.LocationTypeID, LocationTypeID = location.LocationTypeID,
Description = location.Description, Description = location.Description,
ShowInQuickAddBar = location.ShowInQuickAddBar, ShowInQuickAddBar = location.ShowInQuickAddBar,
@ -7768,6 +7811,7 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
Description = model.Description, Description = model.Description,
ShowInQuickAddBar = model.ShowInQuickAddBar ShowInQuickAddBar = model.ShowInQuickAddBar
}); });
await SyncLocationAliasesAsync(locationId, model.Aliases);
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName);
return locationId; return locationId;
} }
@ -7828,6 +7872,33 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi
model.LocationTypeOptions = ChapterService.ToSelectList(lookups.LocationTypes, x => x.LocationTypeID, x => x.TypeName); model.LocationTypeOptions = ChapterService.ToSelectList(lookups.LocationTypes, x => x.LocationTypeID, x => x.TypeName);
return model; return model;
} }
private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await locations.ListAliasesAsync(locationId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await locations.UpdateAliasAsync(row.LocationAliasID, alias, sortOrder);
}
else
{
await locations.AddAliasAsync(locationId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await locations.DeleteAliasAsync(alias.LocationAliasID);
}
}
} }
public sealed class FloorPlanService( public sealed class FloorPlanService(
@ -8880,7 +8951,7 @@ public sealed class CharacterService(
CharacterID = character.CharacterID, CharacterID = character.CharacterID,
ProjectID = character.ProjectID, ProjectID = character.ProjectID,
CharacterName = character.CharacterName, CharacterName = character.CharacterName,
ShortName = character.ShortName, Aliases = (await characters.ListAliasesAsync(character.CharacterID)).Select(x => x.Alias).ToList(),
SexValueID = character.SexValueID, SexValueID = character.SexValueID,
Sex = character.Sex, Sex = character.Sex,
BirthDate = character.BirthDate, BirthDate = character.BirthDate,
@ -8928,6 +8999,7 @@ public sealed class CharacterService(
{ {
Project = project, Project = project,
Character = character, Character = character,
Aliases = await characters.ListAliasesAsync(character.CharacterID),
ProjectCharacters = projectCharacters, ProjectCharacters = projectCharacters,
Images = await characters.ListCharacterImagesAsync(character.CharacterID), Images = await characters.ListCharacterImagesAsync(character.CharacterID),
DisplayAge = DisplayAge(character), DisplayAge = DisplayAge(character),
@ -9009,7 +9081,7 @@ public sealed class CharacterService(
CharacterID = model.CharacterID, CharacterID = model.CharacterID,
ProjectID = model.ProjectID, ProjectID = model.ProjectID,
CharacterName = model.CharacterName, CharacterName = model.CharacterName,
ShortName = model.ShortName, ShortName = existing?.ShortName,
SexValueID = sex.SexValueID, SexValueID = sex.SexValueID,
Sex = sex.SexName, Sex = sex.SexName,
BirthDate = model.BirthDate, BirthDate = model.BirthDate,
@ -9022,6 +9094,7 @@ public sealed class CharacterService(
ImagePath = model.ImagePath ?? existing?.ImagePath, ImagePath = model.ImagePath ?? existing?.ImagePath,
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
}); });
await SyncCharacterAliasesAsync(characterId, model.Aliases);
var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID }; var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID };
imageHost.CharacterID = characterId; imageHost.CharacterID = characterId;
imageHost.ProjectID = model.ProjectID; imageHost.ProjectID = model.ProjectID;
@ -9044,6 +9117,33 @@ public sealed class CharacterService(
return new CharacterSaveResult(characterId, uploadedCharacterImageId); return new CharacterSaveResult(characterId, uploadedCharacterImageId);
} }
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await characters.ListAliasesAsync(characterId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await characters.UpdateAliasAsync(row.CharacterAliasID, alias, sortOrder);
}
else
{
await characters.AddAliasAsync(characterId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await characters.DeleteAliasAsync(alias.CharacterAliasID);
}
}
private async Task<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model) private async Task<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model)
{ {
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID); var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);

View File

@ -0,0 +1,607 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.CharacterAliases', N'U') IS NULL
BEGIN
CREATE TABLE dbo.CharacterAliases
(
CharacterAliasID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CharacterAliases PRIMARY KEY,
CharacterID int NOT NULL,
Alias nvarchar(200) NOT NULL,
SortOrder int NOT NULL CONSTRAINT DF_CharacterAliases_SortOrder DEFAULT (0),
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_CharacterAliases_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_CharacterAliases_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID) ON DELETE CASCADE
);
END;
GO
IF OBJECT_ID(N'dbo.AssetAliases', N'U') IS NULL
BEGIN
CREATE TABLE dbo.AssetAliases
(
AssetAliasID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_AssetAliases PRIMARY KEY,
StoryAssetID int NOT NULL,
Alias nvarchar(200) NOT NULL,
SortOrder int NOT NULL CONSTRAINT DF_AssetAliases_SortOrder DEFAULT (0),
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_AssetAliases_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_AssetAliases_StoryAssets FOREIGN KEY (StoryAssetID) REFERENCES dbo.StoryAssets(StoryAssetID) ON DELETE CASCADE
);
END;
GO
IF OBJECT_ID(N'dbo.LocationAliases', N'U') IS NULL
BEGIN
CREATE TABLE dbo.LocationAliases
(
LocationAliasID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_LocationAliases PRIMARY KEY,
LocationID int NOT NULL,
Alias nvarchar(200) NOT NULL,
SortOrder int NOT NULL CONSTRAINT DF_LocationAliases_SortOrder DEFAULT (0),
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_LocationAliases_CreatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_LocationAliases_Locations FOREIGN KEY (LocationID) REFERENCES dbo.Locations(LocationID) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CharacterAliases_CharacterAlias' AND object_id = OBJECT_ID(N'dbo.CharacterAliases'))
CREATE UNIQUE INDEX UX_CharacterAliases_CharacterAlias ON dbo.CharacterAliases(CharacterID, Alias);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CharacterAliases_CharacterSort' AND object_id = OBJECT_ID(N'dbo.CharacterAliases'))
CREATE INDEX IX_CharacterAliases_CharacterSort ON dbo.CharacterAliases(CharacterID, SortOrder, CharacterAliasID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_AssetAliases_AssetAlias' AND object_id = OBJECT_ID(N'dbo.AssetAliases'))
CREATE UNIQUE INDEX UX_AssetAliases_AssetAlias ON dbo.AssetAliases(StoryAssetID, Alias);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_AssetAliases_AssetSort' AND object_id = OBJECT_ID(N'dbo.AssetAliases'))
CREATE INDEX IX_AssetAliases_AssetSort ON dbo.AssetAliases(StoryAssetID, SortOrder, AssetAliasID);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_LocationAliases_LocationAlias' AND object_id = OBJECT_ID(N'dbo.LocationAliases'))
CREATE UNIQUE INDEX UX_LocationAliases_LocationAlias ON dbo.LocationAliases(LocationID, Alias);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_LocationAliases_LocationSort' AND object_id = OBJECT_ID(N'dbo.LocationAliases'))
CREATE INDEX IX_LocationAliases_LocationSort ON dbo.LocationAliases(LocationID, SortOrder, LocationAliasID);
GO
INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder)
SELECT c.CharacterID, LTRIM(RTRIM(c.ShortName)), 10
FROM dbo.Characters c
WHERE NULLIF(LTRIM(RTRIM(c.ShortName)), N'') IS NOT NULL
AND NOT EXISTS
(
SELECT 1
FROM dbo.CharacterAliases ca
WHERE ca.CharacterID = c.CharacterID
AND ca.Alias = LTRIM(RTRIM(c.ShortName))
);
GO
IF COL_LENGTH(N'dbo.Books', N'UsesExplicitScenes') IS NULL
ALTER TABLE dbo.Books ADD UsesExplicitScenes bit NOT NULL CONSTRAINT DF_Books_UsesExplicitScenes DEFAULT (0);
GO
CREATE OR ALTER PROCEDURE dbo.CharacterAlias_ListByCharacter
@CharacterID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CharacterAliasID, CharacterID, Alias, SortOrder, CreatedUtc
FROM dbo.CharacterAliases
WHERE CharacterID = @CharacterID
ORDER BY SortOrder, CharacterAliasID;
END;
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;
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;
UPDATE dbo.CharacterAliases
SET Alias = @Alias,
SortOrder = COALESCE(@SortOrder, SortOrder)
WHERE CharacterAliasID = @CharacterAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.CharacterAlias_Delete
@CharacterAliasID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.CharacterAliases WHERE CharacterAliasID = @CharacterAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.AssetAlias_ListByAsset
@StoryAssetID int
AS
BEGIN
SET NOCOUNT ON;
SELECT AssetAliasID, StoryAssetID, Alias, SortOrder, CreatedUtc
FROM dbo.AssetAliases
WHERE StoryAssetID = @StoryAssetID
ORDER BY SortOrder, AssetAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.AssetAlias_Add
@StoryAssetID 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;
IF @SortOrder IS NULL
SELECT @SortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.AssetAliases WHERE StoryAssetID = @StoryAssetID;
INSERT dbo.AssetAliases (StoryAssetID, Alias, SortOrder)
VALUES (@StoryAssetID, @Alias, @SortOrder);
SELECT CAST(SCOPE_IDENTITY() AS int) AS AssetAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.AssetAlias_Update
@AssetAliasID 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;
UPDATE dbo.AssetAliases
SET Alias = @Alias,
SortOrder = COALESCE(@SortOrder, SortOrder)
WHERE AssetAliasID = @AssetAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.AssetAlias_Delete
@AssetAliasID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.AssetAliases WHERE AssetAliasID = @AssetAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.LocationAlias_ListByLocation
@LocationID int
AS
BEGIN
SET NOCOUNT ON;
SELECT LocationAliasID, LocationID, Alias, SortOrder, CreatedUtc
FROM dbo.LocationAliases
WHERE LocationID = @LocationID
ORDER BY SortOrder, LocationAliasID;
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;
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;
UPDATE dbo.LocationAliases
SET Alias = @Alias,
SortOrder = COALESCE(@SortOrder, SortOrder)
WHERE LocationAliasID = @LocationAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.LocationAlias_Delete
@LocationAliasID int
AS
BEGIN
SET NOCOUNT ON;
DELETE dbo.LocationAliases WHERE LocationAliasID = @LocationAliasID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_ListByProject
@ProjectID int
AS
BEGIN
SET NOCOUNT ON;
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.WritingStartDate, b.TargetCompletionDate,
b.PublicationDate, b.UsesExplicitScenes, b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.ProjectID = @ProjectID AND b.IsArchived = 0
ORDER BY b.SortOrder, b.BookNumber, b.BookTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_ListByProjects
@ProjectIDs nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
;WITH ProjectIDs AS
(
SELECT DISTINCT TRY_CONVERT(int, value) AS ProjectID
FROM STRING_SPLIT(COALESCE(@ProjectIDs, N''), N',')
WHERE TRY_CONVERT(int, value) IS NOT NULL
)
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.WritingStartDate, b.TargetCompletionDate,
b.PublicationDate, b.UsesExplicitScenes, b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
INNER JOIN ProjectIDs ids ON ids.ProjectID = b.ProjectID
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.IsArchived = 0
ORDER BY b.ProjectID, b.SortOrder, b.BookNumber, b.BookTitle;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_Get
@BookID int
AS
BEGIN
SET NOCOUNT ON;
SELECT b.BookID, b.ProjectID, b.BookTitle, b.Subtitle, b.Tagline, b.ShortDescription, b.Status,
b.BookNumber, b.Description, b.TargetWordCount, b.WritingStartDate, b.TargetCompletionDate,
b.PublicationDate, b.UsesExplicitScenes, b.CoverThumbnailPath, b.CoverStandardPath,
ISNULL(wordCounts.CurrentWordCount, 0) AS CurrentWordCount,
ISNULL(counts.ChapterCount, 0) AS ChapterCount,
ISNULL(counts.SceneCount, 0) AS SceneCount,
b.SortOrder, b.CreatedDate, b.UpdatedDate, b.IsArchived, b.ArchivedDate, b.ArchivedReason
FROM dbo.Books b
OUTER APPLY
(
SELECT COUNT(DISTINCT c.ChapterID) AS ChapterCount, COUNT(s.SceneID) AS SceneCount
FROM dbo.Chapters c
LEFT JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) counts
OUTER APPLY
(
SELECT SUM(ISNULL(sw.ActualWordCount, 0)) AS CurrentWordCount
FROM dbo.Chapters c
INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0
LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID
WHERE c.BookID = b.BookID AND c.IsArchived = 0
) wordCounts
WHERE b.BookID = @BookID AND b.IsArchived = 0;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Book_Save
@BookID int = NULL,
@ProjectID int,
@BookTitle nvarchar(200),
@Subtitle nvarchar(200) = NULL,
@Tagline nvarchar(300) = NULL,
@ShortDescription nvarchar(max) = NULL,
@Status nvarchar(50) = N'Planning',
@BookNumber int,
@Description nvarchar(max) = NULL,
@TargetWordCount int = NULL,
@WritingStartDate date = NULL,
@TargetCompletionDate date = NULL,
@PublicationDate date = NULL,
@UsesExplicitScenes bit = 0
AS
BEGIN
SET NOCOUNT ON;
IF @TargetWordCount IS NOT NULL AND @TargetWordCount <= 0
THROW 51000, 'Target word count must be greater than zero.', 1;
IF NULLIF(LTRIM(RTRIM(@Status)), N'') IS NULL
SET @Status = N'Planning';
IF @BookID IS NULL OR @BookID = 0
BEGIN
DECLARE @NextBookOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 10;
INSERT dbo.Books
(
ProjectID, BookTitle, Subtitle, Tagline, ShortDescription, Status, BookNumber, Description,
TargetWordCount, WritingStartDate, TargetCompletionDate, PublicationDate, UsesExplicitScenes, SortOrder
)
VALUES
(
@ProjectID, @BookTitle, @Subtitle, @Tagline, @ShortDescription, @Status, @BookNumber, @Description,
@TargetWordCount, @WritingStartDate, @TargetCompletionDate, @PublicationDate, @UsesExplicitScenes, @NextBookOrder
);
SELECT CAST(SCOPE_IDENTITY() AS int) AS BookID;
RETURN;
END;
UPDATE dbo.Books
SET BookTitle = @BookTitle,
Subtitle = @Subtitle,
Tagline = @Tagline,
ShortDescription = @ShortDescription,
Status = @Status,
BookNumber = @BookNumber,
Description = @Description,
TargetWordCount = @TargetWordCount,
WritingStartDate = @WritingStartDate,
TargetCompletionDate = @TargetCompletionDate,
PublicationDate = @PublicationDate,
UsesExplicitScenes = @UsesExplicitScenes,
UpdatedDate = SYSUTCDATETIME()
WHERE BookID = @BookID;
SELECT @BookID AS BookID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Chapter_Save
@ChapterID int = NULL,
@BookID int,
@ChapterNumber decimal(9,2),
@ChapterTitle nvarchar(200),
@Summary nvarchar(max) = NULL,
@RevisionStatusID int
AS
BEGIN
SET NOCOUNT ON;
IF @ChapterID IS NULL OR @ChapterID = 0
BEGIN
DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10;
DECLARE @NewChapterID int;
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID)
VALUES (@BookID, @ChapterNumber, @ChapterTitle, @Summary, @NextChapterOrder, @RevisionStatusID);
SET @NewChapterID = CAST(SCOPE_IDENTITY() AS int);
SELECT @NewChapterID AS ChapterID;
RETURN;
END;
UPDATE dbo.Chapters
SET ChapterNumber = @ChapterNumber,
ChapterTitle = @ChapterTitle,
Summary = @Summary,
RevisionStatusID = @RevisionStatusID,
UpdatedDate = SYSUTCDATETIME()
WHERE ChapterID = @ChapterID;
SELECT @ChapterID AS ChapterID;
END;
GO
CREATE OR ALTER TRIGGER dbo.TR_Chapters_CreateDefaultScene
ON dbo.Chapters
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @DefaultTimeModeID int;
DECLARE @DefaultTimeConfidenceID int;
SELECT TOP (1) @DefaultTimeModeID = TimeModeID
FROM dbo.TimeModes
ORDER BY CASE WHEN TimeModeName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeModeID;
SELECT TOP (1) @DefaultTimeConfidenceID = TimeConfidenceID
FROM dbo.TimeConfidences
ORDER BY CASE WHEN TimeConfidenceName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeConfidenceID;
INSERT dbo.Scenes
(
ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID
)
SELECT i.ChapterID, 1, N'Scene 1', NULL, 10, @DefaultTimeModeID, @DefaultTimeConfidenceID, i.RevisionStatusID
FROM inserted i
WHERE i.IsArchived = 0
AND NOT EXISTS
(
SELECT 1
FROM dbo.Scenes s
WHERE s.ChapterID = i.ChapterID
AND s.IsArchived = 0
);
END;
GO
DECLARE @MigrationTimeModeID int;
DECLARE @MigrationTimeConfidenceID int;
SELECT TOP (1) @MigrationTimeModeID = TimeModeID
FROM dbo.TimeModes
ORDER BY CASE WHEN TimeModeName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeModeID;
SELECT TOP (1) @MigrationTimeConfidenceID = TimeConfidenceID
FROM dbo.TimeConfidences
ORDER BY CASE WHEN TimeConfidenceName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeConfidenceID;
INSERT dbo.Scenes
(
ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID
)
SELECT c.ChapterID, 1, N'Scene 1', NULL, 10, @MigrationTimeModeID, @MigrationTimeConfidenceID, c.RevisionStatusID
FROM dbo.Chapters c
WHERE c.IsArchived = 0
AND NOT EXISTS
(
SELECT 1
FROM dbo.Scenes s
WHERE s.ChapterID = c.ChapterID
AND s.IsArchived = 0
);
GO
CREATE OR ALTER PROCEDURE dbo.Scene_Archive
@SceneID int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ChapterID int;
SELECT @ChapterID = ChapterID
FROM dbo.Scenes
WHERE SceneID = @SceneID AND IsArchived = 0;
IF @ChapterID IS NULL
RETURN;
IF (SELECT COUNT(*) FROM dbo.Scenes WHERE ChapterID = @ChapterID AND IsArchived = 0) <= 1
THROW 51000, 'A chapter must contain at least one scene.', 1;
UPDATE dbo.Scenes
SET IsArchived = 1,
ArchivedDate = SYSUTCDATETIME(),
ArchivedReason = NULL,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.Archive_Entity
@EntityType nvarchar(80),
@EntityID int,
@ArchivedReason nvarchar(500) = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @EntityType = N'Project' UPDATE dbo.Projects SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ProjectID = @EntityID;
ELSE IF @EntityType = N'Book' UPDATE dbo.Books SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE BookID = @EntityID;
ELSE IF @EntityType = N'Chapter' UPDATE dbo.Chapters SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ChapterID = @EntityID;
ELSE IF @EntityType = N'Scene'
BEGIN
DECLARE @ChapterID int;
SELECT @ChapterID = ChapterID FROM dbo.Scenes WHERE SceneID = @EntityID AND IsArchived = 0;
IF @ChapterID IS NULL
RETURN;
IF (SELECT COUNT(*) FROM dbo.Scenes WHERE ChapterID = @ChapterID AND IsArchived = 0) <= 1
THROW 51000, 'A chapter must contain at least one scene.', 1;
UPDATE dbo.Scenes SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @EntityID;
END
ELSE IF @EntityType = N'PlotLine' UPDATE dbo.PlotLines SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE PlotLineID = @EntityID;
ELSE IF @EntityType = N'PlotThread' UPDATE dbo.PlotThreads SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE PlotThreadID = @EntityID;
ELSE IF @EntityType = N'StoryAsset' UPDATE dbo.StoryAssets SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE StoryAssetID = @EntityID;
ELSE IF @EntityType = N'Character' UPDATE dbo.Characters SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE CharacterID = @EntityID;
ELSE IF @EntityType = N'Location' UPDATE dbo.Locations SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE LocationID = @EntityID;
ELSE IF @EntityType = N'Scenario' UPDATE dbo.Scenarios SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ScenarioID = @EntityID;
ELSE IF @EntityType = N'Relationship' UPDATE dbo.CharacterRelationships SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE CharacterRelationshipID = @EntityID;
END;
GO

View File

@ -214,6 +214,9 @@ public sealed class BookEditViewModel
[Display(Name = "Publication date")] [Display(Name = "Publication date")]
public DateTime? PublicationDate { get; set; } public DateTime? PublicationDate { get; set; }
[Display(Name = "Uses explicit scenes")]
public bool UsesExplicitScenes { get; set; }
[Display(Name = "Cover image")] [Display(Name = "Cover image")]
public IFormFile? CoverUpload { get; set; } public IFormFile? CoverUpload { get; set; }
@ -1475,6 +1478,8 @@ public sealed class StoryAssetEditViewModel
[Display(Name = "Asset name")] [Display(Name = "Asset name")]
public string AssetName { get; set; } = string.Empty; public string AssetName { get; set; } = string.Empty;
public List<string> Aliases { get; set; } = [];
[Display(Name = "Kind")] [Display(Name = "Kind")]
public int AssetKindID { get; set; } public int AssetKindID { get; set; }
@ -1602,11 +1607,10 @@ public sealed class CharacterEditViewModel
public int ProjectID { get; set; } public int ProjectID { get; set; }
[Required, StringLength(200)] [Required, StringLength(200)]
[Display(Name = "Character name")] [Display(Name = "Display name")]
public string CharacterName { get; set; } = string.Empty; public string CharacterName { get; set; } = string.Empty;
[Display(Name = "Short name")] public List<string> Aliases { get; set; } = [];
public string? ShortName { get; set; }
[Display(Name = "Sex")] [Display(Name = "Sex")]
public int? SexValueID { get; set; } public int? SexValueID { get; set; }
@ -1666,6 +1670,7 @@ public sealed class CharacterDetailViewModel
{ {
public Project Project { get; set; } = new(); public Project Project { get; set; } = new();
public Character Character { get; set; } = new(); public Character Character { get; set; } = new();
public IReadOnlyList<CharacterAlias> Aliases { get; set; } = [];
public IReadOnlyList<Character> ProjectCharacters { get; set; } = []; public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
public IReadOnlyList<CharacterImage> Images { get; set; } = []; public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown"; public string DisplayAge { get; set; } = "Unknown";
@ -1915,6 +1920,8 @@ public sealed class LocationEditViewModel
[Display(Name = "Location name")] [Display(Name = "Location name")]
public string LocationName { get; set; } = string.Empty; public string LocationName { get; set; } = string.Empty;
public List<string> Aliases { get; set; } = [];
[Display(Name = "Location type")] [Display(Name = "Location type")]
public int? LocationTypeID { get; set; } public int? LocationTypeID { get; set; }

View File

@ -53,6 +53,12 @@
<select asp-for="Status" asp-items="Model.StatusOptions" class="form-select"></select> <select asp-for="Status" asp-items="Model.StatusOptions" class="form-select"></select>
<span asp-validation-for="Status" class="text-danger"></span> <span asp-validation-for="Status" class="text-danger"></span>
</div> </div>
<div class="col-md-4">
<div class="form-check mt-4">
<input asp-for="UsesExplicitScenes" class="form-check-input" />
<label asp-for="UsesExplicitScenes" class="form-check-label"></label>
</div>
</div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="TargetWordCount" class="form-label"></label> <label asp-for="TargetWordCount" class="form-label"></label>
<input asp-for="TargetWordCount" class="form-control" min="1" /> <input asp-for="TargetWordCount" class="form-control" min="1" />

View File

@ -76,7 +76,7 @@
<section id="character-overview" class="asset-detail-grid"> <section id="character-overview" class="asset-detail-grid">
<article class="plain-card"> <article class="plain-card">
<h2>Profile</h2> <h2>Profile</h2>
<p><strong>Short name:</strong> @Model.Character.ShortName</p> <p><strong>Aliases:</strong> @(Model.Aliases.Any() ? string.Join(", ", Model.Aliases.Select(x => x.Alias)) : "None")</p>
<p><strong>Sex:</strong> @Model.Character.Sex</p> <p><strong>Sex:</strong> @Model.Character.Sex</p>
<p><strong>Age:</strong> @Model.DisplayAge</p> <p><strong>Age:</strong> @Model.DisplayAge</p>
<p><strong>Height:</strong> @Model.Character.Height</p> <p><strong>Height:</strong> @Model.Character.Height</p>
@ -508,4 +508,3 @@
})(); })();
</script> </script>
} }

View File

@ -30,9 +30,22 @@
<input asp-for="CharacterName" class="form-control" /> <input asp-for="CharacterName" class="form-control" />
<span asp-validation-for="CharacterName" class="text-danger"></span> <span asp-validation-for="CharacterName" class="text-danger"></span>
</div> </div>
<div class="col-md-4"> <div class="col-12">
<label asp-for="ShortName" class="form-label"></label> <label class="form-label">Aliases</label>
<input asp-for="ShortName" class="form-control" /> <div class="d-flex flex-wrap gap-2 mb-2" data-alias-list>
@for (var i = 0; i < Model.Aliases.Count; i++)
{
<span class="badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2">
<span>@Model.Aliases[i]</span>
<button class="btn-close btn-close-sm" type="button" aria-label="Remove @Model.Aliases[i]" data-remove-alias></button>
<input type="hidden" name="Aliases" value="@Model.Aliases[i]" />
</span>
}
</div>
<div class="input-group">
<input class="form-control" type="text" maxlength="200" placeholder="Add an alias" data-alias-input />
<button class="btn btn-outline-secondary" type="button" data-add-alias>Add</button>
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="SexValueID" class="form-label"></label> <label asp-for="SexValueID" class="form-label"></label>
@ -130,5 +143,52 @@
select.addEventListener("change", syncCustomSex); select.addEventListener("change", syncCustomSex);
syncCustomSex(); syncCustomSex();
})(); })();
(() => {
const list = document.querySelector("[data-alias-list]");
const input = document.querySelector("[data-alias-input]");
const add = document.querySelector("[data-add-alias]");
if (!list || !input || !add) return;
const aliases = () => [...list.querySelectorAll("input[name='Aliases']")].map(x => x.value.toLowerCase());
const appendAlias = (value) => {
const alias = value.trim();
if (!alias || aliases().includes(alias.toLowerCase())) return;
const chip = document.createElement("span");
chip.className = "badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2";
const text = document.createElement("span");
text.textContent = alias;
const remove = document.createElement("button");
remove.className = "btn-close btn-close-sm";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${alias}`);
remove.dataset.removeAlias = "";
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "Aliases";
hidden.value = alias;
chip.append(text, remove, hidden);
list.append(chip);
input.value = "";
};
add.addEventListener("click", () => appendAlias(input.value));
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
appendAlias(input.value);
}
});
list.addEventListener("click", event => {
if (event.target instanceof HTMLElement && event.target.matches("[data-remove-alias]")) {
event.target.closest(".badge")?.remove();
}
});
})();
</script> </script>
} }

View File

@ -42,10 +42,6 @@
</div> </div>
</div> </div>
<div class="asset-card-footer"> <div class="asset-card-footer">
@if (!string.IsNullOrWhiteSpace(character.ShortName))
{
<span class="purpose-chip">@character.ShortName</span>
}
@if (character.AgeAtSeriesStart.HasValue) @if (character.AgeAtSeriesStart.HasValue)
{ {
<span class="status-pill">Age @character.AgeAtSeriesStart</span> <span class="status-pill">Age @character.AgeAtSeriesStart</span>

View File

@ -33,6 +33,23 @@
<input asp-for="LocationName" class="form-control" /> <input asp-for="LocationName" class="form-control" />
<span asp-validation-for="LocationName" class="text-danger"></span> <span asp-validation-for="LocationName" class="text-danger"></span>
</div> </div>
<div class="col-12">
<label class="form-label">Aliases</label>
<div class="d-flex flex-wrap gap-2 mb-2" data-alias-list>
@for (var i = 0; i < Model.Aliases.Count; i++)
{
<span class="badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2">
<span>@Model.Aliases[i]</span>
<button class="btn-close btn-close-sm" type="button" aria-label="Remove @Model.Aliases[i]" data-remove-alias></button>
<input type="hidden" name="Aliases" value="@Model.Aliases[i]" />
</span>
}
</div>
<div class="input-group">
<input class="form-control" type="text" maxlength="200" placeholder="Add an alias" data-alias-input />
<button class="btn btn-outline-secondary" type="button" data-add-alias>Add</button>
</div>
</div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="LocationTypeID" class="form-label">Location type <help-icon key="locations.fields.locationType" mode="inline" /></label> <label asp-for="LocationTypeID" class="form-label">Location type <help-icon key="locations.fields.locationType" mode="inline" /></label>
<select asp-for="LocationTypeID" asp-items="Model.LocationTypeOptions" class="form-select"> <select asp-for="LocationTypeID" asp-items="Model.LocationTypeOptions" class="form-select">
@ -66,4 +83,48 @@
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script>
(() => {
const list = document.querySelector("[data-alias-list]");
const input = document.querySelector("[data-alias-input]");
const add = document.querySelector("[data-add-alias]");
if (!list || !input || !add) return;
const aliases = () => [...list.querySelectorAll("input[name='Aliases']")].map(x => x.value.toLowerCase());
const appendAlias = (value) => {
const alias = value.trim();
if (!alias || aliases().includes(alias.toLowerCase())) return;
const chip = document.createElement("span");
chip.className = "badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2";
const text = document.createElement("span");
text.textContent = alias;
const remove = document.createElement("button");
remove.className = "btn-close btn-close-sm";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${alias}`);
remove.dataset.removeAlias = "";
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "Aliases";
hidden.value = alias;
chip.append(text, remove, hidden);
list.append(chip);
input.value = "";
};
add.addEventListener("click", () => appendAlias(input.value));
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
appendAlias(input.value);
}
});
list.addEventListener("click", event => {
if (event.target instanceof HTMLElement && event.target.matches("[data-remove-alias]")) {
event.target.closest(".badge")?.remove();
}
});
})();
</script>
} }

View File

@ -27,6 +27,23 @@
<input asp-for="AssetName" class="form-control" /> <input asp-for="AssetName" class="form-control" />
<span asp-validation-for="AssetName" class="text-danger"></span> <span asp-validation-for="AssetName" class="text-danger"></span>
</div> </div>
<div class="col-12">
<label class="form-label">Aliases</label>
<div class="d-flex flex-wrap gap-2 mb-2" data-alias-list>
@for (var i = 0; i < Model.Aliases.Count; i++)
{
<span class="badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2">
<span>@Model.Aliases[i]</span>
<button class="btn-close btn-close-sm" type="button" aria-label="Remove @Model.Aliases[i]" data-remove-alias></button>
<input type="hidden" name="Aliases" value="@Model.Aliases[i]" />
</span>
}
</div>
<div class="input-group">
<input class="form-control" type="text" maxlength="200" placeholder="Add an alias" data-alias-input />
<button class="btn btn-outline-secondary" type="button" data-add-alias>Add</button>
</div>
</div>
<div class="col-md-4"> <div class="col-md-4">
<label asp-for="AssetKindID" class="form-label">Kind <help-icon key="storyAssets.fields.kind" mode="inline" /></label> <label asp-for="AssetKindID" class="form-label">Kind <help-icon key="storyAssets.fields.kind" mode="inline" /></label>
<select asp-for="AssetKindID" asp-items="Model.AssetKindOptions" class="form-select"></select> <select asp-for="AssetKindID" asp-items="Model.AssetKindOptions" class="form-select"></select>
@ -100,4 +117,48 @@
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
<script>
(() => {
const list = document.querySelector("[data-alias-list]");
const input = document.querySelector("[data-alias-input]");
const add = document.querySelector("[data-add-alias]");
if (!list || !input || !add) return;
const aliases = () => [...list.querySelectorAll("input[name='Aliases']")].map(x => x.value.toLowerCase());
const appendAlias = (value) => {
const alias = value.trim();
if (!alias || aliases().includes(alias.toLowerCase())) return;
const chip = document.createElement("span");
chip.className = "badge rounded-pill text-bg-light border d-inline-flex align-items-center gap-2";
const text = document.createElement("span");
text.textContent = alias;
const remove = document.createElement("button");
remove.className = "btn-close btn-close-sm";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${alias}`);
remove.dataset.removeAlias = "";
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = "Aliases";
hidden.value = alias;
chip.append(text, remove, hidden);
list.append(chip);
input.value = "";
};
add.addEventListener("click", () => appendAlias(input.value));
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
appendAlias(input.value);
}
});
list.addEventListener("click", event => {
if (event.target instanceof HTMLElement && event.target.matches("[data-remove-alias]")) {
event.target.closest(".badge")?.remove();
}
});
})();
</script>
} }