using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IProjectService { Task> ListAsync(); Task GetDetailAsync(int projectId); Task GetEditAsync(int projectId); Task SaveAsync(ProjectEditViewModel model); Task ArchiveAsync(int projectId); } public interface IBookService { Task GetDetailAsync(int bookId); Task GetCreateAsync(int projectId); Task GetEditAsync(int bookId); Task SaveAsync(BookEditViewModel model); Task ArchiveAsync(int bookId); } public interface IChapterService { Task GetDetailAsync(int chapterId); Task GetCreateAsync(int bookId); Task GetEditAsync(int chapterId); Task SaveAsync(ChapterEditViewModel model); Task ArchiveAsync(int chapterId); } public interface ISceneService { Task GetCreateAsync(int chapterId); Task GetEditAsync(int sceneId); Task SaveAsync(SceneEditViewModel model); Task ArchiveAsync(int sceneId); Task MoveAsync(int sceneId, string direction); Task GetMovePreviewAsync(SceneMoveRequestViewModel request); Task MoveToChapterAsync(SceneMoveRequestViewModel request); Task RenumberChapterAsync(int chapterId); Task AddDependencyAsync(SceneDependencyCreateViewModel model); Task DeleteDependencyAsync(int sceneDependencyId); } public interface IPlotService { Task GetPlotLinesAsync(int projectId); Task GetCreatePlotLineAsync(int projectId); Task GetEditPlotLineAsync(int plotLineId); Task SavePlotLineAsync(PlotLineEditViewModel model); Task ArchivePlotLineAsync(int plotLineId); Task GetPlotThreadsAsync(int projectId); Task GetCreatePlotThreadAsync(int projectId, int? plotLineId); Task GetEditPlotThreadAsync(int plotThreadId); Task SavePlotThreadAsync(PlotThreadEditViewModel model); Task ArchivePlotThreadAsync(int plotThreadId); Task AddThreadEventAsync(ThreadEventCreateViewModel model); Task DeleteThreadEventAsync(int threadEventId); } public interface IAssetService { Task GetAssetsAsync(int projectId); Task GetCreateAssetAsync(int projectId); Task GetEditAssetAsync(int storyAssetId); Task GetAssetDetailAsync(int storyAssetId); Task SaveAssetAsync(StoryAssetEditViewModel model); Task ArchiveAssetAsync(int storyAssetId); Task AddAssetEventAsync(AssetEventCreateViewModel model); Task DeleteAssetEventAsync(int assetEventId); Task AddCustodyEventAsync(AssetCustodyCreateViewModel model); Task DeleteCustodyEventAsync(int assetCustodyEventId); Task SaveDependencyAsync(AssetDependencyEditViewModel model); Task DeleteDependencyAsync(int assetDependencyId); Task> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId); } public interface ILocationService { Task GetLocationsAsync(int projectId); Task GetCreateAsync(int projectId); Task GetEditAsync(int locationId); Task GetDetailAsync(int locationId); Task SaveAsync(LocationEditViewModel model); Task ArchiveAsync(int locationId); Task SaveRelationshipAsync(LocationRelationshipEditViewModel model); Task ArchiveRelationshipAsync(int locationRelationshipId); Task SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model); Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId); } public interface IContinuityValidationService { Task GetDashboardAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional, ContinuityValidationSummary? summary = null); Task ValidateProjectAsync(int projectId); Task ValidateBookAsync(int bookId); Task ValidateSceneAsync(int sceneId); Task ValidateAfterSceneMoveAsync(int projectId, int? bookId); Task ValidateAssetAsync(int assetId); Task ValidateCharacterAsync(int characterId); Task> ListSceneWarningsAsync(int sceneId); Task> GetSceneCountsAsync(int projectId, int? bookId); Task DismissAsync(int warningId); Task MarkIntentionalAsync(int warningId); Task RestoreAsync(int warningId); } public interface ICharacterService { Task GetCharactersAsync(int projectId); Task GetCreateCharacterAsync(int projectId); Task GetEditCharacterAsync(int characterId); Task GetCharacterDetailAsync(int characterId); Task SaveCharacterAsync(CharacterEditViewModel model); Task ArchiveCharacterAsync(int characterId); Task AddSceneCharacterAsync(SceneCharacterCreateViewModel model); Task DeleteSceneCharacterAsync(int sceneCharacterId); Task AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model); Task AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model); Task AddRelationshipEventAsync(RelationshipEventCreateViewModel model); string DisplayAge(Character character, DateTime? sceneStartDateTime = null); } public interface ITimelineService { Task GetAsync(int projectId, int? bookId, int? selectedSceneId); } public interface IAnalyticsService { Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber); } public sealed class ProjectService(IProjectRepository projects, IBookRepository books) : IProjectService { public Task> ListAsync() => projects.ListAsync(); public async Task GetDetailAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } return new ProjectDetailViewModel { Project = project, Books = await books.ListByProjectAsync(projectId) }; } public async Task GetEditAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new ProjectEditViewModel { ProjectID = project.ProjectID, ProjectName = project.ProjectName, Description = project.Description }; } public Task SaveAsync(ProjectEditViewModel model) => projects.SaveAsync(new Project { ProjectID = model.ProjectID, ProjectName = model.ProjectName, Description = model.Description }); public Task ArchiveAsync(int projectId) => projects.ArchiveAsync(projectId); } public sealed class BookService(IProjectRepository projects, IBookRepository books, IChapterRepository chapters) : IBookService { public async Task GetDetailAsync(int bookId) { var book = await books.GetAsync(bookId); if (book is null) { return null; } var project = await projects.GetAsync(book.ProjectID); if (project is null) { return null; } return new BookDetailViewModel { Project = project, Book = book, Chapters = await chapters.ListByBookAsync(bookId) }; } public async Task GetCreateAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var existing = await books.ListByProjectAsync(projectId); return new BookEditViewModel { ProjectID = projectId, Project = project, BookNumber = existing.Count + 1 }; } public async Task GetEditAsync(int bookId) { var book = await books.GetAsync(bookId); if (book is null) { return null; } return new BookEditViewModel { BookID = book.BookID, ProjectID = book.ProjectID, BookTitle = book.BookTitle, BookNumber = book.BookNumber, Description = book.Description, Project = await projects.GetAsync(book.ProjectID) }; } public Task SaveAsync(BookEditViewModel model) => books.SaveAsync(new Book { BookID = model.BookID, ProjectID = model.ProjectID, BookTitle = model.BookTitle, BookNumber = model.BookNumber, Description = model.Description }); public Task ArchiveAsync(int bookId) => books.ArchiveAsync(bookId); } public sealed class ChapterService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, ILookupRepository lookups) : IChapterService { public async Task GetDetailAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); if (chapter is null) { return null; } var book = await books.GetAsync(chapter.BookID); var project = book is null ? null : await projects.GetAsync(book.ProjectID); if (book is null || project is null) { return null; } return new ChapterDetailViewModel { Project = project, Book = book, Chapter = chapter, Scenes = await scenes.ListByChapterAsync(chapterId) }; } public async Task GetCreateAsync(int bookId) { var book = await books.GetAsync(bookId); var project = book is null ? null : await projects.GetAsync(book.ProjectID); if (book is null || project is null) { return null; } var existing = await chapters.ListByBookAsync(bookId); var lookupData = await lookups.GetAllAsync(); return new ChapterEditViewModel { BookID = bookId, Book = book, Project = project, ChapterNumber = existing.Count + 1, RevisionStatusID = DefaultRevisionStatusId(lookupData), RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName) }; } public async Task GetEditAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); if (chapter is null) { return null; } var book = await books.GetAsync(chapter.BookID); var project = book is null ? null : await projects.GetAsync(book.ProjectID); if (book is null || project is null) { return null; } var lookupData = await lookups.GetAllAsync(); return new ChapterEditViewModel { ChapterID = chapter.ChapterID, BookID = chapter.BookID, ChapterNumber = chapter.ChapterNumber, ChapterTitle = chapter.ChapterTitle, Summary = chapter.Summary, RevisionStatusID = chapter.RevisionStatusID, Book = book, Project = project, RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName) }; } public Task SaveAsync(ChapterEditViewModel model) => chapters.SaveAsync(new Chapter { ChapterID = model.ChapterID, BookID = model.BookID, ChapterNumber = model.ChapterNumber, ChapterTitle = model.ChapterTitle, Summary = model.Summary, RevisionStatusID = model.RevisionStatusID }); public Task ArchiveAsync(int chapterId) => chapters.ArchiveAsync(chapterId); internal static int DefaultRevisionStatusId(LookupData lookupData) => lookupData.RevisionStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.RevisionStatusID ?? lookupData.RevisionStatuses.First().RevisionStatusID; internal static IReadOnlyList ToSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList(); } public sealed class SceneService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, ILookupRepository lookups, IPlotRepository plots, IAssetRepository assets, ICharacterRepository characters, ILocationRepository locations, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies) : ISceneService { public async Task GetCreateAsync(int chapterId) { var context = await GetContextAsync(chapterId); if (context is null) { return null; } var existing = await scenes.ListByChapterAsync(chapterId); var lookupData = await lookups.GetAllAsync(); return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel { ChapterID = chapterId, SceneNumber = existing.Count + 1, RevisionStatusID = ChapterService.DefaultRevisionStatusId(lookupData), TimeModeID = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeID ?? lookupData.TimeModes.First().TimeModeID, TimeConfidenceID = lookupData.TimeConfidences.FirstOrDefault(x => x.TimeConfidenceName == "Unknown")?.TimeConfidenceID ?? lookupData.TimeConfidences.First().TimeConfidenceID, Chapter = context.Value.Chapter, Book = context.Value.Book, Project = context.Value.Project }, lookupData)); } public async Task GetEditAsync(int sceneId) { var scene = await scenes.GetAsync(sceneId); if (scene is null) { return null; } var context = await GetContextAsync(scene.ChapterID); if (context is null) { return null; } var lookupData = await lookups.GetAllAsync(); return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel { SceneID = scene.SceneID, ChapterID = scene.ChapterID, SceneNumber = scene.SceneNumber, SceneTitle = scene.SceneTitle, Summary = scene.Summary, TimeModeID = scene.TimeModeID, StartDateTime = scene.StartDateTime, EndDateTime = scene.EndDateTime, DurationAmount = scene.DurationAmount, DurationUnitID = scene.DurationUnitID, RelativeTimeText = scene.RelativeTimeText, TimeConfidenceID = scene.TimeConfidenceID, ScenePurposeNotes = scene.ScenePurposeNotes, SceneOutcomeNotes = scene.SceneOutcomeNotes, RevisionStatusID = scene.RevisionStatusID, PrimaryLocationID = scene.PrimaryLocationID, SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds, Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel { MetricTypeID = metric.MetricTypeID, MetricName = metric.MetricName, Description = metric.Description, MinValue = metric.MinValue, MaxValue = metric.MaxValue, Value = metric.Value, Notes = metric.Notes }).ToList(), Chapter = context.Value.Chapter, Book = context.Value.Book, Project = context.Value.Project }, lookupData)); } public async Task SaveAsync(SceneEditViewModel model) { var sceneId = await scenes.SaveAsync(new Scene { SceneID = model.SceneID, ChapterID = model.ChapterID, SceneNumber = model.SceneNumber, SceneTitle = model.SceneTitle, Summary = model.Summary, TimeModeID = model.TimeModeID, StartDateTime = model.StartDateTime, EndDateTime = model.EndDateTime, DurationAmount = model.DurationAmount, DurationUnitID = model.DurationUnitID, RelativeTimeText = model.RelativeTimeText, TimeConfidenceID = model.TimeConfidenceID, ScenePurposeNotes = model.ScenePurposeNotes, SceneOutcomeNotes = model.SceneOutcomeNotes, RevisionStatusID = model.RevisionStatusID, PrimaryLocationID = model.PrimaryLocationID }); await scenes.SavePurposesAsync(sceneId, model.SelectedPurposeTypeIds); await scenes.SaveMetricValuesAsync(sceneId, model.Metrics.Select(metric => new SceneMetricValue { SceneID = sceneId, MetricTypeID = metric.MetricTypeID, Value = metric.Value, Notes = metric.Notes })); return sceneId; } public Task ArchiveAsync(int sceneId) => scenes.ArchiveAsync(sceneId); public async Task MoveAsync(int sceneId, string direction) { var scene = await scenes.GetAsync(sceneId); var context = scene is null ? null : await GetContextAsync(scene.ChapterID); await scenes.MoveAsync(sceneId, direction); if (context is not null) { await warnings.ValidateBookAsync(context.Value.Book.BookID); await warnings.FinaliseSceneDependencyWarningsAsync(context.Value.Project.ProjectID, context.Value.Book.BookID); } } private async Task<(Project Project, Book Book, Chapter Chapter)?> GetContextAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); var book = chapter is null ? null : await books.GetAsync(chapter.BookID); var project = book is null ? null : await projects.GetAsync(book.ProjectID); return chapter is null || book is null || project is null ? null : (project, book, chapter); } private static SceneEditViewModel PopulateLists(SceneEditViewModel model, LookupData lookupData) { model.RevisionStatuses = ChapterService.ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName); model.TimeModes = ChapterService.ToSelectList(lookupData.TimeModes, x => x.TimeModeID, x => x.TimeModeName); model.DurationUnits = ChapterService.ToSelectList(lookupData.DurationUnits, x => x.DurationUnitID, x => x.DurationUnitName); model.TimeConfidences = ChapterService.ToSelectList(lookupData.TimeConfidences, x => x.TimeConfidenceID, x => x.TimeConfidenceName); model.ScenePurposeTypes = lookupData.ScenePurposeTypes; return model; } private async Task PopulateWorkspaceDataAsync(SceneEditViewModel model) { if (model.Project is null) { return model; } model.ReturnProjectID ??= model.Project.ProjectID; model.ReturnBookID ??= model.Book?.BookID; var plotLookups = await plots.GetLookupsAsync(); var plotLines = await plots.ListPlotLinesAsync(model.Project.ProjectID); var plotThreads = await plots.ListPlotThreadsByProjectAsync(model.Project.ProjectID); var assetLookups = await assets.GetLookupsAsync(); var projectAssets = await assets.ListAssetsAsync(model.Project.ProjectID); var characterLookups = await characters.GetLookupsAsync(); var projectCharacters = await characters.ListCharactersAsync(model.Project.ProjectID); var projectRelationships = await characters.ListRelationshipsByProjectAsync(model.Project.ProjectID); var projectLocations = await locations.ListByProjectAsync(model.Project.ProjectID); var dependencyLookups = await sceneDependencies.GetLookupsAsync(); var allSceneOptions = await plots.ListSceneOptionsAsync(model.Project.ProjectID); var chapterOptions = await scenes.ListChapterOptionsAsync(model.Project.ProjectID); model.ThreadEvents = model.SceneID == 0 ? [] : await plots.ListThreadEventsBySceneAsync(model.SceneID); model.AssetEvents = model.SceneID == 0 ? [] : await assets.ListEventsBySceneAsync(model.SceneID); model.AssetCustodyEvents = model.SceneID == 0 ? [] : await assets.ListCustodyBySceneAsync(model.SceneID); model.SceneAssetLocations = model.SceneID == 0 ? [] : await locations.ListSceneAssetLocationsAsync(model.SceneID); model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID); model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID); var sceneDependencyRows = model.SceneID == 0 ? [] : await sceneDependencies.ListBySceneAsync(model.SceneID); model.DependenciesThisSceneNeeds = sceneDependencyRows.Where(x => x.TargetSceneID == model.SceneID).ToList(); model.DependenciesNeedingThisScene = sceneDependencyRows.Where(x => x.SourceSceneID == model.SceneID).ToList(); model.CharacterKnowledge = model.SceneID == 0 ? [] : await characters.ListKnowledgeBySceneAsync(model.SceneID); model.RelationshipEvents = model.SceneID == 0 ? [] : await characters.ListRelationshipEventsBySceneAsync(model.SceneID); model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName); model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}"); model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName); model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName); model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName); model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName); model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName); model.AssetEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName); model.AssetCustodyEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetCustodyEventTypes, x => x.AssetCustodyEventTypeID, x => x.TypeName); model.CustodyRoleOptions = ChapterService.ToSelectList(assetLookups.CustodyRoles, x => x.CustodyRoleID, x => x.RoleName); model.CharacterOptions = ToOptionalSelectList(projectCharacters, x => x.CharacterID, x => x.CharacterName); model.CharacterRoleOptions = ToOptionalSelectList(characterLookups.RoleTypes, x => x.CharacterRoleInSceneTypeID, x => x.TypeName); model.PresenceTypeOptions = ToOptionalSelectList(characterLookups.PresenceTypes, x => x.PresenceTypeID, x => x.TypeName); model.CharacterAttributeTypeOptions = ChapterService.ToSelectList(characterLookups.AttributeTypes, x => x.CharacterAttributeTypeID, x => x.AttributeName); model.KnowledgeStateOptions = ChapterService.ToSelectList(characterLookups.KnowledgeStates, x => x.KnowledgeStateID, x => x.StateName); model.RelationshipOptions = ToOptionalSelectList(projectRelationships, x => x.CharacterRelationshipID, x => $"{x.CharacterAName} -> {x.CharacterBName} ({x.RelationshipTypeName})"); model.RelationshipTypeOptions = ToOptionalSelectList(characterLookups.RelationshipTypes, x => x.RelationshipTypeID, x => x.TypeName); model.RelationshipStateOptions = ChapterService.ToSelectList(characterLookups.RelationshipStates, x => x.RelationshipStateID, x => x.StateName); model.LocationOptions = ToOptionalSelectList(projectLocations, x => x.LocationID, x => new string(' ', x.Depth * 2) + x.LocationName); model.SceneOptions = ToOptionalSelectList(allSceneOptions.Where(x => x.SceneID != model.SceneID), x => x.SceneID, x => $"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}"); model.ChapterOptions = ChapterService.ToSelectList(chapterOptions, x => x.ChapterID, x => $"{x.BookTitle} / Chapter {x.ChapterNumber}: {x.ChapterTitle}"); model.SceneDependencyTypeOptions = ChapterService.ToSelectList(dependencyLookups.DependencyTypes, x => x.SceneDependencyTypeID, x => x.TypeName); model.NewThreadEvent = new ThreadEventCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, EventTitle = string.Empty, EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID ?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID ?? 0 }; model.NewAssetEvent = new AssetEventCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, AssetEventTypeID = assetLookups.AssetEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.AssetEventTypeID ?? assetLookups.AssetEventTypes.FirstOrDefault()?.AssetEventTypeID ?? 0, NewAssetKindID = assetLookups.AssetKinds.FirstOrDefault(x => x.KindName == "Other")?.AssetKindID }; model.NewAssetCustodyEvent = new AssetCustodyCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, AssetCustodyEventTypeID = assetLookups.AssetCustodyEventTypes.FirstOrDefault(x => x.TypeName == "Has")?.AssetCustodyEventTypeID ?? assetLookups.AssetCustodyEventTypes.FirstOrDefault()?.AssetCustodyEventTypeID ?? 0, CustodyRoleID = assetLookups.CustodyRoles.FirstOrDefault(x => x.RoleName == "Has Custody")?.CustodyRoleID ?? assetLookups.CustodyRoles.FirstOrDefault()?.CustodyRoleID ?? 0 }; model.NewSceneAssetLocation = new SceneAssetLocationCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, UpdateCurrentLocation = true }; model.NewSceneCharacter = new SceneCharacterCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, RoleInSceneTypeID = characterLookups.RoleTypes.FirstOrDefault(x => x.TypeName == "Main Participant")?.CharacterRoleInSceneTypeID, PresenceTypeID = characterLookups.PresenceTypes.FirstOrDefault(x => x.TypeName == "Present")?.PresenceTypeID }; model.NewCharacterAttributeEvent = new CharacterAttributeEventCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, CharacterAttributeTypeID = characterLookups.AttributeTypes.FirstOrDefault()?.CharacterAttributeTypeID ?? 0 }; model.NewCharacterKnowledge = new CharacterKnowledgeCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, KnowledgeStateID = characterLookups.KnowledgeStates.FirstOrDefault(x => x.StateName == "Knows")?.KnowledgeStateID ?? characterLookups.KnowledgeStates.FirstOrDefault()?.KnowledgeStateID ?? 0 }; model.NewRelationshipEvent = new RelationshipEventCreateViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, RelationshipStateID = characterLookups.RelationshipStates.FirstOrDefault(x => x.StateName == "Unknown")?.RelationshipStateID ?? characterLookups.RelationshipStates.FirstOrDefault()?.RelationshipStateID ?? 0 }; model.NewDependency = new SceneDependencyCreateViewModel { ProjectID = model.Project.ProjectID, CurrentSceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, SceneDependencyTypeID = dependencyLookups.DependencyTypes.FirstOrDefault(x => x.TypeName == "Must Occur Before")?.SceneDependencyTypeID ?? dependencyLookups.DependencyTypes.FirstOrDefault()?.SceneDependencyTypeID ?? 0 }; model.MoveRequest = new SceneMoveRequestViewModel { SceneID = model.SceneID, TargetChapterID = model.ChapterID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID }; model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model); return model; } private static IReadOnlyList ToOptionalSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList(); public async Task GetMovePreviewAsync(SceneMoveRequestViewModel request) { var preview = await scenes.GetMovePreviewAsync(request.SceneID, request.TargetChapterID, request.Position); if (preview is null) { return null; } return new SceneMovePreviewViewModel { Preview = preview, Request = request, CurrentWarnings = await warnings.ListBySceneAsync(request.SceneID) }; } public async Task MoveToChapterAsync(SceneMoveRequestViewModel request) { var preview = await scenes.GetMovePreviewAsync(request.SceneID, request.TargetChapterID, request.Position); if (preview is null) { return null; } await scenes.MoveToChapterAsync(request.SceneID, request.TargetChapterID, request.Position, request.RenumberScenes); var summary = await warnings.ValidateBookAsync(preview.TargetBookID); if (request.ReturnProjectID.HasValue) { await warnings.FinaliseSceneDependencyWarningsAsync(request.ReturnProjectID.Value, preview.TargetBookID); } return summary; } public Task RenumberChapterAsync(int chapterId) => scenes.RenumberChapterAsync(chapterId); public Task AddDependencyAsync(SceneDependencyCreateViewModel model) { var sourceSceneId = model.Direction == "RelatedDependsOnCurrent" ? model.CurrentSceneID : model.RelatedSceneID; var targetSceneId = model.Direction == "RelatedDependsOnCurrent" ? model.RelatedSceneID : model.CurrentSceneID; return sceneDependencies.SaveAsync(new SceneDependency { ProjectID = model.ProjectID, SourceSceneID = sourceSceneId, TargetSceneID = targetSceneId, SceneDependencyTypeID = model.SceneDependencyTypeID, Description = model.Description, IsHardDependency = model.IsHardDependency }); } public Task DeleteDependencyAsync(int sceneDependencyId) => sceneDependencies.ArchiveAsync(sceneDependencyId); private static IReadOnlyList BuildLocationConsistencyMessages(SceneEditViewModel model) { if (model.SceneID == 0) { return []; } var messages = new List(); if (!model.PrimaryLocationID.HasValue) { messages.Add("This scene has no primary location yet."); } foreach (var character in model.SceneCharacters.Where(x => !x.LocationID.HasValue)) { messages.Add($"{character.CharacterName} is in the scene, but has no scene location."); } var placedAssetIds = model.SceneAssetLocations.Select(x => x.StoryAssetID).ToHashSet(); var custodyAssetIds = model.AssetCustodyEvents.Select(x => x.StoryAssetID).ToHashSet(); foreach (var assetEvent in model.AssetEvents.Where(x => !placedAssetIds.Contains(x.StoryAssetID) && !custodyAssetIds.Contains(x.StoryAssetID))) { messages.Add($"{assetEvent.AssetName} is used in this scene, but has no scene location or custody entry."); } return messages; } } public sealed class TimelineService(ITimelineRepository timelineRepository, IBookRepository books, ISceneService scenes, IAssetRepository assets, ICharacterRepository characters, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies) : ITimelineService { private static readonly string[] DefaultGraphMetrics = ["Overall Intensity", "Tension", "Emotional Weight", "Action"]; public async Task GetAsync(int projectId, int? bookId, int? selectedSceneId) { var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId); if (timeline.Project is null) { return null; } var allBooks = await books.ListByProjectAsync(projectId); var chaptersByBook = timeline.Chapters.GroupBy(x => x.BookID).ToDictionary(x => x.Key, x => x.ToList()); var scenesByChapter = timeline.Scenes.GroupBy(x => x.ChapterID).ToDictionary(x => x.Key, x => x.ToList()); var purposeLabelsByScene = timeline.PurposeLabels.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.OrderBy(p => p.SortOrder).ToList()); var orderedScenes = timeline.Scenes.ToList(); var warningCounts = (await warnings.GetSceneCountsAsync(projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount); var dependencyCounts = (await sceneDependencies.GetCountsAsync(projectId, bookId)).ToDictionary(x => x.SceneID, x => x.DependencyCount); foreach (var scene in orderedScenes) { scene.PurposeLabels = purposeLabelsByScene.GetValueOrDefault(scene.SceneID, []); scene.WarningCount = warningCounts.GetValueOrDefault(scene.SceneID); scene.DependencyCount = dependencyCounts.GetValueOrDefault(scene.SceneID); } var assetTimeline = await assets.GetTimelineAsync(projectId, bookId); var characterTimeline = await characters.GetTimelineAsync(projectId, bookId); var selectedScene = selectedSceneId.HasValue ? await scenes.GetEditAsync(selectedSceneId.Value) : null; if (selectedScene is not null) { selectedScene.ReturnProjectID = projectId; selectedScene.ReturnBookID = bookId; selectedScene.ReturnToTimeline = true; selectedScene.NewThreadEvent.ReturnProjectID = projectId; selectedScene.NewThreadEvent.ReturnBookID = bookId; selectedScene.NewAssetEvent.ReturnProjectID = projectId; selectedScene.NewAssetEvent.ReturnBookID = bookId; selectedScene.NewAssetCustodyEvent.ReturnProjectID = projectId; selectedScene.NewAssetCustodyEvent.ReturnBookID = bookId; } return new TimelineViewModel { Project = timeline.Project, SelectedBookID = bookId, SelectedSceneID = selectedScene?.SceneID, SelectedScene = selectedScene, BookOptions = allBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(), Books = timeline.Books.Select(book => new BookTimelineViewModel { Book = book, Chapters = chaptersByBook.GetValueOrDefault(book.BookID, []) .Select(chapter => new ChapterTimelineViewModel { Chapter = chapter, Scenes = scenesByChapter.GetValueOrDefault(chapter.ChapterID, []) }) .ToList() }).ToList(), OrderedScenes = orderedScenes, MetricGraphs = BuildMetricGraphs(timeline, orderedScenes), PlotLanes = BuildPlotLanes(timeline, orderedScenes), AssetLanes = BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes), CharacterLanes = BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) }; } private static IReadOnlyList BuildMetricGraphs(TimelineData timeline, IReadOnlyList orderedScenes) { var pointsByMetric = timeline.MetricPoints .GroupBy(x => x.MetricTypeID) .ToDictionary(x => x.Key, x => x.ToDictionary(point => point.SceneID)); return timeline.MetricTypes .Where(metric => DefaultGraphMetrics.Contains(metric.MetricName)) .OrderBy(metric => metric.SortOrder) .Select(metric => new MetricGraphViewModel { MetricTypeID = metric.MetricTypeID, MetricName = metric.MetricName, Points = orderedScenes.Select(scene => { var value = metric.DefaultValue; if (pointsByMetric.TryGetValue(metric.MetricTypeID, out var scenePoints) && scenePoints.TryGetValue(scene.SceneID, out var point)) { value = point.Value; } return new MetricGraphPointViewModel { SceneID = scene.SceneID, SceneNumber = scene.SceneNumber, SceneTitle = scene.SceneTitle, Value = value, Percent = Math.Clamp((int)Math.Round((value - metric.MinValue) * 100.0 / (metric.MaxValue - metric.MinValue)), 0, 100) }; }).ToList() }).ToList(); } private static IReadOnlyList BuildPlotLanes(TimelineData timeline, IReadOnlyList orderedScenes) { var eventsByPlotLineAndScene = timeline.ThreadEvents .GroupBy(x => x.PlotLineID) .ToDictionary( lineGroup => lineGroup.Key, lineGroup => lineGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList())); return timeline.PlotLines .OrderBy(x => x.SortOrder) .ThenBy(x => x.PlotLineName) .Select(plotLine => { eventsByPlotLineAndScene.TryGetValue(plotLine.PlotLineID, out var sceneEvents); return new PlotLineLaneViewModel { PlotLine = plotLine, SceneSlots = orderedScenes.Select(scene => new PlotLaneSceneViewModel { Scene = scene, Events = sceneEvents is not null && sceneEvents.TryGetValue(scene.SceneID, out var events) ? events : [] }).ToList() }; }).ToList(); } private static IReadOnlyList BuildAssetLanes(IReadOnlyList assets, IReadOnlyList events, IReadOnlyList orderedScenes) { var eventsByAssetAndScene = events .GroupBy(x => x.StoryAssetID) .ToDictionary( assetGroup => assetGroup.Key, assetGroup => assetGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList())); return assets .OrderByDescending(x => x.Importance) .ThenBy(x => x.AssetName) .Select(asset => { eventsByAssetAndScene.TryGetValue(asset.StoryAssetID, out var sceneEvents); return new AssetLaneViewModel { Asset = asset, SceneSlots = orderedScenes.Select(scene => new AssetLaneSceneViewModel { Scene = scene, Events = sceneEvents is not null && sceneEvents.TryGetValue(scene.SceneID, out var assetEvents) ? assetEvents : [] }).ToList() }; }).ToList(); } private static IReadOnlyList BuildCharacterLanes(IReadOnlyList characters, IReadOnlyList appearances, IReadOnlyList orderedScenes) { var appearancesByCharacterAndScene = appearances .GroupBy(x => x.CharacterID) .ToDictionary( characterGroup => characterGroup.Key, characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList())); return characters .OrderBy(x => x.CharacterName) .Select(character => { appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances); return new CharacterLaneViewModel { Character = character, SceneSlots = orderedScenes.Select(scene => new CharacterLaneSceneViewModel { Scene = scene, Appearances = sceneAppearances is not null && sceneAppearances.TryGetValue(scene.SceneID, out var rows) ? rows : [] }).ToList() }; }).ToList(); } } public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots) : IPlotService { public async Task GetPlotLinesAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new PlotLineListViewModel { Project = project, PlotLines = await plots.ListPlotLinesAsync(projectId) }; } public async Task GetCreatePlotLineAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var lookupData = await plots.GetLookupsAsync(); var plotLines = await plots.ListPlotLinesAsync(projectId); return await PopulatePlotLineListsAsync(new PlotLineEditViewModel { ProjectID = projectId, Project = project, SortOrder = (plotLines.Count + 1) * 10, Colour = "#2f6f63", IsVisibleOnTimeline = true, PlotLineTypeID = lookupData.PlotLineTypes.FirstOrDefault(x => x.TypeName == "Side Plot")?.PlotLineTypeID ?? lookupData.PlotLineTypes.First().PlotLineTypeID }, lookupData, plotLines); } public async Task GetEditPlotLineAsync(int plotLineId) { var plotLine = await plots.GetPlotLineAsync(plotLineId); if (plotLine is null) { return null; } var project = await projects.GetAsync(plotLine.ProjectID); if (project is null) { return null; } var lookupData = await plots.GetLookupsAsync(); var plotLines = await plots.ListPlotLinesAsync(plotLine.ProjectID); return await PopulatePlotLineListsAsync(new PlotLineEditViewModel { PlotLineID = plotLine.PlotLineID, ProjectID = plotLine.ProjectID, BookID = plotLine.BookID, PlotLineName = plotLine.PlotLineName, PlotLineTypeID = plotLine.PlotLineTypeID, Description = plotLine.Description, ParentPlotLineID = plotLine.ParentPlotLineID, SortOrder = plotLine.SortOrder, Colour = plotLine.Colour, IsMainPlot = plotLine.IsMainPlot, IsVisibleOnTimeline = plotLine.IsVisibleOnTimeline, Project = project }, lookupData, plotLines.Where(x => x.PlotLineID != plotLine.PlotLineID)); } public Task SavePlotLineAsync(PlotLineEditViewModel model) => plots.SavePlotLineAsync(new PlotLineItem { PlotLineID = model.PlotLineID, ProjectID = model.ProjectID, BookID = model.BookID, PlotLineName = model.PlotLineName, PlotLineTypeID = model.PlotLineTypeID, Description = model.Description, ParentPlotLineID = model.ParentPlotLineID, SortOrder = model.SortOrder, Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour, IsMainPlot = model.IsMainPlot, IsVisibleOnTimeline = model.IsVisibleOnTimeline }); public Task ArchivePlotLineAsync(int plotLineId) => plots.ArchivePlotLineAsync(plotLineId); public async Task GetPlotThreadsAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new PlotThreadListViewModel { Project = project, PlotThreads = await plots.ListPlotThreadsByProjectAsync(projectId) }; } public async Task GetCreatePlotThreadAsync(int projectId, int? plotLineId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var lookupData = await plots.GetLookupsAsync(); return await PopulatePlotThreadListsAsync(new PlotThreadEditViewModel { ProjectID = projectId, Project = project, PlotLineID = plotLineId ?? 0, ThreadTypeID = lookupData.ThreadTypes.FirstOrDefault(x => x.TypeName == "Question")?.ThreadTypeID ?? lookupData.ThreadTypes.First().ThreadTypeID, ThreadStatusID = lookupData.ThreadStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.ThreadStatusID ?? lookupData.ThreadStatuses.First().ThreadStatusID, Importance = 5 }, lookupData); } public async Task GetEditPlotThreadAsync(int plotThreadId) { var plotThread = await plots.GetPlotThreadAsync(plotThreadId); if (plotThread is null) { return null; } var project = await projects.GetAsync(plotThread.ProjectID); if (project is null) { return null; } var lookupData = await plots.GetLookupsAsync(); return await PopulatePlotThreadListsAsync(new PlotThreadEditViewModel { PlotThreadID = plotThread.PlotThreadID, ProjectID = plotThread.ProjectID, PlotLineID = plotThread.PlotLineID, ThreadTitle = plotThread.ThreadTitle, ThreadTypeID = plotThread.ThreadTypeID, ThreadStatusID = plotThread.ThreadStatusID, Importance = plotThread.Importance, Summary = plotThread.Summary, IntroducedSceneID = plotThread.IntroducedSceneID, PlannedResolutionSceneID = plotThread.PlannedResolutionSceneID, ActualResolutionSceneID = plotThread.ActualResolutionSceneID, Project = project }, lookupData); } public Task SavePlotThreadAsync(PlotThreadEditViewModel model) => plots.SavePlotThreadAsync(new PlotThread { PlotThreadID = model.PlotThreadID, PlotLineID = model.PlotLineID, ThreadTitle = model.ThreadTitle, ThreadTypeID = model.ThreadTypeID, ThreadStatusID = model.ThreadStatusID, Importance = model.Importance, Summary = model.Summary, IntroducedSceneID = model.IntroducedSceneID, PlannedResolutionSceneID = model.PlannedResolutionSceneID, ActualResolutionSceneID = model.ActualResolutionSceneID }); public Task ArchivePlotThreadAsync(int plotThreadId) => plots.ArchivePlotThreadAsync(plotThreadId); public async Task AddThreadEventAsync(ThreadEventCreateViewModel model) { var plotThreadId = model.PlotThreadID.GetValueOrDefault(); var lookupData = await plots.GetLookupsAsync(); var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID) ?? lookupData.ThreadEventTypes.First(); var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(); if (plotThreadId == 0) { if (!model.PlotLineID.HasValue) { throw new InvalidOperationException("Choose a plot line or an existing plot thread."); } var plannedStatusId = lookupData.ThreadStatuses.First(x => x.StatusName == "Planned").ThreadStatusID; var threadTypeId = model.NewThreadTypeID ?? GetDefaultThreadTypeId(lookupData, eventType.TypeName); var threadTitle = string.IsNullOrWhiteSpace(model.NewThreadTitle) ? title : model.NewThreadTitle.Trim(); plotThreadId = await plots.SavePlotThreadAsync(new PlotThread { PlotLineID = model.PlotLineID.Value, ThreadTitle = threadTitle, ThreadTypeID = threadTypeId, ThreadStatusID = plannedStatusId, Importance = 5 }); } return await plots.SaveThreadEventAsync(new ThreadEvent { PlotThreadID = plotThreadId, SceneID = model.SceneID, EventTypeID = eventType.ThreadEventTypeID, EventTitle = title, EventDescription = model.EventDescription }); } public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId); private static int GetDefaultThreadTypeId(PlotLookupData lookupData, string eventTypeName) { var preferredTypeName = eventTypeName switch { "Clue Planted" => "Clue", "Question Raised" or "Question Answered" => "Question", "False Lead" => "Suspicion", "Payoff" => "Payoff Setup", "Promise Made" => "Promise", "Threat Introduced" => "Threat", _ => "Question" }; return lookupData.ThreadTypes.FirstOrDefault(x => x.TypeName == preferredTypeName)?.ThreadTypeID ?? lookupData.ThreadTypes.First().ThreadTypeID; } private async Task PopulatePlotLineListsAsync(PlotLineEditViewModel model, PlotLookupData lookupData, IEnumerable parentCandidates) { model.PlotLineTypes = ChapterService.ToSelectList(lookupData.PlotLineTypes, x => x.PlotLineTypeID, x => x.TypeName); model.BookOptions = (await books.ListByProjectAsync(model.ProjectID)) .Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString())) .ToList(); model.ParentPlotLineOptions = parentCandidates .Select(x => new SelectListItem(x.PlotLineName, x.PlotLineID.ToString())) .ToList(); return model; } private async Task PopulatePlotThreadListsAsync(PlotThreadEditViewModel model, PlotLookupData lookupData) { var plotLines = await plots.ListPlotLinesAsync(model.ProjectID); model.PlotLineOptions = ChapterService.ToSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName); model.ThreadTypeOptions = ChapterService.ToSelectList(lookupData.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName); model.ThreadStatusOptions = ChapterService.ToSelectList(lookupData.ThreadStatuses, x => x.ThreadStatusID, x => x.StatusName); model.SceneOptions = (await plots.ListSceneOptionsAsync(model.ProjectID)) .Select(x => new SelectListItem($"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString())) .ToList(); return model; } } public sealed class ContinuityValidationService( IProjectRepository projects, IBookRepository books, IAssetRepository assets, ICharacterRepository characters, IWarningRepository warnings) : IContinuityValidationService { public async Task GetDashboardAsync( int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional, ContinuityValidationSummary? summary = null) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var lookupData = await warnings.GetLookupsAsync(); var rows = await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, warningSeverityId, entityType, includeDismissed, includeIntentional); return new WarningDashboardViewModel { Project = project, BookID = bookId, SceneID = sceneId, WarningTypeID = warningTypeId, WarningSeverityID = warningSeverityId, EntityType = entityType, IncludeDismissed = includeDismissed, IncludeIntentional = includeIntentional, Warnings = rows, BookOptions = (await books.ListByProjectAsync(projectId)).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(), WarningTypeOptions = ChapterService.ToSelectList(lookupData.WarningTypes, x => x.WarningTypeID, x => x.TypeName), SeverityOptions = ChapterService.ToSelectList(lookupData.Severities, x => x.WarningSeverityID, x => x.SeverityName), EntityTypeOptions = rows.Select(x => x.EntityType).Distinct().OrderBy(x => x).Select(x => new SelectListItem(x, x, x == entityType)).ToList(), CountsBySeverity = rows.GroupBy(x => x.SeverityName).ToDictionary(x => x.Key, x => x.Count()), CountsByType = rows.GroupBy(x => x.WarningTypeName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()), LastValidationSummary = summary }; } public async Task ValidateProjectAsync(int projectId) { var summary = await warnings.ValidateProjectAsync(projectId); await warnings.FinaliseSceneDependencyWarningsAsync(projectId, null); return summary; } public async Task ValidateBookAsync(int bookId) { var summary = await warnings.ValidateBookAsync(bookId); var book = await books.GetAsync(bookId); if (book is not null) { await warnings.FinaliseSceneDependencyWarningsAsync(book.ProjectID, bookId); } return summary; } public async Task ValidateSceneAsync(int sceneId) { var summary = await warnings.ValidateSceneAsync(sceneId); return summary; } public Task ValidateAfterSceneMoveAsync(int projectId, int? bookId) => bookId.HasValue ? warnings.ValidateBookAsync(bookId.Value) : warnings.ValidateProjectAsync(projectId); public async Task ValidateAssetAsync(int assetId) { var asset = await assets.GetAssetAsync(assetId); return asset is null ? new ContinuityValidationSummary() : await warnings.ValidateProjectAsync(asset.ProjectID); } public async Task ValidateCharacterAsync(int characterId) { var character = await characters.GetCharacterAsync(characterId); return character is null ? new ContinuityValidationSummary() : await warnings.ValidateProjectAsync(character.ProjectID); } public Task> ListSceneWarningsAsync(int sceneId) => warnings.ListBySceneAsync(sceneId); public Task> GetSceneCountsAsync(int projectId, int? bookId) => warnings.GetSceneCountsAsync(projectId, bookId); public Task DismissAsync(int warningId) => warnings.DismissAsync(warningId); public Task MarkIntentionalAsync(int warningId) => warnings.MarkIntentionalAsync(warningId); public Task RestoreAsync(int warningId) => warnings.RestoreAsync(warningId); } public sealed class AnalyticsService(IProjectRepository projects, IBookRepository books, IAnalyticsRepository analytics) : IAnalyticsService { public async Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var data = await analytics.GetStoryHealthAsync(projectId, bookId, startChapterNumber, endChapterNumber, startSceneNumber, endSceneNumber); return new AnalyticsViewModel { Project = project, BookID = bookId, StartChapterNumber = startChapterNumber, EndChapterNumber = endChapterNumber, StartSceneNumber = startSceneNumber, EndSceneNumber = endSceneNumber, BookOptions = (await books.ListByProjectAsync(projectId)).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(), MetricGraphs = data.MetricPoints .GroupBy(x => x.MetricName) .Select(x => new AnalyticsMetricGraphViewModel { MetricName = x.Key, Points = x.OrderBy(p => p.GlobalSceneIndex).ToList() }) .ToList(), RevisionSummary = data.RevisionSummary, RevisionAttentionScenes = data.RevisionAttentionScenes, PurposeSummary = data.PurposeSummary, SceneGaps = data.SceneGaps, ThreadHealth = data.ThreadHealth, AssetHealth = data.AssetHealth, WarningSeverityCounts = data.WarningSeverityCounts, WarningTypeCounts = data.WarningTypeCounts, WarningHotspots = data.WarningHotspots, PovSummary = data.PovSummary, MultiPovChapters = data.MultiPovChapters, CharacterAppearances = data.CharacterAppearances, BusyScenes = data.BusyScenes, LocationUsage = data.LocationUsage, PacingReviewNotes = BuildPacingNotes(data.MetricPoints) }; } private static IReadOnlyList BuildPacingNotes(IReadOnlyList points) { var notes = new List(); foreach (var group in points.GroupBy(x => x.MetricName)) { AddRuns(notes, group.OrderBy(x => x.GlobalSceneIndex).ToList(), high: true); if (group.Key is "Overall Intensity" or "Tension" or "Emotional Weight" or "Action") { AddRuns(notes, group.OrderBy(x => x.GlobalSceneIndex).ToList(), high: false); } } return notes.Take(12).ToList(); } private static void AddRuns(List notes, IReadOnlyList points, bool high) { var threshold = high ? 8 : 2; var minRun = high ? 6 : 8; var run = new List(); foreach (var point in points) { var qualifies = high ? point.Value >= threshold : point.Value <= threshold; if (qualifies) { run.Add(point); continue; } FlushRun(notes, run, high, minRun); run.Clear(); } FlushRun(notes, run, high, minRun); } private static void FlushRun(List notes, IReadOnlyList run, bool high, int minRun) { if (run.Count < minRun) { return; } notes.Add(new AnalyticsReviewNote { MetricName = run[0].MetricName, SceneID = run[^1].SceneID, SceneRange = $"Ch {run[0].ChapterNumber} Scene {run[0].SceneNumber} to Ch {run[^1].ChapterNumber} Scene {run[^1].SceneNumber}", ValuePattern = string.Join(", ", run.Select(x => x.Value)), ReviewNote = high ? "Possible pacing issue: this stays intense for a long run. May need a recovery scene." : "Worth reviewing: this stays very low for a long run and may feel flat." }); } } public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService { public async Task GetAssetsAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new StoryAssetListViewModel { Project = project, Assets = await assets.ListAssetsAsync(projectId) }; } public async Task GetCreateAssetAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var lookupData = await assets.GetLookupsAsync(); return await PopulateAssetListsAsync(new StoryAssetEditViewModel { ProjectID = projectId, Project = project, Importance = 5, AssetKindID = lookupData.AssetKinds.FirstOrDefault(x => x.KindName == "Physical Object")?.AssetKindID ?? lookupData.AssetKinds.First().AssetKindID, CurrentStateID = lookupData.AssetStates.FirstOrDefault(x => x.StateName == "Unknown")?.AssetStateID }, lookupData); } public async Task GetEditAssetAsync(int storyAssetId) { var asset = await assets.GetAssetAsync(storyAssetId); if (asset is null) { return null; } var project = await projects.GetAsync(asset.ProjectID); if (project is null) { return null; } var lookupData = await assets.GetLookupsAsync(); return await PopulateAssetListsAsync(new StoryAssetEditViewModel { StoryAssetID = asset.StoryAssetID, ProjectID = asset.ProjectID, AssetName = asset.AssetName, AssetKindID = asset.AssetKindID, Description = asset.Description, Importance = asset.Importance, CurrentStateID = asset.CurrentStateID, CurrentLocationID = asset.CurrentLocationID, IsResolved = asset.IsResolved, Project = project }, lookupData); } public async Task GetAssetDetailAsync(int storyAssetId) { var asset = await assets.GetAssetAsync(storyAssetId); if (asset is null) { return null; } var project = await projects.GetAsync(asset.ProjectID); if (project is null) { return null; } var lookupData = await assets.GetLookupsAsync(); var projectAssets = await assets.ListAssetsAsync(asset.ProjectID); return new StoryAssetDetailViewModel { Project = project, Asset = asset, ProjectAssets = projectAssets, Events = await assets.ListEventsByAssetAsync(storyAssetId), Dependencies = await assets.ListDependenciesByAssetAsync(storyAssetId), CustodyEvents = await assets.ListCustodyByAssetAsync(storyAssetId), NewDependency = new AssetDependencyEditViewModel { ProjectID = asset.ProjectID, SourceAssetID = projectAssets.FirstOrDefault(x => x.StoryAssetID != asset.StoryAssetID)?.StoryAssetID ?? asset.StoryAssetID, TargetAssetID = asset.StoryAssetID, AssetDependencyTypeID = lookupData.AssetDependencyTypes.FirstOrDefault(x => x.TypeName == "Requires")?.AssetDependencyTypeID ?? lookupData.AssetDependencyTypes.First().AssetDependencyTypeID }, AssetOptions = ChapterService.ToSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName), AssetDependencyTypeOptions = ChapterService.ToSelectList(lookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName), AssetStateOptions = OptionalStateList(lookupData.AssetStates) }; } public Task SaveAssetAsync(StoryAssetEditViewModel model) => assets.SaveAssetAsync(new StoryAsset { StoryAssetID = model.StoryAssetID, ProjectID = model.ProjectID, AssetName = model.AssetName, AssetKindID = model.AssetKindID, Description = model.Description, Importance = model.Importance, CurrentStateID = model.CurrentStateID, CurrentLocationID = model.CurrentLocationID, IsResolved = model.IsResolved }); public Task ArchiveAssetAsync(int storyAssetId) => assets.ArchiveAssetAsync(storyAssetId); public async Task AddAssetEventAsync(AssetEventCreateViewModel model) { var lookupData = await assets.GetLookupsAsync(); var assetId = model.StoryAssetID.GetValueOrDefault(); if (assetId == 0) { if (!model.ReturnProjectID.HasValue || string.IsNullOrWhiteSpace(model.NewAssetName)) { throw new InvalidOperationException("Choose an asset or enter a new asset name."); } assetId = await assets.SaveAssetAsync(new StoryAsset { ProjectID = model.ReturnProjectID.Value, AssetName = model.NewAssetName.Trim(), AssetKindID = model.NewAssetKindID ?? lookupData.AssetKinds.FirstOrDefault(x => x.KindName == "Other")?.AssetKindID ?? lookupData.AssetKinds.First().AssetKindID, Importance = 5, CurrentStateID = model.FromStateID ?? lookupData.AssetStates.FirstOrDefault(x => x.StateName == "Unknown")?.AssetStateID }); } var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID) ?? lookupData.AssetEventTypes.First(); var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(); return await assets.SaveEventAsync(new AssetEvent { StoryAssetID = assetId, SceneID = model.SceneID, AssetEventTypeID = eventType.AssetEventTypeID, FromStateID = model.FromStateID, ToStateID = model.ToStateID, EventTitle = title, EventDescription = model.EventDescription }); } public Task DeleteAssetEventAsync(int assetEventId) => assets.DeleteEventAsync(assetEventId); public Task AddCustodyEventAsync(AssetCustodyCreateViewModel model) => assets.SaveCustodyEventAsync( new AssetCustodyEvent { StoryAssetID = model.StoryAssetID, SceneID = model.SceneID, AssetCustodyEventTypeID = model.AssetCustodyEventTypeID, Description = model.Description }, model.CustodianNames, model.CustodianCharacterIds, model.CustodyRoleID); public Task DeleteCustodyEventAsync(int assetCustodyEventId) => assets.DeleteCustodyEventAsync(assetCustodyEventId); public Task SaveDependencyAsync(AssetDependencyEditViewModel model) => assets.SaveDependencyAsync(new AssetDependency { AssetDependencyID = model.AssetDependencyID, SourceAssetID = model.SourceAssetID, TargetAssetID = model.TargetAssetID, AssetDependencyTypeID = model.AssetDependencyTypeID, SourceRequiredStateID = model.SourceRequiredStateID, TargetBlockedStateID = model.TargetBlockedStateID, Description = model.Description }); public Task DeleteDependencyAsync(int assetDependencyId) => assets.DeleteDependencyAsync(assetDependencyId); public Task> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId) => assets.CheckDependencyIssuesAsync(storyAssetId, sceneId, toStateId); private async Task PopulateAssetListsAsync(StoryAssetEditViewModel model, AssetLookupData lookupData) { model.AssetKindOptions = ChapterService.ToSelectList(lookupData.AssetKinds, x => x.AssetKindID, x => x.KindName); model.AssetStateOptions = OptionalStateList(lookupData.AssetStates); model.LocationOptions = ToOptionalLocationList(await locations.ListByProjectAsync(model.ProjectID)); return model; } private static IReadOnlyList OptionalStateList(IEnumerable states) => states.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList(); private static IReadOnlyList ToOptionalLocationList(IEnumerable items) => items.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())).ToList(); } public sealed class LocationService(IProjectRepository projects, ILocationRepository locations) : ILocationService { public async Task GetLocationsAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new LocationListViewModel { Project = project, Locations = await locations.ListByProjectAsync(projectId) }; } public async Task GetCreateAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } return await PopulateLocationListsAsync(new LocationEditViewModel { ProjectID = projectId, Project = project }); } public async Task GetEditAsync(int locationId) { var location = await locations.GetAsync(locationId); if (location is null) { return null; } return await PopulateLocationListsAsync(new LocationEditViewModel { LocationID = location.LocationID, ProjectID = location.ProjectID, ParentLocationID = location.ParentLocationID, LocationName = location.LocationName, LocationTypeID = location.LocationTypeID, Description = location.Description, Project = await projects.GetAsync(location.ProjectID) }); } public async Task GetDetailAsync(int locationId) { var location = await locations.GetAsync(locationId); if (location is null) { return null; } var project = await projects.GetAsync(location.ProjectID); if (project is null) { return null; } var allLocations = await locations.ListByProjectAsync(project.ProjectID); var lookups = await locations.GetLookupsAsync(); return new LocationDetailViewModel { Project = project, Location = location, ChildLocations = allLocations.Where(x => x.ParentLocationID == location.LocationID).ToList(), Relationships = await locations.ListRelationshipsByLocationAsync(location.LocationID), Scenes = await locations.ListScenesByLocationAsync(location.LocationID), Characters = await locations.ListCharactersByLocationAsync(location.LocationID), Assets = await locations.ListAssetsByLocationAsync(location.LocationID), NewRelationship = new LocationRelationshipEditViewModel { ProjectID = project.ProjectID, FromLocationID = location.LocationID, LocationRelationshipTypeID = lookups.RelationshipTypes.FirstOrDefault()?.LocationRelationshipTypeID ?? 0 }, LocationOptions = allLocations.Where(x => x.LocationID != location.LocationID) .Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())) .ToList(), RelationshipTypeOptions = ChapterService.ToSelectList(lookups.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName) }; } public Task SaveAsync(LocationEditViewModel model) => locations.SaveAsync(new LocationItem { LocationID = model.LocationID, ProjectID = model.ProjectID, ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID, LocationName = model.LocationName, LocationTypeID = model.LocationTypeID, Description = model.Description }); public Task ArchiveAsync(int locationId) => locations.ArchiveAsync(locationId); public Task SaveRelationshipAsync(LocationRelationshipEditViewModel model) => locations.SaveRelationshipAsync(new LocationRelationship { LocationRelationshipID = model.LocationRelationshipID, FromLocationID = model.FromLocationID, ToLocationID = model.ToLocationID, LocationRelationshipTypeID = model.LocationRelationshipTypeID, IsBidirectional = model.IsBidirectional, Strength = model.Strength, CanHearNormalSpeech = model.CanHearNormalSpeech, CanHearLoudNoise = model.CanHearLoudNoise, CanSeeMovement = model.CanSeeMovement, CanSeeClearly = model.CanSeeClearly, CanTravelDirectly = model.CanTravelDirectly, Notes = model.Notes }); public Task ArchiveRelationshipAsync(int locationRelationshipId) => locations.ArchiveRelationshipAsync(locationRelationshipId); public Task SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model) => locations.SaveSceneAssetLocationAsync(new SceneAssetLocation { SceneAssetLocationID = model.SceneAssetLocationID, SceneID = model.SceneID, StoryAssetID = model.StoryAssetID, LocationID = model.LocationID, Description = model.Description }, model.UpdateCurrentLocation); public Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId) => locations.DeleteSceneAssetLocationAsync(sceneAssetLocationId); private async Task PopulateLocationListsAsync(LocationEditViewModel model) { var lookups = await locations.GetLookupsAsync(); var allLocations = await locations.ListByProjectAsync(model.ProjectID); model.ParentLocationOptions = allLocations .Where(x => x.LocationID != model.LocationID) .Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())) .ToList(); model.LocationTypeOptions = ChapterService.ToSelectList(lookups.LocationTypes, x => x.LocationTypeID, x => x.TypeName); return model; } } public sealed class CharacterService(IProjectRepository projects, ICharacterRepository characters) : ICharacterService { public async Task GetCharactersAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new CharacterListViewModel { Project = project, Characters = await characters.ListCharactersAsync(projectId) }; } public async Task GetCreateCharacterAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new CharacterEditViewModel { ProjectID = projectId, Project = project }; } public async Task GetEditCharacterAsync(int characterId) { var character = await characters.GetCharacterAsync(characterId); if (character is null) { return null; } return new CharacterEditViewModel { CharacterID = character.CharacterID, ProjectID = character.ProjectID, CharacterName = character.CharacterName, ShortName = character.ShortName, Sex = character.Sex, BirthDate = character.BirthDate, AgeAtSeriesStart = character.AgeAtSeriesStart, Height = character.Height, EyeColour = character.EyeColour, DefaultDescription = character.DefaultDescription, Project = await projects.GetAsync(character.ProjectID) }; } public async Task GetCharacterDetailAsync(int characterId) { var character = await characters.GetCharacterAsync(characterId); if (character is null) { return null; } var project = await projects.GetAsync(character.ProjectID); if (project is null) { return null; } return new CharacterDetailViewModel { Project = project, Character = character, DisplayAge = DisplayAge(character), Appearances = await GetAppearancesByCharacterAsync(character.CharacterID), AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID), KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID), Relationships = await characters.ListRelationshipsByCharacterAsync(character.CharacterID), RelationshipEvents = await characters.ListRelationshipEventsByCharacterAsync(character.CharacterID) }; } public Task SaveCharacterAsync(CharacterEditViewModel model) => characters.SaveCharacterAsync(new Character { CharacterID = model.CharacterID, ProjectID = model.ProjectID, CharacterName = model.CharacterName, ShortName = model.ShortName, Sex = model.Sex, BirthDate = model.BirthDate, AgeAtSeriesStart = model.AgeAtSeriesStart, Height = model.Height, EyeColour = model.EyeColour, DefaultDescription = model.DefaultDescription }); public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId); public async Task AddSceneCharacterAsync(SceneCharacterCreateViewModel model) { var characterId = model.CharacterID.GetValueOrDefault(); if (characterId == 0) { if (!model.ReturnProjectID.HasValue || string.IsNullOrWhiteSpace(model.NewCharacterName)) { throw new InvalidOperationException("Choose a character or enter a new character name."); } characterId = await characters.SaveCharacterAsync(new Character { ProjectID = model.ReturnProjectID.Value, CharacterName = model.NewCharacterName.Trim() }); } return await characters.SaveSceneCharacterAsync(new SceneCharacter { SceneID = model.SceneID, CharacterID = characterId, RoleInSceneTypeID = model.RoleInSceneTypeID, PresenceTypeID = model.PresenceTypeID, LocationID = model.LocationID, EntryLocationID = model.EntryLocationID, ExitLocationID = model.ExitLocationID, AppearanceNotes = model.AppearanceNotes, OutfitDescription = model.OutfitDescription, PhysicalCondition = model.PhysicalCondition, EmotionalState = model.EmotionalState, KnowledgeNotes = model.KnowledgeNotes }); } public Task DeleteSceneCharacterAsync(int sceneCharacterId) => characters.DeleteSceneCharacterAsync(sceneCharacterId); public Task AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model) => characters.SaveAttributeEventAsync(new CharacterAttributeEvent { CharacterID = model.CharacterID, CharacterAttributeTypeID = model.CharacterAttributeTypeID, SceneID = model.SceneID, AttributeValue = model.AttributeValue, Description = model.Description }); public Task AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model) => characters.SaveKnowledgeAsync(new CharacterKnowledgeItem { CharacterID = model.CharacterID, StoryAssetID = model.StoryAssetID, PlotThreadID = model.PlotThreadID, SceneID = model.SceneID, KnowledgeStateID = model.KnowledgeStateID, Description = model.Description }); public async Task AddRelationshipEventAsync(RelationshipEventCreateViewModel model) { var relationshipId = model.CharacterRelationshipID.GetValueOrDefault(); if (relationshipId == 0) { if (!model.ReturnProjectID.HasValue || !model.CharacterAID.HasValue || !model.CharacterBID.HasValue || !model.RelationshipTypeID.HasValue) { throw new InvalidOperationException("Choose a relationship or enter both characters and a relationship type."); } relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship { ProjectID = model.ReturnProjectID.Value, CharacterAID = model.CharacterAID.Value, CharacterBID = model.CharacterBID.Value, RelationshipTypeID = model.RelationshipTypeID.Value, IsKnownToReader = true }); } return await characters.SaveRelationshipEventAsync(new RelationshipEvent { CharacterRelationshipID = relationshipId, SceneID = model.SceneID, RelationshipStateID = model.RelationshipStateID, Intensity = model.Intensity, IsKnownToOtherCharacter = model.IsKnownToOtherCharacter, Description = model.Description }); } public string DisplayAge(Character character, DateTime? sceneStartDateTime = null) { if (character.BirthDate.HasValue && sceneStartDateTime.HasValue) { var age = sceneStartDateTime.Value.Year - character.BirthDate.Value.Year; if (sceneStartDateTime.Value.Date < character.BirthDate.Value.Date.AddYears(age)) { age--; } return age.ToString(); } return character.AgeAtSeriesStart.HasValue ? $"About {character.AgeAtSeriesStart.Value}" : "Unknown"; } private async Task> GetAppearancesByCharacterAsync(int characterId) { var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null); return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList(); } }