using System.Diagnostics; using System.Net; using System.Text; 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 MoveRelativeAsync(int sceneId, int anchorSceneId, string position); 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 ArchiveRelationshipAsync(int characterRelationshipId); Task AddSceneCharacterAsync(SceneCharacterCreateViewModel model); Task UpdateSceneCharacterAsync(SceneCharacterEditViewModel model); Task DeleteSceneCharacterAsync(int sceneCharacterId); Task AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model); Task AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model); Task DeleteKnowledgeAsync(int characterKnowledgeId); Task AddRelationshipEventAsync(RelationshipEventCreateViewModel model); Task DeleteRelationshipEventAsync(int relationshipEventId); string DisplayAge(Character character, DateTime? sceneStartDateTime = null); } public interface ITimelineService { Task GetAsync(TimelineFilterViewModel filter); Task GetSettingsAsync(int projectId); Task SaveSettingsAsync(TimelineSettingsViewModel model); Task SavePresetAsync(TimelinePresetSaveViewModel model); Task GetPresetAsync(int timelineViewPresetId); Task ArchivePresetAsync(int timelineViewPresetId); Task MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model); Task AddCharacterByDropAsync(TimelineSceneCharacterDropViewModel model); Task AddAssetEventByDropAsync(TimelineAssetEventDropViewModel model); Task AddAssetDependencyByDropAsync(TimelineAssetDependencyDropViewModel model); Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model); } public interface IAnalyticsService { Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber); } public interface IStoryBibleService { Task GetAsync(int projectId, string? query); } public interface IWriterWorkspaceService { Task GetDashboardAsync(int? projectId); Task GetChapterWorkflowAsync(int chapterId); Task SaveChapterGoalAsync(ChapterGoalEditViewModel model); Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model); Task SaveNoteAsync(SceneNoteEditViewModel model); Task DeleteNoteAsync(int sceneNoteId); Task SaveChecklistItemAsync(SceneChecklistItemEditViewModel model); Task DeleteChecklistItemAsync(int sceneChecklistItemId); Task SaveAttachmentAsync(SceneAttachmentEditViewModel model); Task DeleteAttachmentAsync(int sceneAttachmentId); } public interface IExportService { Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format); Task<(string FileName, string ContentType, string Content)> ExportNarrativeTimelineAsync(int projectId, string format); Task<(string FileName, string ContentType, string Content)> ExportCharacterReferenceAsync(int characterId, string format); Task<(string FileName, string ContentType, string Content)> ExportSceneBriefAsync(int sceneId, string format); Task<(string FileName, string ContentType, string Content)> ExportChapterBriefAsync(int chapterId, string format); Task<(string FileName, string ContentType, string Content)> ExportWriterSessionBriefAsync(int sceneId, string format); Task<(string FileName, string ContentType, string Content)> ExportResearchPackAsync(int projectId, string format); Task<(string FileName, string ContentType, string Content)> ExportSearchResultsAsync(int projectId, string query, string format); } public interface IDynamicsService { Task GetCharacterArcAsync(int characterId); Task GetRelationshipEvolutionAsync(int characterRelationshipId); Task GetKnowledgeTimelineAsync(int characterId); Task GetStoryDynamicsAsync(int projectId); Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId); } public interface IScenarioService { Task ListAsync(int projectId); Task GetCreateAsync(int projectId, int? bookId); Task CreateAsync(ScenarioCreateViewModel model); Task GetTimelineAsync(int scenarioId); Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position); Task ValidateAsync(int scenarioId); Task CompareAsync(int scenarioId); Task ApplyAsync(int scenarioId); Task ArchiveAsync(int scenarioId); } public interface IArchiveService { Task GetArchivedItemsAsync(int? projectId, string? entityType); Task ArchiveAsync(string entityType, int entityId, string? reason); Task RestoreAsync(string entityType, int entityId); Task DeleteForeverAsync(string entityType, int entityId, string confirmationText); } public interface IAcceptanceService { Task GetPhase1ChecklistAsync(int? projectId); } 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, IWriterWorkspaceRepository writerWorkspace) : 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); } } public Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position) => scenes.MoveRelativeAsync(sceneId, anchorSceneId, position); 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); var noteTypes = await writerWorkspace.ListNoteTypesAsync(); var attachmentTypes = await writerWorkspace.ListAttachmentTypesAsync(); model.Workflow = model.SceneID == 0 ? new SceneWorkflow() : await writerWorkspace.GetWorkflowAsync(model.SceneID); model.Notes = model.SceneID == 0 ? [] : await writerWorkspace.ListNotesAsync(model.SceneID); model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID); model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(model.SceneID); model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName); model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName); model.DraftStatusOptions = DraftStatuses() .Select(x => new SelectListItem(x, x, string.Equals(x, model.Workflow.DraftStatus, StringComparison.OrdinalIgnoreCase))) .ToList(); 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.NewNote = new SceneNoteEditViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, SceneNoteTypeID = noteTypes.FirstOrDefault(x => x.TypeName == "Drafting Note")?.SceneNoteTypeID ?? noteTypes.FirstOrDefault()?.SceneNoteTypeID ?? 0 }; model.NewChecklistItem = new SceneChecklistItemEditViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID }; model.NewAttachment = new SceneAttachmentEditViewModel { SceneID = model.SceneID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID, SceneAttachmentTypeID = attachmentTypes.FirstOrDefault(x => x.TypeName == "Link")?.SceneAttachmentTypeID ?? attachmentTypes.FirstOrDefault()?.SceneAttachmentTypeID ?? 0 }; model.MoveRequest = new SceneMoveRequestViewModel { SceneID = model.SceneID, TargetChapterID = model.ChapterID, ReturnProjectID = model.ReturnProjectID, ReturnBookID = model.ReturnBookID }; model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model); return model; } internal static IReadOnlyList DraftStatuses() => [ "Planned", "Ready To Draft", "Drafting", "Drafted", "Revising", "Polishing", "Complete", "On Hold" ]; 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, ITimelinePresetRepository presets, ITimelineSettingsRepository timelineSettings, IProjectRepository projects, IBookRepository books, ISceneService scenes, IAssetRepository assets, ICharacterRepository characters, ILocationRepository locations, IPlotRepository plots, ILookupRepository lookups, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies, ILogger logger) : ITimelineService { public async Task GetAsync(TimelineFilterViewModel filter) { var projectId = filter.ProjectID; var bookId = filter.BookID; var selectedSceneId = filter.SelectedSceneID; var settings = await TimeTimelineLoadAsync("dbo.ProjectTimelineSettings_Get", () => timelineSettings.GetAsync(projectId), projectId, bookId); if (settings is null) { return null; } var selectedMetricIds = (await TimeTimelineLoadAsync("dbo.ProjectTimelineMetricSettings_List", () => timelineSettings.ListMetricSettingsAsync(projectId), projectId, bookId)) .OrderBy(x => x.SortOrder) .Select(x => x.MetricTypeID) .ToList(); var needsPlotTimelineData = settings.ShowPlotLines || filter.PlotLineID.HasValue || filter.PlotThreadID.HasValue || filter.FocusType is "plotline" or "plotthread"; var needsMetricTimelineData = settings.ShowMetricShape && selectedMetricIds.Any(); var needsAssetTimelineData = settings.ShowStoryAssets || filter.StoryAssetID.HasValue || filter.FocusType == "asset"; var needsCharacterTimelineData = settings.ShowCharacterAppearances || filter.CharacterID.HasValue || filter.FocusType == "character"; var needsWarnings = settings.ShowWarnings || filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly; var timeline = await TimeTimelineLoadAsync( "dbo.Timeline_GetByProject", () => timelineRepository.GetByProjectAsync(projectId, bookId, needsMetricTimelineData, selectedMetricIds, needsPlotTimelineData), projectId, bookId); if (timeline.Project is null) { return null; } var allBooks = await TimeTimelineLoadAsync("dbo.Book_ListByProject", () => books.ListByProjectAsync(projectId), projectId, bookId); var lookupData = await TimeTimelineLoadAsync("dbo.Lookup_All", lookups.GetAllAsync, projectId, bookId); var assetLookupData = await TimeTimelineLoadAsync("dbo.AssetLookup_All", assets.GetLookupsAsync, projectId, bookId); var characterLookupData = await TimeTimelineLoadAsync("dbo.CharacterLookup_All", characters.GetLookupsAsync, projectId, bookId); var locationLookupData = await TimeTimelineLoadAsync("dbo.LocationLookup_All", locations.GetLookupsAsync, projectId, bookId); var plotLines = await TimeTimelineLoadAsync("dbo.PlotLine_ListByProject", () => plots.ListPlotLinesAsync(projectId), projectId, bookId); var plotThreads = await TimeTimelineLoadAsync("dbo.PlotThread_ListByProject", () => plots.ListPlotThreadsByProjectAsync(projectId), projectId, bookId); var allAssets = await TimeTimelineLoadAsync("dbo.StoryAsset_ListByProject", () => assets.ListAssetsAsync(projectId), projectId, bookId); var allCharacters = await TimeTimelineLoadAsync("dbo.Character_ListByProject", () => characters.ListCharactersAsync(projectId), projectId, bookId); var allLocations = await TimeTimelineLoadAsync("dbo.Location_ListByProject", () => locations.ListByProjectAsync(projectId), projectId, bookId); var warningLookups = await TimeTimelineLoadAsync("dbo.WarningLookup_All", warnings.GetLookupsAsync, projectId, bookId); 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 = needsWarnings ? (await TimeTimelineLoadAsync("dbo.ContinuityWarning_SceneCounts", () => warnings.GetSceneCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount) : new Dictionary(); var dependencyCounts = (await TimeTimelineLoadAsync("dbo.SceneDependency_Counts", () => sceneDependencies.GetCountsAsync(projectId, bookId), 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); } (IReadOnlyList Assets, IReadOnlyList Events) assetTimeline = needsAssetTimelineData ? await TimeTimelineLoadAsync("dbo.AssetTimeline_GetByProject", () => assets.GetTimelineAsync(projectId, bookId), projectId, bookId) : ([], []); (IReadOnlyList Characters, IReadOnlyList Appearances) characterTimeline = needsCharacterTimelineData ? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId) : ([], []); var scenePovLabels = characterTimeline.Appearances .Where(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true) .GroupBy(x => x.SceneID) .ToDictionary(x => x.Key, x => x.First().CharacterName); var matchingSceneIds = await TimeTimelineLoadAsync("TimelineService.BuildMatchingSceneIds", () => BuildMatchingSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId), projectId, bookId); var focusSceneIds = await TimeTimelineLoadAsync("TimelineService.BuildFocusSceneIds", () => BuildFocusSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId), projectId, bookId); var focusLabel = BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations); var selectedScene = selectedSceneId.HasValue ? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId) : 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, Settings = settings, SelectedBookID = bookId, SelectedSceneID = selectedScene?.SceneID, SelectedScene = selectedScene, Filter = filter, BookOptions = allBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(), ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString())).ToList(), ChapterFromOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterFromID)).ToList(), ChapterToOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterToID)).ToList(), SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString())).ToList(), SceneFromOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneFromID)).ToList(), SceneToOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneToID)).ToList(), PlotLineOptions = plotLines.Select(x => new SelectListItem(x.PlotLineName, x.PlotLineID.ToString(), x.PlotLineID == filter.PlotLineID)).ToList(), PlotThreadOptions = plotThreads.Select(x => new SelectListItem(x.ThreadTitle, x.PlotThreadID.ToString(), x.PlotThreadID == filter.PlotThreadID)).ToList(), AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), x.StoryAssetID == filter.StoryAssetID)).ToList(), QuickAddAssets = allAssets .Where(x => x.ShowInQuickAddBar) .OrderByDescending(x => x.Importance) .ThenBy(x => x.AssetName) .ToList(), AssetEventTypeOptions = ChapterService.ToSelectList(assetLookupData.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName), AssetStateOptions = assetLookupData.AssetStates.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList(), AssetDependencyTypeOptions = ChapterService.ToSelectList(assetLookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName), CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.CharacterID)).ToList(), QuickAddCharacters = allCharacters .Where(x => x.ShowInQuickAddBar) .OrderByDescending(x => x.CharacterImportance ?? 0) .ThenBy(x => x.CharacterName) .ToList(), CharacterRoleOptions = characterLookupData.RoleTypes.Select(x => new SelectListItem(x.TypeName, x.CharacterRoleInSceneTypeID.ToString())).ToList(), PresenceTypeOptions = characterLookupData.PresenceTypes.Select(x => new SelectListItem(x.TypeName, x.PresenceTypeID.ToString())).ToList(), PovCharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.POVCharacterID)).ToList(), LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), x.LocationID == filter.LocationID)).ToList(), QuickAddLocations = allLocations .Where(x => x.ShowInQuickAddBar) .OrderBy(x => x.LocationPath) .ToList(), LocationRelationshipTypeOptions = ChapterService.ToSelectList(locationLookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName), WarningSeverityOptions = warningLookups.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == filter.WarningSeverityID)).ToList(), WarningTypeOptions = warningLookups.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == filter.WarningTypeID)).ToList(), RevisionStatusOptions = lookupData.RevisionStatuses.Select(x => new SelectListItem(x.StatusName, x.RevisionStatusID.ToString(), x.RevisionStatusID == filter.RevisionStatusID)).ToList(), ScenePurposeOptions = lookupData.ScenePurposeTypes.Select(x => new SelectListItem(x.PurposeName, x.ScenePurposeTypeID.ToString(), x.ScenePurposeTypeID == filter.ScenePurposeTypeID)).ToList(), ViewPresets = await TimeTimelineLoadAsync("dbo.TimelineViewPreset_List", () => presets.ListAsync(projectId, bookId), projectId, bookId), 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 = settings.ShowMetricShape ? BuildMetricGraphs(timeline, orderedScenes) : [], PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [], AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [], CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [], MatchingSceneIDs = matchingSceneIds, FocusSceneIDs = focusSceneIds, ScenePovLabels = scenePovLabels, FocusLabel = focusLabel }; } public Task GetPresetAsync(int timelineViewPresetId) => presets.GetAsync(timelineViewPresetId); public Task ArchivePresetAsync(int timelineViewPresetId) => presets.ArchiveAsync(timelineViewPresetId); public async Task GetSettingsAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var settings = await timelineSettings.GetAsync(projectId) ?? new ProjectTimelineSettings { ProjectID = projectId }; var metricTypes = await timelineSettings.ListMetricTypesAsync(projectId); var selectedMetricIds = (await timelineSettings.ListMetricSettingsAsync(projectId)).Select(x => x.MetricTypeID).ToHashSet(); return new TimelineSettingsViewModel { ProjectID = project.ProjectID, ProjectName = project.ProjectName, ShowSceneCards = settings.ShowSceneCards, ShowMetricShape = settings.ShowMetricShape, ShowPlotLines = settings.ShowPlotLines, ShowStoryAssets = settings.ShowStoryAssets, ShowCharacterAppearances = settings.ShowCharacterAppearances, ShowWarnings = settings.ShowWarnings, SelectedMetricTypeIDs = selectedMetricIds.ToList(), MetricOptions = metricTypes .OrderBy(x => x.SortOrder) .ThenBy(x => x.MetricName) .Select(x => new TimelineMetricSettingOptionViewModel { MetricTypeID = x.MetricTypeID, MetricName = x.MetricName, Description = x.Description, SortOrder = x.SortOrder, IsSelected = selectedMetricIds.Contains(x.MetricTypeID) }) .ToList() }; } public async Task SaveSettingsAsync(TimelineSettingsViewModel model) { if (model.ShowMetricShape && !model.SelectedMetricTypeIDs.Any()) { throw new InvalidOperationException("Select at least one metric or disable Metric Shape."); } await timelineSettings.SaveAsync(new ProjectTimelineSettings { ProjectID = model.ProjectID, ShowSceneCards = model.ShowSceneCards, ShowMetricShape = model.ShowMetricShape, ShowPlotLines = model.ShowPlotLines, ShowStoryAssets = model.ShowStoryAssets, ShowCharacterAppearances = model.ShowCharacterAppearances, ShowWarnings = model.ShowWarnings }, model.SelectedMetricTypeIDs); } private async Task TimeTimelineLoadAsync(string operationName, Func> operation, int projectId, int? bookId) { var stopwatch = Stopwatch.StartNew(); try { return await operation(); } finally { stopwatch.Stop(); logger.LogInformation( "Timeline load operation {OperationName} completed in {ElapsedMilliseconds} ms for ProjectID {ProjectID}, BookID {BookID}.", operationName, stopwatch.ElapsedMilliseconds, projectId, bookId); } } public async Task MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model) { if (model.SceneID == model.AnchorSceneID) { return null; } if (model.AnchorSceneID > 0) { await scenes.MoveRelativeAsync(model.SceneID, model.AnchorSceneID, model.Position == "Before" ? "Before" : "After"); } else if (model.TargetChapterID.HasValue) { await scenes.MoveToChapterAsync(new SceneMoveRequestViewModel { SceneID = model.SceneID, TargetChapterID = model.TargetChapterID.Value, Position = "End", RenumberScenes = true, ReturnProjectID = model.ProjectID, ReturnBookID = model.BookID }); } else { return null; } await warnings.FinaliseSceneDependencyWarningsAsync(model.ProjectID, model.BookID); return model.BookID.HasValue ? await warnings.ValidateBookAsync(model.BookID.Value) : await warnings.ValidateProjectAsync(model.ProjectID); } public async Task AddCharacterByDropAsync(TimelineSceneCharacterDropViewModel model) { var characterId = model.CharacterID.GetValueOrDefault(); if (characterId == 0) { throw new InvalidOperationException("Choose a character to drop onto the scene."); } await characters.SaveSceneCharacterAsync(new SceneCharacter { SceneID = model.SceneID, CharacterID = characterId, RoleInSceneTypeID = model.RoleInSceneTypeID, PresenceTypeID = model.PresenceTypeID, LocationID = model.LocationID }); } public async Task AddAssetEventByDropAsync(TimelineAssetEventDropViewModel model) { var assetId = model.StoryAssetID.GetValueOrDefault(); if (assetId == 0) { throw new InvalidOperationException("Choose an asset to drop onto the scene."); } var lookupData = await assets.GetLookupsAsync(); var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID) ?? lookupData.AssetEventTypes.First(); var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(); var description = model.EventDescription; if (!string.IsNullOrWhiteSpace(model.CustodyNote)) { description = string.IsNullOrWhiteSpace(description) ? $"Custody note: {model.CustodyNote.Trim()}" : $"{description.Trim()}\n\nCustody note: {model.CustodyNote.Trim()}"; } await assets.SaveEventAsync(new AssetEvent { StoryAssetID = assetId, SceneID = model.SceneID, AssetEventTypeID = eventType.AssetEventTypeID, FromStateID = model.FromStateID, ToStateID = model.ToStateID, EventTitle = title, EventDescription = description }); if (model.LocationID.HasValue) { await locations.SaveSceneAssetLocationAsync(new SceneAssetLocation { SceneID = model.SceneID, StoryAssetID = assetId, LocationID = model.LocationID.Value, Description = title }, updateCurrentLocation: true); } } public async Task AddAssetDependencyByDropAsync(TimelineAssetDependencyDropViewModel model) { if (model.SourceAssetID == model.TargetAssetID) { throw new InvalidOperationException("An asset cannot depend on itself."); } var existing = await assets.ListDependenciesByAssetAsync(model.SourceAssetID); if (existing.Any(x => x.SourceAssetID == model.SourceAssetID && x.TargetAssetID == model.TargetAssetID && x.AssetDependencyTypeID == model.AssetDependencyTypeID)) { throw new InvalidOperationException("That asset dependency already exists."); } await assets.SaveDependencyAsync(new AssetDependency { SourceAssetID = model.SourceAssetID, TargetAssetID = model.TargetAssetID, AssetDependencyTypeID = model.AssetDependencyTypeID, SourceRequiredStateID = model.SourceRequiredStateID, TargetBlockedStateID = model.TargetBlockedStateID, Description = model.Description }); } public async Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model) { if (model.FromLocationID == model.ToLocationID) { throw new InvalidOperationException("A location cannot be related to itself."); } var existing = await locations.ListRelationshipsByLocationAsync(model.FromLocationID); if (existing.Any(x => x.FromLocationID == model.FromLocationID && x.ToLocationID == model.ToLocationID && x.LocationRelationshipTypeID == model.LocationRelationshipTypeID)) { throw new InvalidOperationException("That location relationship already exists."); } await locations.SaveRelationshipAsync(new LocationRelationship { 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 SavePresetAsync(TimelinePresetSaveViewModel model) { var filter = model.Filter; filter.ProjectID = model.ProjectID; filter.BookID = model.BookID; return presets.SaveAsync(new TimelineViewPreset { ProjectID = model.ProjectID, BookID = model.BookID, PresetName = string.IsNullOrWhiteSpace(model.PresetName) ? "Untitled view" : model.PresetName.Trim(), FilterJson = System.Text.Json.JsonSerializer.Serialize(filter), VisibilityJson = string.IsNullOrWhiteSpace(model.VisibilityJson) ? "{}" : model.VisibilityJson, FocusType = filter.FocusType, FocusID = filter.FocusID }); } private async Task> BuildMatchingSceneIdsAsync( TimelineFilterViewModel filter, IReadOnlyList orderedScenes, IReadOnlyList threadEvents, IReadOnlyList assetEvents, IReadOnlyList characterAppearances, int projectId, int? bookId) { var matches = orderedScenes.Select(x => x.SceneID).ToHashSet(); void Intersect(IEnumerable sceneIds) => matches.IntersectWith(sceneIds.ToHashSet()); if (filter.ChapterFromID.HasValue) { var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.ChapterID == filter.ChapterFromID)?.index; if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID)); } if (filter.ChapterToID.HasValue) { var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).LastOrDefault(x => x.scene.ChapterID == filter.ChapterToID)?.index; if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID)); } if (filter.SceneFromID.HasValue) { var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneFromID)?.index; if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID)); } if (filter.SceneToID.HasValue) { var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneToID)?.index; if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID)); } if (filter.RevisionStatusID.HasValue) Intersect(orderedScenes.Where(x => x.RevisionStatusID == filter.RevisionStatusID).Select(x => x.SceneID)); if (filter.ScenePurposeTypeID.HasValue) Intersect(orderedScenes.Where(x => x.PurposeLabels.Any(p => p.ScenePurposeTypeID == filter.ScenePurposeTypeID)).Select(x => x.SceneID)); if (filter.POVCharacterID.HasValue) Intersect(orderedScenes.Where(x => x.POVCharacterID == filter.POVCharacterID).Select(x => x.SceneID)); if (filter.PlotLineID.HasValue) Intersect(threadEvents.Where(x => x.PlotLineID == filter.PlotLineID).Select(x => x.SceneID)); if (filter.PlotThreadID.HasValue) Intersect(threadEvents.Where(x => x.PlotThreadID == filter.PlotThreadID).Select(x => x.SceneID)); if (filter.StoryAssetID.HasValue) Intersect(assetEvents.Where(x => x.StoryAssetID == filter.StoryAssetID).Select(x => x.SceneID)); if (filter.CharacterID.HasValue) Intersect(characterAppearances.Where(x => x.CharacterID == filter.CharacterID).Select(x => x.SceneID)); if (filter.LocationID.HasValue) { var locationScenes = (await TimeTimelineLoadAsync("dbo.LocationScene_ListByLocation", () => locations.ListScenesByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID); var characterLocationScenes = (await TimeTimelineLoadAsync("dbo.LocationCharacter_ListByLocation", () => locations.ListCharactersByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID); var assetLocationScenes = (await TimeTimelineLoadAsync("dbo.SceneAssetLocation_ListByLocation", () => locations.ListAssetsByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID); Intersect(locationScenes.Concat(characterLocationScenes).Concat(assetLocationScenes)); } if (filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly) { var warningScenes = (await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId)) .Where(x => x.SceneID.HasValue) .Select(x => x.SceneID!.Value); Intersect(warningScenes); } return matches; } private async Task> BuildFocusSceneIdsAsync( TimelineFilterViewModel filter, IReadOnlyList orderedScenes, IReadOnlyList threadEvents, IReadOnlyList assetEvents, IReadOnlyList characterAppearances, int projectId, int? bookId) { if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue) { return new HashSet(); } var focusType = filter.FocusType.ToLowerInvariant(); return focusType switch { "character" => characterAppearances.Where(x => x.CharacterID == filter.FocusID).Select(x => x.SceneID).ToHashSet(), "asset" => assetEvents.Where(x => x.StoryAssetID == filter.FocusID).Select(x => x.SceneID).ToHashSet(), "plotline" => threadEvents.Where(x => x.PlotLineID == filter.FocusID).Select(x => x.SceneID).ToHashSet(), "plotthread" => threadEvents.Where(x => x.PlotThreadID == filter.FocusID).Select(x => x.SceneID).ToHashSet(), "location" => (await TimeTimelineLoadAsync("dbo.LocationScene_ListByLocation", () => locations.ListScenesByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID) .Concat((await TimeTimelineLoadAsync("dbo.LocationCharacter_ListByLocation", () => locations.ListCharactersByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID)) .Concat((await TimeTimelineLoadAsync("dbo.SceneAssetLocation_ListByLocation", () => locations.ListAssetsByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID)) .ToHashSet(), "scene" => orderedScenes.Where(x => x.SceneID == filter.FocusID).Select(x => x.SceneID).ToHashSet(), _ => new HashSet() }; } private static string? BuildFocusLabel(TimelineFilterViewModel filter, IReadOnlyList plotLines, IReadOnlyList plotThreads, IReadOnlyList assets, IReadOnlyList characters, IReadOnlyList locations) { if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue) return null; var name = filter.FocusType.ToLowerInvariant() switch { "character" => characters.FirstOrDefault(x => x.CharacterID == filter.FocusID)?.CharacterName, "asset" => assets.FirstOrDefault(x => x.StoryAssetID == filter.FocusID)?.AssetName, "plotline" => plotLines.FirstOrDefault(x => x.PlotLineID == filter.FocusID)?.PlotLineName, "plotthread" => plotThreads.FirstOrDefault(x => x.PlotThreadID == filter.FocusID)?.ThreadTitle, "location" => locations.FirstOrDefault(x => x.LocationID == filter.FocusID)?.LocationPath, "scene" => "Selected scene", _ => null }; return string.IsNullOrWhiteSpace(name) ? null : $"{filter.FocusType}: {name}"; } 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 .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 // Timeline grouping prefers CharacterImportance when present; imported classification // metadata and strong scene roles remain as fallbacks for older projects. .OrderByDescending(character => CharacterLanePriority(character, appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances) ? sceneAppearances.Values.SelectMany(x => x) : [])) .ThenBy(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(); } private static int CharacterLanePriority(Character character, IEnumerable appearances) { var description = character.DefaultDescription ?? string.Empty; var appearanceList = appearances as IReadOnlyCollection ?? appearances.ToList(); var priority = 0; if (character.CharacterImportance >= 8) { priority += 1_200; } else if (character.CharacterImportance >= 4) { priority += 500; } else if (character.CharacterImportance.HasValue) { priority -= 150; } else if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase)) { priority += 1_000; } else if (description.Contains("Imported character classification: Supporting", StringComparison.OrdinalIgnoreCase)) { priority += 400; } else if (description.Contains("Imported character classification: Minor", StringComparison.OrdinalIgnoreCase)) { priority -= 100; } priority += appearanceList.Count * 3; foreach (var appearance in appearanceList) { if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase)) { priority += 80; } else if (string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase)) { priority += 35; } else if (string.Equals(appearance.RoleInSceneTypeName, "Supporting Participant", StringComparison.OrdinalIgnoreCase)) { priority += 10; } } return priority; } } 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, IChapterRepository chapters, ISceneRepository scenes, IAssetRepository assets, ICharacterRepository characters, IWarningRepository warnings, IDynamicsRepository dynamics) : 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 dynamics.RunContinuityChecksAsync(projectId, null, null); 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 dynamics.RunContinuityChecksAsync(book.ProjectID, bookId, null); await warnings.FinaliseSceneDependencyWarningsAsync(book.ProjectID, bookId); } return summary; } public async Task ValidateSceneAsync(int sceneId) { var summary = await warnings.ValidateSceneAsync(sceneId); var scene = await scenes.GetAsync(sceneId); if (scene is not null) { var chapter = await chapters.GetAsync(scene.ChapterID); if (chapter is not null) { var book = await books.GetAsync(chapter.BookID); if (book is not null) { await dynamics.RunContinuityChecksAsync(book.ProjectID, book.BookID, sceneId); } } } return summary; } public async Task ValidateAfterSceneMoveAsync(int projectId, int? bookId) { var summary = bookId.HasValue ? await warnings.ValidateBookAsync(bookId.Value) : await warnings.ValidateProjectAsync(projectId); await dynamics.RunContinuityChecksAsync(projectId, bookId, null); await warnings.FinaliseSceneDependencyWarningsAsync(projectId, bookId); return summary; } public async Task ValidateAssetAsync(int assetId) { var asset = await assets.GetAssetAsync(assetId); return asset is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(asset.ProjectID); } public async Task ValidateCharacterAsync(int characterId) { var character = await characters.GetCharacterAsync(characterId); return character is null ? new ContinuityValidationSummary() : await 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 StoryBibleService(IProjectRepository projects, IStoryBibleRepository storyBible) : IStoryBibleService { public async Task GetAsync(int projectId, string? query) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } return new StoryBibleViewModel { Project = project, Query = query, Characters = await storyBible.GetCharacterSummariesAsync(projectId), PlotThreads = await storyBible.GetThreadSummariesAsync(projectId), Assets = await storyBible.GetAssetSummariesAsync(projectId), Relationships = await storyBible.GetRelationshipSummariesAsync(projectId), Locations = await storyBible.GetLocationSummariesAsync(projectId), Timeline = await storyBible.GetTimelineSummaryAsync(projectId), OpenQuestions = await storyBible.GetOpenQuestionsAsync(projectId), RevisionItems = await storyBible.GetRevisionProgressAsync(projectId), SearchResults = await storyBible.SearchAsync(projectId, query) }; } } public sealed class WriterWorkspaceService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, IWriterWorkspaceRepository writerWorkspace) : IWriterWorkspaceService { public async Task GetDashboardAsync(int? projectId) { var projectRows = await projects.ListAsync(); return new WriterDashboardViewModel { ProjectID = projectId, ProjectOptions = projectRows.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == projectId)).ToList(), ContinueWriting = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "ContinueWriting"), NeedsAttention = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "NeedsAttention"), RevisionQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "RevisionQueue"), PolishingQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "PolishingQueue"), StoryHealthReminders = await writerWorkspace.GetWriterDashboardRemindersAsync(projectId) }; } public async Task GetChapterWorkflowAsync(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); if (chapter is null || book is null || project is null) { return null; } return new ChapterWorkflowViewModel { Project = project, Book = book, Chapter = chapter, Goal = await writerWorkspace.GetChapterGoalAsync(chapterId), Scenes = await writerWorkspace.GetChapterWorkflowAsync(chapterId) }; } public Task SaveChapterGoalAsync(ChapterGoalEditViewModel model) => writerWorkspace.SaveChapterGoalAsync(new ChapterGoal { ChapterID = model.ChapterID, GoalSummary = model.GoalSummary, EmotionalGoal = model.EmotionalGoal, ReaderTakeaway = model.ReaderTakeaway, MustInclude = model.MustInclude, RisksOrConcerns = model.RisksOrConcerns }); public Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model) => writerWorkspace.SaveWorkflowAsync(new SceneWorkflow { SceneID = model.SceneID, DraftStatus = model.DraftStatus, EstimatedWordCount = model.EstimatedWordCount, ActualWordCount = model.ActualWordCount, DraftedDate = model.DraftedDate, LastWorkedOn = model.LastWorkedOn, ReadyForDraft = model.ReadyForDraft, ReadyForRevision = model.ReadyForRevision, ReadyForPolish = model.ReadyForPolish, IsBlocked = model.IsBlocked, BlockedReason = model.BlockedReason, Priority = model.Priority }); public Task SaveNoteAsync(SceneNoteEditViewModel model) => writerWorkspace.SaveNoteAsync(new SceneNote { SceneNoteID = model.SceneNoteID, SceneID = model.SceneID, SceneNoteTypeID = model.SceneNoteTypeID, NoteTitle = model.NoteTitle, NoteText = model.NoteText, SortOrder = model.SortOrder, IsPinned = model.IsPinned, IsResolved = model.IsResolved }); public Task DeleteNoteAsync(int sceneNoteId) => writerWorkspace.DeleteNoteAsync(sceneNoteId); public Task SaveChecklistItemAsync(SceneChecklistItemEditViewModel model) => writerWorkspace.SaveChecklistItemAsync(new SceneChecklistItem { SceneChecklistItemID = model.SceneChecklistItemID, SceneID = model.SceneID, ItemText = model.ItemText, IsCompleted = model.IsCompleted, SortOrder = model.SortOrder }); public Task DeleteChecklistItemAsync(int sceneChecklistItemId) => writerWorkspace.DeleteChecklistItemAsync(sceneChecklistItemId); public Task SaveAttachmentAsync(SceneAttachmentEditViewModel model) => writerWorkspace.SaveAttachmentAsync(new SceneAttachment { SceneAttachmentID = model.SceneAttachmentID, SceneID = model.SceneID, SceneAttachmentTypeID = model.SceneAttachmentTypeID, Title = model.Title, FilePath = model.FilePath, ExternalUrl = model.ExternalUrl, Notes = model.Notes }); public Task DeleteAttachmentAsync(int sceneAttachmentId) => writerWorkspace.DeleteAttachmentAsync(sceneAttachmentId); } public sealed class ExportService(IExportRepository exports) : IExportService { public async Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format) => Render(await exports.GetStoryBibleAsync(projectId), "story-bible", format); public async Task<(string FileName, string ContentType, string Content)> ExportNarrativeTimelineAsync(int projectId, string format) => Render(await exports.GetNarrativeTimelineAsync(projectId), "narrative-timeline", format); public async Task<(string FileName, string ContentType, string Content)> ExportCharacterReferenceAsync(int characterId, string format) => Render(await exports.GetCharacterReferenceAsync(characterId), "character-reference", format); public async Task<(string FileName, string ContentType, string Content)> ExportSceneBriefAsync(int sceneId, string format) => Render(await exports.GetSceneBriefAsync(sceneId), "scene-brief", format); public async Task<(string FileName, string ContentType, string Content)> ExportChapterBriefAsync(int chapterId, string format) => Render(await exports.GetChapterBriefAsync(chapterId), "chapter-brief", format); public async Task<(string FileName, string ContentType, string Content)> ExportWriterSessionBriefAsync(int sceneId, string format) => Render(await exports.GetWriterSessionBriefAsync(sceneId), "writing-session-brief", format); public async Task<(string FileName, string ContentType, string Content)> ExportResearchPackAsync(int projectId, string format) => Render(await exports.GetResearchPackAsync(projectId), "research-pack", format); public async Task<(string FileName, string ContentType, string Content)> ExportSearchResultsAsync(int projectId, string query, string format) => Render(await exports.GetSearchResultsAsync(projectId, query), "search-results", format); private static (string FileName, string ContentType, string Content) Render(ExportPacket packet, string slug, string format) { var normalised = string.IsNullOrWhiteSpace(format) ? "html" : format.Trim().ToLowerInvariant(); return normalised switch { "markdown" or "md" => ($"{slug}.md", "text/markdown; charset=utf-8", RenderMarkdown(packet)), "text" or "txt" => ($"{slug}.txt", "text/plain; charset=utf-8", RenderText(packet)), _ => ($"{slug}.html", "text/html; charset=utf-8", RenderHtml(packet)) }; } private static string RenderMarkdown(ExportPacket packet) { var builder = new StringBuilder(); builder.AppendLine($"# {packet.Title}").AppendLine(); AppendPacket(builder, packet, markdown: true); return builder.ToString(); } private static string RenderText(ExportPacket packet) { var builder = new StringBuilder(); builder.AppendLine(packet.Title).AppendLine(new string('=', packet.Title.Length)).AppendLine(); AppendPacket(builder, packet, markdown: false); return builder.ToString(); } private static string RenderHtml(ExportPacket packet) { var body = RenderMarkdown(packet) .Split('\n') .Select(line => { var text = WebUtility.HtmlEncode(line.TrimEnd()); if (text.StartsWith("### ")) return $"

{text[4..]}

"; if (text.StartsWith("## ")) return $"

{text[3..]}

"; if (text.StartsWith("# ")) return $"

{text[2..]}

"; if (text.StartsWith("- ")) return $"
  • {text[2..]}
  • "; return string.IsNullOrWhiteSpace(text) ? "" : $"

    {text}

    "; }); return $$""" {{WebUtility.HtmlEncode(packet.Title)}} {{string.Join(Environment.NewLine, body)}} """; } private static void AppendPacket(StringBuilder builder, ExportPacket packet, bool markdown) { if (packet.SceneHeader is not null) AppendSceneHeader(builder, packet.SceneHeader, markdown); if (packet.ChapterHeader is not null) AppendChapterHeader(builder, packet.ChapterHeader, markdown); if (packet.Character is not null) AppendCharacter(builder, packet); AppendTimeline(builder, packet.Timeline, markdown); AppendCharacters(builder, packet.Characters); AppendThreads(builder, packet.PlotThreads); AppendAssets(builder, packet.Assets); AppendRelationships(builder, packet.Relationships); AppendLocations(builder, packet.Locations); AppendItems(builder, "Brief Details", packet.Items, markdown); AppendResearch(builder, packet.ResearchItems, markdown); AppendSearch(builder, packet.SearchResults, markdown); AppendAttention(builder, packet.OpenQuestions); AppendRevision(builder, packet.RevisionItems); } private static void Heading(StringBuilder builder, string text, bool markdown, int level = 2) => builder.AppendLine(markdown ? $"{new string('#', level)} {text}" : text).AppendLine(markdown ? "" : new string('-', text.Length)); private static void AppendSceneHeader(StringBuilder builder, ExportSceneBriefHeader scene, bool markdown) { Heading(builder, $"{scene.BookTitle} / Chapter {scene.ChapterNumber} / Scene {scene.SceneNumber}: {scene.SceneTitle}", markdown); builder.AppendLine($"- POV: {scene.PovCharacterName ?? "Not set"}"); builder.AppendLine($"- Location: {scene.LocationPath ?? "Not set"}"); builder.AppendLine($"- Time: {scene.TimeLabel}"); builder.AppendLine($"- Revision: {scene.RevisionStatusName}"); builder.AppendLine($"- Draft status: {scene.DraftStatus}"); if (scene.Priority.HasValue) builder.AppendLine($"- Priority: {scene.Priority}"); if (scene.IsBlocked) builder.AppendLine($"- Blocked: {scene.BlockedReason}"); AppendBlock(builder, "Summary", scene.Summary, markdown); AppendBlock(builder, "Purpose", scene.ScenePurposeNotes, markdown); AppendBlock(builder, "Outcome", scene.SceneOutcomeNotes, markdown); } private static void AppendChapterHeader(StringBuilder builder, ExportChapterBriefHeader chapter, bool markdown) { Heading(builder, $"{chapter.BookTitle} / Chapter {chapter.ChapterNumber}: {chapter.ChapterTitle}", markdown); builder.AppendLine($"- Revision: {chapter.RevisionStatusName}"); AppendBlock(builder, "Summary", chapter.Summary, markdown); AppendBlock(builder, "Goal", chapter.GoalSummary, markdown); AppendBlock(builder, "Emotion", chapter.EmotionalGoal, markdown); AppendBlock(builder, "Reader Takeaway", chapter.ReaderTakeaway, markdown); AppendBlock(builder, "Must Include", chapter.MustInclude, markdown); AppendBlock(builder, "Risks", chapter.RisksOrConcerns, markdown); } private static void AppendBlock(StringBuilder builder, string title, string? text, bool markdown) { if (string.IsNullOrWhiteSpace(text)) return; builder.AppendLine(markdown ? $"### {title}" : title).AppendLine(text.Trim()).AppendLine(); } private static void AppendTimeline(StringBuilder builder, IReadOnlyList rows, bool markdown) { if (!rows.Any()) return; Heading(builder, "Timeline Summary", markdown); foreach (var book in rows.GroupBy(x => new { x.BookID, x.BookTitle })) { builder.AppendLine(markdown ? $"### {book.Key.BookTitle}" : book.Key.BookTitle); foreach (var scene in book) { builder.AppendLine($"- Chapter {scene.ChapterNumber}, Scene {scene.SceneNumber}: {scene.SceneTitle}"); builder.AppendLine($" POV: {scene.PovCharacterName ?? "Not set"}; Location: {scene.LocationPath ?? "Not set"}; Status: {scene.RevisionStatusName}"); if (!string.IsNullOrWhiteSpace(scene.Summary)) builder.AppendLine($" {scene.Summary}"); } builder.AppendLine(); } } private static void AppendCharacters(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Characters").AppendLine(); foreach (var c in rows) builder.AppendLine($"- {c.CharacterName}: {c.SceneCount} scenes; first {c.FirstSceneLabel ?? "not placed"}; last {c.LastSceneLabel ?? "not placed"}"); builder.AppendLine(); } private static void AppendThreads(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Plot Threads").AppendLine(); foreach (var t in rows) builder.AppendLine($"- {t.ThreadTitle} ({t.ThreadStatusName}, importance {t.Importance}): {t.EventHistory ?? "No events"}"); builder.AppendLine(); } private static void AppendAssets(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Story Assets").AppendLine(); foreach (var a in rows) builder.AppendLine($"- {a.AssetName} ({a.KindName}, {a.CurrentStateName ?? "No state"}): {a.EventHistory ?? "No events"}"); builder.AppendLine(); } private static void AppendRelationships(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Relationships").AppendLine(); foreach (var r in rows) builder.AppendLine($"- {r.CharacterAName} / {r.CharacterBName}: {r.RelationshipTypeName}; {r.CurrentStateName ?? "No state"}"); builder.AppendLine(); } private static void AppendLocations(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Locations").AppendLine(); foreach (var l in rows) builder.AppendLine($"- {l.LocationPath}: {l.SceneCount} scenes, {l.CharacterCount} characters, {l.AssetCount} assets"); builder.AppendLine(); } private static void AppendCharacter(StringBuilder builder, ExportPacket packet) { var c = packet.Character!; builder.AppendLine($"## {c.CharacterName}").AppendLine(); builder.AppendLine($"- Short name: {c.ShortName}"); builder.AppendLine($"- Sex: {c.Sex}"); builder.AppendLine($"- Age at series start: {c.AgeAtSeriesStart}"); builder.AppendLine($"- Height: {c.Height}"); builder.AppendLine($"- Eye colour: {c.EyeColour}"); if (!string.IsNullOrWhiteSpace(c.DefaultDescription)) builder.AppendLine().AppendLine(c.DefaultDescription).AppendLine(); foreach (var app in packet.CharacterAppearances) builder.AppendLine($"- Appearance: {app.BookTitle} / Ch {app.ChapterNumber} / Scene {app.SceneNumber}: {app.SceneTitle} ({app.RoleInSceneTypeName})"); foreach (var rel in packet.CharacterRelationships) builder.AppendLine($"- Relationship: {rel.CharacterAName} / {rel.CharacterBName}: {rel.RelationshipTypeName}"); foreach (var item in packet.CharacterKnowledge) builder.AppendLine($"- Knowledge: {item.KnowledgeStateName} - {item.AssetName ?? item.ThreadTitle}: {item.Description}"); builder.AppendLine(); } private static void AppendItems(StringBuilder builder, string title, IReadOnlyList rows, bool markdown) { if (!rows.Any()) return; Heading(builder, title, markdown); foreach (var group in rows.GroupBy(x => x.ItemType)) { builder.AppendLine(markdown ? $"### {group.Key}" : group.Key); foreach (var item in group) builder.AppendLine($"- {item.Title}{(string.IsNullOrWhiteSpace(item.Status) ? "" : $" [{item.Status}]")}{(string.IsNullOrWhiteSpace(item.Detail) ? "" : $": {item.Detail}")}"); builder.AppendLine(); } } private static void AppendResearch(StringBuilder builder, IReadOnlyList rows, bool markdown) { if (!rows.Any()) return; Heading(builder, "Research Pack", markdown); foreach (var item in rows) { builder.AppendLine($"- {item.BookTitle} / Ch {item.ChapterNumber} / Scene {item.SceneNumber}: {item.Topic} - {item.Title}"); if (!string.IsNullOrWhiteSpace(item.Detail)) builder.AppendLine($" {item.Detail}"); if (!string.IsNullOrWhiteSpace(item.Reference)) builder.AppendLine($" {item.Reference}"); } builder.AppendLine(); } private static void AppendSearch(StringBuilder builder, IReadOnlyList rows, bool markdown) { if (!rows.Any()) return; Heading(builder, "Search Results", markdown); foreach (var group in rows.GroupBy(x => x.ResultType)) { builder.AppendLine(markdown ? $"### {group.Key}" : group.Key); foreach (var result in group) builder.AppendLine($"- {result.Title}{(string.IsNullOrWhiteSpace(result.Detail) ? "" : $": {result.Detail}")}"); } } private static void AppendAttention(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Open Questions and Warnings").AppendLine(); foreach (var item in rows) builder.AppendLine($"- {item.Category}: {item.Title}{(string.IsNullOrWhiteSpace(item.Detail) ? "" : $" - {item.Detail}")}"); builder.AppendLine(); } private static void AppendRevision(StringBuilder builder, IReadOnlyList rows) { if (!rows.Any()) return; builder.AppendLine("## Revision Status").AppendLine(); foreach (var item in rows) builder.AppendLine($"- {item.GroupName}: {item.ItemCount} {item.Detail}"); builder.AppendLine(); } } public sealed class DynamicsService(IProjectRepository projects, IDynamicsRepository dynamics) : IDynamicsService { public async Task GetCharacterArcAsync(int characterId) { var data = await dynamics.GetCharacterArcAsync(characterId); if (data.Character is null) { return null; } var project = await projects.GetAsync(data.Character.ProjectID); return project is null ? null : new CharacterArcViewModel { Project = project, Character = data.Character, ArcPoints = data.ArcPoints, RelationshipEvents = data.RelationshipEvents, KnowledgeEvents = data.KnowledgeEvents, Issues = data.Issues }; } public async Task GetRelationshipEvolutionAsync(int characterRelationshipId) { var data = await dynamics.GetRelationshipEvolutionAsync(characterRelationshipId); if (data.Relationship is null) { return null; } var project = await projects.GetAsync(data.Relationship.ProjectID); return project is null ? null : new RelationshipEvolutionViewModel { Project = project, Relationship = data.Relationship, Events = data.Events, Issues = data.Issues }; } public async Task GetKnowledgeTimelineAsync(int characterId) { var data = await dynamics.GetKnowledgeTimelineAsync(characterId); if (data.Character is null) { return null; } var project = await projects.GetAsync(data.Character.ProjectID); return project is null ? null : new KnowledgeTimelineViewModel { Project = project, Character = data.Character, KnowledgeEvents = data.KnowledgeEvents, Issues = data.Issues }; } public async Task GetStoryDynamicsAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } var data = await dynamics.GetStoryDynamicsAsync(projectId); return new StoryDynamicsViewModel { Project = project, Pairings = data.Pairings, Issues = data.Issues }; } public Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId) => dynamics.RunContinuityChecksAsync(projectId, bookId, sceneId); } public sealed class ScenarioService(IProjectRepository projects, IBookRepository books, IScenarioRepository scenarios, IContinuityValidationService validation) : IScenarioService { public async Task ListAsync(int projectId) { var project = await projects.GetAsync(projectId); return project is null ? null : new ScenarioListViewModel { Project = project, Scenarios = await scenarios.ListAsync(projectId) }; } public async Task GetCreateAsync(int projectId, int? bookId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } return new ScenarioCreateViewModel { ProjectID = projectId, BookID = bookId, BookOptions = (await books.ListByProjectAsync(projectId)) .Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)) .ToList() }; } public Task CreateAsync(ScenarioCreateViewModel model) => scenarios.CreateAsync(model.ProjectID, model.BookID, model.ScenarioName, model.Description); public async Task GetTimelineAsync(int scenarioId) { var scenario = await scenarios.GetAsync(scenarioId); if (scenario is null) { return null; } var project = await projects.GetAsync(scenario.ProjectID); if (project is null) { return null; } var data = await scenarios.GetTimelineAsync(scenarioId); return new ScenarioTimelineViewModel { Project = project, Scenario = scenario, Books = data.Books, Chapters = data.Chapters, Scenes = data.Scenes, Warnings = data.Warnings, ChapterOptions = data.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString())).ToList() }; } public Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position) => scenarios.MoveSceneAsync(scenarioId, sceneId, anchorSceneId, targetChapterId, position); public Task ValidateAsync(int scenarioId) => scenarios.ValidateAsync(scenarioId); public async Task CompareAsync(int scenarioId) { var scenario = await scenarios.GetAsync(scenarioId); if (scenario is null) { return null; } var project = await projects.GetAsync(scenario.ProjectID); if (project is null) { return null; } var data = await scenarios.CompareAsync(scenarioId); return new ScenarioCompareViewModel { Project = project, Scenario = scenario, MovedScenes = data.MovedScenes, ScenarioWarnings = data.Warnings }; } public async Task ApplyAsync(int scenarioId) { var scenario = await scenarios.GetAsync(scenarioId); await scenarios.ApplyAsync(scenarioId); if (scenario is not null) { await validation.ValidateProjectAsync(scenario.ProjectID); } } public Task ArchiveAsync(int scenarioId) => scenarios.ArchiveAsync(scenarioId); } public sealed class ArchiveService(IArchiveRepository archives) : IArchiveService { private static readonly (string Value, string Label)[] EntityTypes = [ ("Project", "Projects"), ("Book", "Books"), ("Chapter", "Chapters"), ("Scene", "Scenes"), ("PlotLine", "Plot Lines"), ("PlotThread", "Plot Threads"), ("StoryAsset", "Story Assets"), ("Character", "Characters"), ("Location", "Locations"), ("Scenario", "Scenarios"), ("Relationship", "Relationships") ]; public async Task GetArchivedItemsAsync(int? projectId, string? entityType) { var normalisedType = EntityTypes.Any(x => x.Value == entityType) ? entityType : null; var projects = await archives.ListProjectFiltersAsync(); return new ArchivedItemsViewModel { ProjectID = projectId, EntityType = normalisedType, ProjectOptions = projects.Select(project => new SelectListItem( project.IsArchived ? $"{project.ProjectName} (Archived)" : project.ProjectName, project.ProjectID.ToString(), project.ProjectID == projectId)).ToList(), EntityTypeOptions = EntityTypes.Select(type => new SelectListItem(type.Label, type.Value, type.Value == normalisedType)).ToList(), Items = await archives.ListAsync(projectId, normalisedType) }; } public Task ArchiveAsync(string entityType, int entityId, string? reason) => archives.ArchiveAsync(entityType, entityId, reason); public Task RestoreAsync(string entityType, int entityId) => archives.RestoreAsync(entityType, entityId); public Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) => archives.DeleteForeverAsync(entityType, entityId, confirmationText); } public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance) : IAcceptanceService { public async Task GetPhase1ChecklistAsync(int? projectId) { var projectRows = await projects.ListAsync(); var selectedProject = projectId.HasValue ? projectRows.FirstOrDefault(x => x.ProjectID == projectId.Value) : projectRows.FirstOrDefault(); var effectiveProjectId = projectId ?? selectedProject?.ProjectID; return new Phase1AcceptanceViewModel { ProjectID = effectiveProjectId, ProjectName = selectedProject?.ProjectName, ProjectOptions = projectRows.Select(project => new SelectListItem( project.ProjectName, project.ProjectID.ToString(), project.ProjectID == effectiveProjectId)).ToList(), Items = await acceptance.GetPhase1ChecklistAsync(effectiveProjectId) }; } } public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService { public async Task GetAssetsAsync(int projectId) { var project = await projects.GetAsync(projectId); var lookupData = await assets.GetLookupsAsync(); return project is null ? null : new StoryAssetListViewModel { Project = project, Assets = await assets.ListAssetsAsync(projectId), AssetDependencyTypeOptions = ChapterService.ToSelectList(lookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName), AssetStateOptions = OptionalStateList(lookupData.AssetStates) }; } 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, ShowInQuickAddBar = asset.ShowInQuickAddBar, 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, ShowInQuickAddBar = model.ShowInQuickAddBar }); 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); var lookupData = await locations.GetLookupsAsync(); return project is null ? null : new LocationListViewModel { Project = project, Locations = await locations.ListByProjectAsync(projectId), RelationshipTypeOptions = ChapterService.ToSelectList(lookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName) }; } 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, ShowInQuickAddBar = location.ShowInQuickAddBar, 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, ShowInQuickAddBar = model.ShowInQuickAddBar }); 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, CharacterImportance = character.CharacterImportance, ShowInQuickAddBar = character.ShowInQuickAddBar, 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, CharacterImportance = model.CharacterImportance, ShowInQuickAddBar = model.ShowInQuickAddBar, DefaultDescription = model.DefaultDescription }); public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId); public Task ArchiveRelationshipAsync(int characterRelationshipId) => characters.ArchiveRelationshipAsync(characterRelationshipId); 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("Select an existing 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 UpdateSceneCharacterAsync(SceneCharacterEditViewModel model) => characters.SaveSceneCharacterAsync(new SceneCharacter { SceneCharacterID = model.SceneCharacterID, SceneID = model.SceneID, CharacterID = model.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 Task DeleteKnowledgeAsync(int characterKnowledgeId) => characters.DeleteKnowledgeAsync(characterKnowledgeId); 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 Task DeleteRelationshipEventAsync(int relationshipEventId) => characters.DeleteRelationshipEventAsync(relationshipEventId); 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(); } }