From 3c48301732e1a59f47a867944f29251146dda4bb Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 27 Jun 2026 18:59:31 +0100 Subject: [PATCH] Phase 16A - Base Identity Model Cleanup and Chapter/Scene Foundation --- PlotLine/Controllers/BooksController.cs | 1 + PlotLine/Controllers/CharactersController.cs | 2 +- PlotLine/Controllers/LocationsController.cs | 16 +- PlotLine/Controllers/ScenesController.cs | 11 +- PlotLine/Controllers/StoryAssetsController.cs | 1 + PlotLine/Data/ImportRepository.cs | 20 +- PlotLine/Data/Repositories.cs | 136 +++- PlotLine/Models/CoreModels.cs | 28 + PlotLine/Services/CoreServices.cs | 106 ++- ...hase16A_BaseIdentityAndSceneFoundation.sql | 607 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 13 +- PlotLine/Views/Books/Edit.cshtml | 6 + PlotLine/Views/Characters/Details.cshtml | 3 +- PlotLine/Views/Characters/Edit.cshtml | 66 +- PlotLine/Views/Characters/Index.cshtml | 4 - PlotLine/Views/Locations/Edit.cshtml | 61 ++ PlotLine/Views/StoryAssets/Edit.cshtml | 61 ++ 17 files changed, 1120 insertions(+), 22 deletions(-) create mode 100644 PlotLine/Sql/093_Phase16A_BaseIdentityAndSceneFoundation.sql diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs index e4558c1..eda932a 100644 --- a/PlotLine/Controllers/BooksController.cs +++ b/PlotLine/Controllers/BooksController.cs @@ -67,6 +67,7 @@ public sealed class BooksController(IBookService books) : Controller createModel.WritingStartDate = model.WritingStartDate; createModel.TargetCompletionDate = model.TargetCompletionDate; createModel.PublicationDate = model.PublicationDate; + createModel.UsesExplicitScenes = model.UsesExplicitScenes; await books.PopulateEditContextAsync(createModel); return View("Edit", createModel); } diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index 68e95a6..df019c9 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -118,7 +118,7 @@ public sealed class CharactersController(ICharacterService characters) : Control private static void CopyCharacterFormValues(CharacterEditViewModel source, CharacterEditViewModel target) { target.CharacterName = source.CharacterName; - target.ShortName = source.ShortName; + target.Aliases = source.Aliases; target.SexValueID = source.SexValueID; target.Sex = source.Sex; target.CustomSex = source.CustomSex; diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs index c311b64..47c40fe 100644 --- a/PlotLine/Controllers/LocationsController.cs +++ b/PlotLine/Controllers/LocationsController.cs @@ -38,7 +38,21 @@ public sealed class LocationsController(ILocationService locations) : Controller { 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); diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index f31f832..8ec1ddd 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -88,7 +88,16 @@ public sealed class ScenesController( [ValidateAntiForgeryToken] public async Task 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."; if (TryGetLocalReturnUrl(out var returnUrl)) { diff --git a/PlotLine/Controllers/StoryAssetsController.cs b/PlotLine/Controllers/StoryAssetsController.cs index 592991b..c69305e 100644 --- a/PlotLine/Controllers/StoryAssetsController.cs +++ b/PlotLine/Controllers/StoryAssetsController.cs @@ -110,6 +110,7 @@ public sealed class StoryAssetsController(IAssetService assets) : Controller } model.AssetName = posted.AssetName; + model.Aliases = posted.Aliases; model.AssetKindID = posted.AssetKindID; model.Description = posted.Description; model.Importance = posted.Importance; diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index e10eaa6..49f8994 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -287,15 +287,31 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : commandType: CommandType.StoredProcedure); 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( + "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 povCharacterId = ResolveId(characterMap, scene.PovCharacterName, package.Aliases.Characters); var sceneId = await connection.QuerySingleAsync( "dbo.Scene_Save", new { - SceneID = 0, + SceneID = sceneIndex == 0 ? defaultSceneId : 0, ChapterID = chapterId, scene.SceneNumber, SceneTitle = Clean(scene.Title), diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index a81e87d..2e8e6e4 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -1,5 +1,6 @@ using System.Data; using Dapper; +using Microsoft.Data.SqlClient; using PlotLine.Models; namespace PlotLine.Data; @@ -159,6 +160,10 @@ public interface IAssetRepository Task> ListAssetsAsync(int projectId); Task GetAssetAsync(int storyAssetId); Task SaveAssetAsync(StoryAsset asset); + Task> ListAliasesAsync(int storyAssetId); + Task 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 ArchiveAssetAsync(int storyAssetId); Task> ListEventsBySceneAsync(int sceneId); @@ -183,6 +188,10 @@ public interface ICharacterRepository Task> ListCharactersAsync(int projectId); Task GetCharacterAsync(int characterId); Task SaveCharacterAsync(Character character); + Task> ListAliasesAsync(int characterId); + Task AddAliasAsync(int characterId, string alias, int? sortOrder); + Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder); + Task DeleteAliasAsync(int characterAliasId); Task> ListSexValuesAsync(int ownerUserId); Task GetSexValueAsync(int characterSexValueId, int ownerUserId); Task GetOrCreateSexValueAsync(int ownerUserId, string sexName); @@ -223,6 +232,10 @@ public interface ILocationRepository Task> ListByProjectAsync(int projectId); Task GetAsync(int locationId); Task SaveAsync(LocationItem location); + Task> ListAliasesAsync(int locationId); + Task AddAliasAsync(int locationId, string alias, int? sortOrder); + Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder); + Task DeleteAliasAsync(int locationAliasId); Task ArchiveAsync(int locationId); Task> ListRelationshipsByProjectAsync(int projectId); Task> ListRelationshipsByLocationAsync(int locationId); @@ -477,6 +490,43 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task> ListAliasesAsync(int locationId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.LocationAlias_ListByLocation", + new { LocationID = locationId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task AddAliasAsync(int locationId, string alias, int? sortOrder) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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) { using var connection = connectionFactory.CreateConnection(); @@ -2178,7 +2228,8 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB book.TargetWordCount, book.WritingStartDate, book.TargetCompletionDate, - book.PublicationDate + book.PublicationDate, + book.UsesExplicitScenes }, commandType: CommandType.StoredProcedure); } @@ -2337,7 +2388,14 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I public async Task ArchiveAsync(int sceneId) { 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) @@ -2977,6 +3035,43 @@ public sealed class AssetRepository(ISqlConnectionFactory connectionFactory) : I commandType: CommandType.StoredProcedure); } + public async Task> ListAliasesAsync(int storyAssetId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.AssetAlias_ListByAsset", + new { StoryAssetID = storyAssetId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task AddAliasAsync(int storyAssetId, string alias, int? sortOrder) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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) { using var connection = connectionFactory.CreateConnection(); @@ -3228,6 +3323,43 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task> ListAliasesAsync(int characterId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.CharacterAlias_ListByCharacter", + new { CharacterID = characterId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task AddAliasAsync(int characterId, string alias, int? sortOrder) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "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> ListSexValuesAsync(int ownerUserId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index c8c89af..594877a 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -238,6 +238,7 @@ public sealed class Book public DateTime? WritingStartDate { get; set; } public DateTime? TargetCompletionDate { get; set; } public DateTime? PublicationDate { get; set; } + public bool UsesExplicitScenes { get; set; } public string? CoverThumbnailPath { get; set; } public string? CoverStandardPath { get; set; } public int CurrentWordCount { get; set; } @@ -254,6 +255,33 @@ public sealed class Book : 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 const string Planning = "Planning"; diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 44e77cc..92fbf0d 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -10,6 +10,17 @@ using PlotLine.ViewModels; namespace PlotLine.Services; +internal static class AliasInput +{ + public static IReadOnlyList Clean(IEnumerable? aliases) => + (aliases ?? []) + .Select(alias => (alias ?? string.Empty).Trim()) + .Where(alias => alias.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(50) + .ToList(); +} + public interface IProjectService { Task ListAsync(); @@ -1181,6 +1192,7 @@ public sealed class BookService( WritingStartDate = book.WritingStartDate, TargetCompletionDate = book.TargetCompletionDate, PublicationDate = book.PublicationDate, + UsesExplicitScenes = book.UsesExplicitScenes, CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath, CoverStandardPath = book.CoverStandardPath, Project = await projects.GetAsync(book.ProjectID), @@ -1227,7 +1239,8 @@ public sealed class BookService( TargetWordCount = model.TargetWordCount, WritingStartDate = model.WritingStartDate, TargetCompletionDate = model.TargetCompletionDate, - PublicationDate = model.PublicationDate + PublicationDate = model.PublicationDate, + UsesExplicitScenes = model.UsesExplicitScenes }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); @@ -7479,6 +7492,7 @@ public sealed class AssetService( StoryAssetID = asset.StoryAssetID, ProjectID = asset.ProjectID, AssetName = asset.AssetName, + Aliases = (await assets.ListAliasesAsync(asset.StoryAssetID)).Select(x => x.Alias).ToList(), AssetKindID = asset.AssetKindID, Description = asset.Description, Importance = asset.Importance, @@ -7549,6 +7563,7 @@ public sealed class AssetService( ImagePath = model.ImagePath ?? existing?.ImagePath, ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath }); + await SyncAssetAliasesAsync(assetId, model.Aliases); var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID }; imageHost.StoryAssetID = assetId; imageHost.ProjectID = model.ProjectID; @@ -7664,6 +7679,33 @@ public sealed class AssetService( return model; } + private async Task SyncAssetAliasesAsync(int storyAssetId, IEnumerable 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 OptionalStateList(IEnumerable states) => 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, ParentLocationID = location.ParentLocationID, LocationName = location.LocationName, + Aliases = (await locations.ListAliasesAsync(location.LocationID)).Select(x => x.Alias).ToList(), LocationTypeID = location.LocationTypeID, Description = location.Description, ShowInQuickAddBar = location.ShowInQuickAddBar, @@ -7768,6 +7811,7 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi Description = model.Description, ShowInQuickAddBar = model.ShowInQuickAddBar }); + await SyncLocationAliasesAsync(locationId, model.Aliases); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); 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); return model; } + + private async Task SyncLocationAliasesAsync(int locationId, IEnumerable 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( @@ -8880,7 +8951,7 @@ public sealed class CharacterService( CharacterID = character.CharacterID, ProjectID = character.ProjectID, CharacterName = character.CharacterName, - ShortName = character.ShortName, + Aliases = (await characters.ListAliasesAsync(character.CharacterID)).Select(x => x.Alias).ToList(), SexValueID = character.SexValueID, Sex = character.Sex, BirthDate = character.BirthDate, @@ -8928,6 +8999,7 @@ public sealed class CharacterService( { Project = project, Character = character, + Aliases = await characters.ListAliasesAsync(character.CharacterID), ProjectCharacters = projectCharacters, Images = await characters.ListCharacterImagesAsync(character.CharacterID), DisplayAge = DisplayAge(character), @@ -9009,7 +9081,7 @@ public sealed class CharacterService( CharacterID = model.CharacterID, ProjectID = model.ProjectID, CharacterName = model.CharacterName, - ShortName = model.ShortName, + ShortName = existing?.ShortName, SexValueID = sex.SexValueID, Sex = sex.SexName, BirthDate = model.BirthDate, @@ -9022,6 +9094,7 @@ public sealed class CharacterService( ImagePath = model.ImagePath ?? existing?.ImagePath, ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath }); + await SyncCharacterAliasesAsync(characterId, model.Aliases); var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID }; imageHost.CharacterID = characterId; imageHost.ProjectID = model.ProjectID; @@ -9044,6 +9117,33 @@ public sealed class CharacterService( return new CharacterSaveResult(characterId, uploadedCharacterImageId); } + private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable 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 PopulateCharacterEditOptionsAsync(CharacterEditViewModel model) { var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID); diff --git a/PlotLine/Sql/093_Phase16A_BaseIdentityAndSceneFoundation.sql b/PlotLine/Sql/093_Phase16A_BaseIdentityAndSceneFoundation.sql new file mode 100644 index 0000000..92184ab --- /dev/null +++ b/PlotLine/Sql/093_Phase16A_BaseIdentityAndSceneFoundation.sql @@ -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 diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index d753a8f..d96b01a 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -214,6 +214,9 @@ public sealed class BookEditViewModel [Display(Name = "Publication date")] public DateTime? PublicationDate { get; set; } + [Display(Name = "Uses explicit scenes")] + public bool UsesExplicitScenes { get; set; } + [Display(Name = "Cover image")] public IFormFile? CoverUpload { get; set; } @@ -1475,6 +1478,8 @@ public sealed class StoryAssetEditViewModel [Display(Name = "Asset name")] public string AssetName { get; set; } = string.Empty; + public List Aliases { get; set; } = []; + [Display(Name = "Kind")] public int AssetKindID { get; set; } @@ -1602,11 +1607,10 @@ public sealed class CharacterEditViewModel public int ProjectID { get; set; } [Required, StringLength(200)] - [Display(Name = "Character name")] + [Display(Name = "Display name")] public string CharacterName { get; set; } = string.Empty; - [Display(Name = "Short name")] - public string? ShortName { get; set; } + public List Aliases { get; set; } = []; [Display(Name = "Sex")] public int? SexValueID { get; set; } @@ -1666,6 +1670,7 @@ public sealed class CharacterDetailViewModel { public Project Project { get; set; } = new(); public Character Character { get; set; } = new(); + public IReadOnlyList Aliases { get; set; } = []; public IReadOnlyList ProjectCharacters { get; set; } = []; public IReadOnlyList Images { get; set; } = []; public string DisplayAge { get; set; } = "Unknown"; @@ -1915,6 +1920,8 @@ public sealed class LocationEditViewModel [Display(Name = "Location name")] public string LocationName { get; set; } = string.Empty; + public List Aliases { get; set; } = []; + [Display(Name = "Location type")] public int? LocationTypeID { get; set; } diff --git a/PlotLine/Views/Books/Edit.cshtml b/PlotLine/Views/Books/Edit.cshtml index f629959..9ce0b23 100644 --- a/PlotLine/Views/Books/Edit.cshtml +++ b/PlotLine/Views/Books/Edit.cshtml @@ -53,6 +53,12 @@ +
+
+ + +
+
diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index 6d445a4..6b50ed8 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -76,7 +76,7 @@

Profile

-

Short name: @Model.Character.ShortName

+

Aliases: @(Model.Aliases.Any() ? string.Join(", ", Model.Aliases.Select(x => x.Alias)) : "None")

Sex: @Model.Character.Sex

Age: @Model.DisplayAge

Height: @Model.Character.Height

@@ -508,4 +508,3 @@ })(); } - diff --git a/PlotLine/Views/Characters/Edit.cshtml b/PlotLine/Views/Characters/Edit.cshtml index ba4ce7f..0cd4fae 100644 --- a/PlotLine/Views/Characters/Edit.cshtml +++ b/PlotLine/Views/Characters/Edit.cshtml @@ -30,9 +30,22 @@
-
- - +
+ +
+ @for (var i = 0; i < Model.Aliases.Count; i++) + { + + @Model.Aliases[i] + + + + } +
+
+ + +
@@ -130,5 +143,52 @@ select.addEventListener("change", 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(); + } + }); + })(); } diff --git a/PlotLine/Views/Characters/Index.cshtml b/PlotLine/Views/Characters/Index.cshtml index 92aee3e..49debbf 100644 --- a/PlotLine/Views/Characters/Index.cshtml +++ b/PlotLine/Views/Characters/Index.cshtml @@ -42,10 +42,6 @@
+
+ +
+ @for (var i = 0; i < Model.Aliases.Count; i++) + { + + @Model.Aliases[i] + + + + } +
+
+ + +
+
+
+ +
+ @for (var i = 0; i < Model.Aliases.Count; i++) + { + + @Model.Aliases[i] + + + + } +
+
+ + +
+
@@ -100,4 +117,48 @@ @section Scripts { + }