using System.Diagnostics; using System.Net; using System.Text; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Options; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface IProjectService { Task ListAsync(); Task GetDetailAsync(int projectId); Task GetArchivedOwnerProjectAsync(int projectId); Task GetCreateAsync(); Task GetEditAsync(int projectId); Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model); Task SaveAsync(ProjectEditViewModel model); Task ArchiveAsync(int projectId); Task RestoreAsync(int projectId); Task HardDeleteAsync(int projectId); } public interface IProjectCollaborationService { Task GetCollaboratorsAsync(int projectId); Task AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model); Task RemoveCollaboratorAsync(int projectId, int userId); } public interface IProjectActivityService { Task GetActivityAsync(int projectId); Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); } public sealed class ProjectCollaborationResult { public bool Succeeded { get; init; } public bool NotFound { get; init; } public string Message { get; init; } = string.Empty; public static ProjectCollaborationResult Success(string message) => new() { Succeeded = true, Message = message }; public static ProjectCollaborationResult Failure(string message) => new() { Message = message }; public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." }; } public sealed class ProjectActivityService( IProjectRepository projects, IProjectActivityRepository activity, ICurrentUserService currentUser) : IProjectActivityService { public async Task GetActivityAsync(int projectId) { var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; return project is null ? null : new ProjectActivityViewModel { Project = project, Activities = await activity.ListForProjectAsync(projectId) }; } public Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) { if (projectId <= 0 || string.IsNullOrWhiteSpace(activityType) || string.IsNullOrWhiteSpace(entityType)) { return Task.CompletedTask; } return activity.RecordAsync(projectId, currentUser.UserId, activityType, entityType, entityId, entityName, description); } } 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 PopulatePlotLineFormAsync(PlotLineEditViewModel model); 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 UpdateThreadEventAsync(ThreadEventEditViewModel 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 SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model); 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 ISceneMetricTypeService { Task GetAsync(int projectId); Task GetEditAsync(int projectId, int metricTypeId); Task SaveAsync(SceneMetricTypeEditViewModel model); Task SetActiveAsync(int projectId, int metricTypeId, bool isActive); Task MoveAsync(int projectId, int metricTypeId, string direction); Task AddMissingDefaultsAsync(int projectId); Task RemoveUnusedAsync(int projectId, int metricTypeId); } public interface ISceneMetricValueService { Task UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel 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 IRelationshipMapService { Task GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork); } 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 IStorageService { Task GetSceneStorageUsageAsync(int sceneId); Task CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes); Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes); Task MarkDeletedByStoragePathAsync(string? storagePath); } 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, IProjectActivityService activity, ICurrentUserService currentUser, IHardDeleteFileCleanupService fileCleanup, ILogger logger) : IProjectService { public async Task ListAsync() { if (!currentUser.UserId.HasValue) { return new ProjectIndexViewModel(); } return new ProjectIndexViewModel { ActiveProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value), ArchivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value) }; } public async Task GetDetailAsync(int projectId) { var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; if (project is null) { return null; } return new ProjectDetailViewModel { Project = project, Books = await books.ListByProjectAsync(projectId) }; } public Task GetArchivedOwnerProjectAsync(int projectId) { return currentUser.UserId.HasValue ? projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value) : Task.FromResult(null); } public async Task GetCreateAsync() { var model = new ProjectEditViewModel(); await PopulateGenrePresetOptionsAsync(model); return model; } public async Task GetEditAsync(int projectId) { var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; return project is null ? null : new ProjectEditViewModel { ProjectID = project.ProjectID, ProjectName = project.ProjectName, Description = project.Description }; } public async Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model) { var presets = await projects.ListGenreMetricPresetsAsync(); model.GenreMetricPresetOptions = presets .Select(x => new SelectListItem(x.PresetName, x.PresetKey, string.Equals(x.PresetKey, model.GenreMetricPresetKey, StringComparison.OrdinalIgnoreCase))) .ToList(); } public async Task SaveAsync(ProjectEditViewModel model) { if (!currentUser.UserId.HasValue) { return 0; } var isNew = model.ProjectID == 0; var projectId = await projects.SaveAsync(new Project { ProjectID = model.ProjectID, ProjectName = model.ProjectName, Description = model.Description }, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null); await activity.RecordAsync(projectId, isNew ? "Created" : "Updated", "Project", projectId, model.ProjectName); return projectId; } public async Task ArchiveAsync(int projectId) { if (!currentUser.UserId.HasValue) { return false; } var project = await projects.GetForUserAsync(projectId, currentUser.UserId.Value); var archived = await projects.ArchiveForOwnerAsync(projectId, currentUser.UserId.Value); if (archived) { await activity.RecordAsync(projectId, "Archived", "Project", projectId, project?.ProjectName); } return archived; } public async Task RestoreAsync(int projectId) { if (!currentUser.UserId.HasValue) { return false; } var project = await projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value); var restored = await projects.RestoreForOwnerAsync(projectId, currentUser.UserId.Value); if (restored) { await activity.RecordAsync(projectId, "Restored", "Project", projectId, project?.ProjectName); } return restored; } public async Task HardDeleteAsync(int projectId) { if (!currentUser.UserId.HasValue) { logger.LogWarning("Hard project delete requested without an authenticated user for project {ProjectID}.", projectId); return ProjectHardDeleteResult.Inaccessible(); } var userId = currentUser.UserId.Value; var activeProject = await projects.GetForUserAsync(projectId, userId); if (activeProject is not null) { if (!activeProject.IsOwner) { logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: user is not owner.", projectId, userId); return ProjectHardDeleteResult.Inaccessible(); } logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId); return ProjectHardDeleteResult.ActiveProject(); } var project = await projects.GetArchivedForOwnerAsync(projectId, userId); if (project is null) { logger.LogWarning("Hard project delete denied for archived project {ProjectID} and user {UserID}: project not found or user is not owner.", projectId, userId); return ProjectHardDeleteResult.Inaccessible(); } IReadOnlyList filePaths; try { filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId); await projects.HardDeleteForOwnerAsync(projectId, userId); logger.LogInformation("Database hard delete completed for archived project {ProjectID} owned by user {UserID}. {FilePathCount} file paths queued for cleanup.", projectId, userId, filePaths.Count); } catch (Exception ex) { logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.", projectId, userId); return ProjectHardDeleteResult.Failure("Project could not be permanently deleted. No files were removed."); } var cleanup = fileCleanup.DeleteFiles("project hard delete", projectId, filePaths); if (cleanup.Failed > 0) { logger.LogWarning("Project {ProjectID} was hard deleted from the database but file cleanup had {FailedFileCount} failures, {DeletedFileCount} deleted files, and {MissingFileCount} missing files.", projectId, cleanup.Failed, cleanup.Deleted, cleanup.Missing); } else { logger.LogInformation("File cleanup completed for hard-deleted project {ProjectID}: {DeletedFileCount} deleted files, {MissingFileCount} missing files.", projectId, cleanup.Deleted, cleanup.Missing); } return ProjectHardDeleteResult.Success(cleanup.Deleted, cleanup.Missing, cleanup.Failed); } } public sealed class ProjectCollaborationService( IProjectRepository projects, IProjectCollaborationRepository collaboration, IUserRepository users, IProjectAccessService access, ISubscriptionService subscriptions, IEmailQueueRepository emailQueue, IProjectActivityService activity, ICurrentUserService currentUser, IOptions emailOptions, ILogger logger) : IProjectCollaborationService { private readonly EmailSettings _emailSettings = emailOptions.Value; public async Task GetCollaboratorsAsync(int projectId) { if (!await access.CanOwnProjectAsync(projectId)) { return null; } var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; if (project is null) { return null; } return new ProjectCollaboratorsViewModel { Project = project, Collaborators = await collaboration.ListCollaboratorsAsync(projectId), PendingInvitations = await collaboration.ListPendingInvitationsAsync(projectId), Invite = new ProjectCollaboratorInviteViewModel { ProjectID = projectId } }; } public async Task AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model) { if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(model.ProjectID)) { return ProjectCollaborationResult.Inaccessible(); } var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID); if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value) { return ProjectCollaborationResult.Inaccessible(); } var email = NormalizeEmail(model.EmailAddress); if (string.IsNullOrWhiteSpace(email)) { return ProjectCollaborationResult.Failure("Enter a valid collaborator email address."); } if (string.Equals(email, currentUser.Email, StringComparison.OrdinalIgnoreCase)) { return ProjectCollaborationResult.Failure("Owners already have access to their own projects."); } var user = await users.GetByEmailAsync(email); if (user is not null) { if (user.UserID == ownerUserId.Value) { return ProjectCollaborationResult.Failure("Owners already have access to their own projects."); } var existingCollaborators = await collaboration.ListCollaboratorsAsync(model.ProjectID); if (existingCollaborators.Any(x => x.UserID == user.UserID)) { return ProjectCollaborationResult.Success("That user already has collaborator access."); } if (!await collaboration.CollaboratorCountsForOwnerAsync(ownerUserId.Value, user.UserID)) { var limit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value); if (!limit.Allowed) { return ProjectCollaborationResult.Failure(limit.Message); } } await collaboration.AddCollaboratorAsync(model.ProjectID, user.UserID, currentUser.UserId.Value); await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", user.UserID, user.DisplayName, $"Added collaborator {user.Email}."); await QueueInvitationNotificationAsync(user, model.ProjectID); return ProjectCollaborationResult.Success("Collaborator added."); } var pendingLimit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value); if (!pendingLimit.Allowed) { return ProjectCollaborationResult.Failure(pendingLimit.Message); } await collaboration.CreatePendingInvitationAsync(model.ProjectID, email, currentUser.UserId.Value); await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", null, email, "Pending invitation recorded."); return ProjectCollaborationResult.Success("Pending invitation recorded. When that person creates an account, this invitation can be completed in the collaboration workflow."); } public async Task RemoveCollaboratorAsync(int projectId, int userId) { if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(projectId)) { return ProjectCollaborationResult.Inaccessible(); } var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId); if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value) { return ProjectCollaborationResult.Inaccessible(); } if (userId == ownerUserId.Value) { return ProjectCollaborationResult.Failure("Project owners cannot be removed as collaborators."); } var removed = await collaboration.RemoveCollaboratorAsync(projectId, userId); if (removed) { await activity.RecordAsync(projectId, "CollaboratorRemoved", "Collaborator", userId, null); } return removed ? ProjectCollaborationResult.Success("Collaborator removed.") : ProjectCollaborationResult.Failure("Collaborator access was not found."); } private async Task QueueInvitationNotificationAsync(AppUser user, int projectId) { var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; var projectName = project?.ProjectName ?? "a project"; var inviterName = CleanDisplayName(currentUser.DisplayName); var subject = $"You have been added to {projectName} in PlotDirector"; var plainText = $""" You have been added as a collaborator. Hello {CleanDisplayName(user.DisplayName)}, {inviterName} added you as a collaborator on "{projectName}" in PlotDirector. Sign in to PlotDirector to open the shared project. """; var html = $"""

PlotDirector

You have been added as a collaborator

Hello {WebUtility.HtmlEncode(CleanDisplayName(user.DisplayName))},

{WebUtility.HtmlEncode(inviterName)} added you as a collaborator on {WebUtility.HtmlEncode(projectName)}.

Sign in to PlotDirector to open the shared project.

"""; var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem { ToEmail = user.Email, ToName = CleanDisplayName(user.DisplayName), FromEmail = GetFromAddress(), FromName = CleanDisplayName(_emailSettings.FromName), Subject = subject, PlainTextBody = plainText, HtmlBody = html, MaxAttempts = 5 }); logger.LogInformation("Queued collaborator invitation email {EmailQueueID} for user {UserID}.", emailQueueId, user.UserID); } private static string NormalizeEmail(string? value) => value?.Trim().ToLowerInvariant() ?? string.Empty; private static string CleanDisplayName(string? value) { return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim(); } private string GetFromAddress() { return string.IsNullOrWhiteSpace(_emailSettings.FromAddress) ? "hello@plotdirector.com" : _emailSettings.FromAddress.Trim(); } } internal static class SubscriptionLimitGuards { public static async Task EnsureAllowedAsync(Func> validateAsync, int? userId) { if (!userId.HasValue) { throw new SubscriptionLimitException("Sign in before creating new content."); } var result = await validateAsync(userId.Value); if (!result.Allowed) { throw new SubscriptionLimitException(result.Message); } } } public sealed class BookService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, ISubscriptionService subscriptions, IProjectActivityService activity, ICurrentUserService currentUser) : 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 async Task SaveAsync(BookEditViewModel model) { if (model.BookID == 0) { await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId); } var isNew = model.BookID == 0; var bookId = await books.SaveAsync(new Book { BookID = model.BookID, ProjectID = model.ProjectID, BookTitle = model.BookTitle, BookNumber = model.BookNumber, Description = model.Description }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle); return bookId; } public async Task ArchiveAsync(int bookId) { var book = await books.GetAsync(bookId); await books.ArchiveAsync(bookId); if (book is not null) { await activity.RecordAsync(book.ProjectID, "Archived", "Book", bookId, book.BookTitle); } } } public sealed class ChapterService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, ILookupRepository lookups, ISubscriptionService subscriptions, IProjectActivityService activity, ICurrentUserService currentUser) : 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 async Task SaveAsync(ChapterEditViewModel model) { if (model.ChapterID == 0) { await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateChapterAsync(model.BookID, userId), currentUser.UserId); } var isNew = model.ChapterID == 0; var chapterId = await chapters.SaveAsync(new Chapter { ChapterID = model.ChapterID, BookID = model.BookID, ChapterNumber = model.ChapterNumber, ChapterTitle = model.ChapterTitle, Summary = model.Summary, RevisionStatusID = model.RevisionStatusID }); var book = await books.GetAsync(model.BookID); if (book is not null) { await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle); } return chapterId; } public async Task ArchiveAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); var book = chapter is null ? null : await books.GetAsync(chapter.BookID); await chapters.ArchiveAsync(chapterId); if (chapter is not null && book is not null) { await activity.RecordAsync(book.ProjectID, "Archived", "Chapter", chapterId, chapter.ChapterTitle); } } 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, ISubscriptionService subscriptions, IProjectActivityService activity, IStorageService storage, ICurrentUserService currentUser) : 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 isNew = model.SceneID == 0; if (isNew) { var chapter = await chapters.GetAsync(model.ChapterID); if (chapter is null) { throw new SubscriptionLimitException("The chapter could not be found."); } await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateSceneAsync(chapter.BookID, userId), currentUser.UserId); } 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 })); var context = await GetContextAsync(model.ChapterID); if (context is not null) { await activity.RecordAsync(context.Value.Project.ProjectID, isNew ? "Created" : "Updated", "Scene", sceneId, model.SceneTitle); } return sceneId; } public async Task ArchiveAsync(int sceneId) { var scene = await scenes.GetAsync(sceneId); var context = scene is null ? null : await GetContextAsync(scene.ChapterID); await scenes.ArchiveAsync(sceneId); if (scene is not null && context is not null) { await activity.RecordAsync(context.Value.Project.ProjectID, "Archived", "Scene", sceneId, scene.SceneTitle); } } 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.StorageUsage = model.SceneID == 0 ? null : await storage.GetSceneStorageUsageAsync(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.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName); model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}"); model.PlotLineEventOptions = plotLines; model.PlotThreadEventOptions = plotThreads; model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName); model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName); model.PlotEventTypeOptions = ChapterService.ToSelectList(plotLookups.PlotEventTypes, x => x.PlotEventTypeID, 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 = ToRelationshipTypeSelectList(characterLookups.RelationshipTypes); 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, PlotEventTypeID = plotLookups.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID ?? plotLookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID ?? 0, 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; } private static IReadOnlyList ToOptionalSelectList(IEnumerable rows, Func value, Func text) => rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList(); private static IReadOnlyList ToRelationshipTypeSelectList(IEnumerable relationshipTypes) { var groups = relationshipTypes .GroupBy(x => string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName) .ToDictionary(x => x.Key, x => new SelectListGroup { Name = x.Key }); return relationshipTypes .OrderBy(x => x.CategorySortOrder == 0 ? 900 : x.CategorySortOrder) .ThenBy(x => x.SortOrder) .ThenBy(x => x.TypeName) .Select(x => new SelectListItem(x.TypeName, x.RelationshipTypeID.ToString()) { Group = groups[string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName] }) .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, MinValue = metric.MinValue, MaxValue = metric.MaxValue, 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, IProjectActivityService activity) : 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, PlotImportanceID = lookupData.PlotImportance.FirstOrDefault(x => x.ImportanceName == "Secondary")?.PlotImportanceID ?? lookupData.PlotImportance.First().PlotImportanceID, 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, PlotImportanceID = plotLine.PlotImportanceID, Description = plotLine.Description, ParentPlotLineID = plotLine.ParentPlotLineID, EmergesFromPlotLineID = plotLine.EmergesFromPlotLineID, SortOrder = plotLine.SortOrder, Colour = plotLine.Colour, IsVisibleOnTimeline = plotLine.IsVisibleOnTimeline, Project = project }, lookupData, plotLines.Where(x => x.PlotLineID != plotLine.PlotLineID)); } public async Task PopulatePlotLineFormAsync(PlotLineEditViewModel model) { var project = await projects.GetAsync(model.ProjectID); if (project is null) { return null; } var lookupData = await plots.GetLookupsAsync(); var plotLines = await plots.ListPlotLinesAsync(model.ProjectID); model.Project = project; if (model.PlotLineTypeID == 0) { model.PlotLineTypeID = lookupData.PlotLineTypes.FirstOrDefault(x => x.TypeName == "Side Plot")?.PlotLineTypeID ?? lookupData.PlotLineTypes.First().PlotLineTypeID; } return await PopulatePlotLineListsAsync(model, lookupData, plotLines.Where(x => x.PlotLineID != model.PlotLineID)); } public async Task SavePlotLineAsync(PlotLineEditViewModel model) { var isNew = model.PlotLineID == 0; var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem { PlotLineID = model.PlotLineID, ProjectID = model.ProjectID, BookID = model.BookID, PlotLineName = model.PlotLineName, PlotLineTypeID = model.PlotLineTypeID, PlotImportanceID = model.PlotImportanceID, Description = model.Description, ParentPlotLineID = model.ParentPlotLineID, EmergesFromPlotLineID = model.EmergesFromPlotLineID, SortOrder = model.SortOrder, Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour, IsVisibleOnTimeline = model.IsVisibleOnTimeline }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Plot Line", plotLineId, model.PlotLineName); return plotLineId; } public async Task ArchivePlotLineAsync(int plotLineId) { var plotLine = await plots.GetPlotLineAsync(plotLineId); await plots.ArchivePlotLineAsync(plotLineId); if (plotLine is not null) { await activity.RecordAsync(plotLine.ProjectID, "Archived", "Plot Line", plotLineId, plotLine.PlotLineName); } } 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 async Task SavePlotThreadAsync(PlotThreadEditViewModel model) { var isNew = model.PlotThreadID == 0; var plotThreadId = await 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 }); var plotLine = await plots.GetPlotLineAsync(model.PlotLineID); if (plotLine is not null) { await activity.RecordAsync(plotLine.ProjectID, isNew ? "Created" : "Updated", "Thread", plotThreadId, model.ThreadTitle); } return plotThreadId; } public async Task ArchivePlotThreadAsync(int plotThreadId) { var plotThread = await plots.GetPlotThreadAsync(plotThreadId); await plots.ArchivePlotThreadAsync(plotThreadId); if (plotThread is not null) { await activity.RecordAsync(plotThread.ProjectID, "Archived", "Thread", plotThreadId, plotThread.ThreadTitle); } } public async Task AddThreadEventAsync(ThreadEventCreateViewModel model) { var plotThreadId = model.PlotThreadID.GetValueOrDefault(); var lookupData = await plots.GetLookupsAsync(); if (model.PlotEventTypeID <= 0) { throw new InvalidOperationException("Choose an event type."); } var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID) ?? lookupData.ThreadEventTypes.First(); var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID) ?? throw new InvalidOperationException("Choose a valid event type."); var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(); var sourcePlotLineId = model.PlotLineID; 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 }); } else { var existingPlotThread = await plots.GetPlotThreadAsync(plotThreadId); if (existingPlotThread is null) { throw new InvalidOperationException("Choose a valid plot thread."); } sourcePlotLineId = existingPlotThread.PlotLineID; } if (!sourcePlotLineId.HasValue) { throw new InvalidOperationException("Choose a source plot line."); } var projectPlotLines = await plots.ListPlotLinesAsync(model.ReturnProjectID.GetValueOrDefault()); if (projectPlotLines.Count == 0) { var sourcePlotLine = await plots.GetPlotLineAsync(sourcePlotLineId.Value); projectPlotLines = sourcePlotLine is null ? [] : await plots.ListPlotLinesAsync(sourcePlotLine.ProjectID); } ValidatePlotEventTargets( plotEventType.TypeName, sourcePlotLineId.Value, projectPlotLines, model.TargetPlotLineID, model.SelectedTargetPlotLineIDs); var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge" ? model.TargetPlotLineID : null; var splitTargetPlotLineIds = plotEventType.TypeName == "Split" ? model.SelectedTargetPlotLineIDs : []; var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent { PlotThreadID = plotThreadId, SceneID = model.SceneID, EventTypeID = eventType.ThreadEventTypeID, PlotEventTypeID = plotEventType.PlotEventTypeID, TargetPlotLineID = targetPlotLineId, EventTitle = title, EventDescription = model.EventDescription }); await plots.SaveThreadEventTargetsAsync(threadEventId, splitTargetPlotLineIds); var plotThread = await plots.GetPlotThreadAsync(plotThreadId); if (plotThread is not null) { await activity.RecordAsync(plotThread.ProjectID, "Created", "Thread Event", threadEventId, title); } return threadEventId; } public async Task UpdateThreadEventAsync(ThreadEventEditViewModel model) { var lookupData = await plots.GetLookupsAsync(); if (model.PlotEventTypeID <= 0) { throw new InvalidOperationException("Choose an event type."); } var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID) ?? throw new InvalidOperationException("Choose a valid marker type."); var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID) ?? throw new InvalidOperationException("Choose a valid event type."); var plotThread = await plots.GetPlotThreadAsync(model.PlotThreadID) ?? throw new InvalidOperationException("Choose a valid plot thread."); var projectPlotLines = await plots.ListPlotLinesAsync(plotThread.ProjectID); ValidatePlotEventTargets( plotEventType.TypeName, plotThread.PlotLineID, projectPlotLines, model.TargetPlotLineID, model.SelectedTargetPlotLineIDs); var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge" ? model.TargetPlotLineID : null; var splitTargetPlotLineIds = plotEventType.TypeName == "Split" ? model.SelectedTargetPlotLineIDs : []; await plots.SaveThreadEventAsync(new ThreadEvent { ThreadEventID = model.ThreadEventID, PlotThreadID = plotThread.PlotThreadID, SceneID = model.SceneID, EventTypeID = eventType.ThreadEventTypeID, PlotEventTypeID = plotEventType.PlotEventTypeID, TargetPlotLineID = targetPlotLineId, EventTitle = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(), EventDescription = model.EventDescription }); await plots.SaveThreadEventTargetsAsync(model.ThreadEventID, splitTargetPlotLineIds); await activity.RecordAsync(plotThread.ProjectID, "Updated", "Thread Event", model.ThreadEventID, model.EventTitle); } private static void ValidatePlotEventTargets( string plotEventTypeName, int sourcePlotLineId, IReadOnlyList projectPlotLines, int? targetPlotLineId, IEnumerable selectedTargetPlotLineIds) { var projectPlotLineIds = projectPlotLines.Select(x => x.PlotLineID).ToHashSet(); if (plotEventTypeName is "Branch" or "Merge") { if (!targetPlotLineId.HasValue) { throw new InvalidOperationException(plotEventTypeName == "Branch" ? "Choose a target plot line for the branch event." : "Choose the plot line this event merges into."); } if (targetPlotLineId.Value == sourcePlotLineId) { throw new InvalidOperationException("Target plot line cannot be the source plot line."); } if (!projectPlotLineIds.Contains(targetPlotLineId.Value)) { throw new InvalidOperationException("Target plot line must belong to this project."); } } if (plotEventTypeName == "Split") { var targets = selectedTargetPlotLineIds.Where(id => id > 0).Distinct().ToList(); if (targets.Count < 2) { throw new InvalidOperationException("Choose at least two target plot lines for a split event."); } if (targets.Contains(sourcePlotLineId)) { throw new InvalidOperationException("Split target plot lines cannot include the source plot line."); } if (targets.Any(id => !projectPlotLineIds.Contains(id))) { throw new InvalidOperationException("Split target plot lines must belong to this project."); } } } 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.PlotImportance = ChapterService.ToSelectList(lookupData.PlotImportance, x => x.PlotImportanceID, x => x.ImportanceName); 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 SceneMetricTypeService(IProjectRepository projects, ISceneMetricTypeRepository metricTypes) : ISceneMetricTypeService { public async Task GetAsync(int projectId) { var project = await projects.GetAsync(projectId); if (project is null) { return null; } return new ProjectSceneMetricsViewModel { Project = project, Metrics = await metricTypes.ListForManagementAsync(projectId), NewMetric = new SceneMetricTypeEditViewModel { ProjectID = projectId } }; } public async Task GetEditAsync(int projectId, int metricTypeId) { var metric = await metricTypes.GetAsync(metricTypeId, projectId); return metric is null ? null : new SceneMetricTypeEditViewModel { MetricTypeID = metric.MetricTypeID, ProjectID = projectId, MetricName = metric.MetricName, Description = metric.Description, MinValue = metric.MinValue, MaxValue = metric.MaxValue, DefaultValue = metric.DefaultValue, SortOrder = metric.SortOrder, IsActive = metric.IsActive }; } public Task SaveAsync(SceneMetricTypeEditViewModel model) => metricTypes.SaveAsync(new SceneMetricType { MetricTypeID = model.MetricTypeID, ProjectID = model.ProjectID, MetricName = model.MetricName, Description = model.Description, MinValue = model.MinValue, MaxValue = model.MaxValue, DefaultValue = model.DefaultValue, SortOrder = model.SortOrder, IsActive = model.IsActive }); public Task SetActiveAsync(int projectId, int metricTypeId, bool isActive) => metricTypes.SetActiveAsync(metricTypeId, projectId, isActive); public Task MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction); public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId); public Task RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId); } public sealed class SceneMetricValueService( ISceneRepository scenes, IChapterRepository chapters, IBookRepository books, ISceneMetricTypeRepository metricTypes) : ISceneMetricValueService { public async Task UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model) { var metricTypeId = model.MetricTypeID > 0 ? model.MetricTypeID : model.MetricID; if (model.SceneID <= 0 || metricTypeId <= 0) { throw new InvalidOperationException("Missing scene or metric details."); } var scene = await scenes.GetAsync(model.SceneID); if (scene is null) { throw new InvalidOperationException("Scene could not be found."); } var chapter = await chapters.GetAsync(scene.ChapterID); var book = chapter is null ? null : await books.GetAsync(chapter.BookID); if (book is null) { throw new InvalidOperationException("Scene project could not be found."); } var metric = await metricTypes.GetAsync(metricTypeId, book.ProjectID); if (metric is null || !metric.IsActive || !metric.IsEnabledForProject) { throw new InvalidOperationException("Metric is not available for this project."); } var value = Math.Clamp(model.Value, metric.MinValue, metric.MaxValue); await scenes.SaveMetricValueAsync(model.SceneID, metricTypeId, value); return value; } } 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 RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService { public async Task GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork) { if (projectId <= 0) { projectId = (await projects.ListAsync()).FirstOrDefault()?.ProjectID ?? 0; } var project = await projects.GetAsync(projectId); if (project is null) { return null; } var data = await characters.GetRelationshipMapAsync(projectId); var sceneOrder = BuildSceneOrder(data); var point = ResolveStoryPoint(data, sceneOrder, bookId, chapterId, sceneId); var eventsByRelationship = data.RelationshipEvents .GroupBy(x => x.CharacterRelationshipID) .ToDictionary(x => x.Key, x => x.OrderBy(e => SceneIndex(sceneOrder, e.SceneID)).ThenBy(e => e.CreatedDate).ThenBy(e => e.RelationshipEventID).ToList()); var relationships = data.Relationships .Select(relationship => BuildRelationship(relationship, eventsByRelationship.TryGetValue(relationship.CharacterRelationshipID, out var events) ? events : [], sceneOrder, point.MaxSceneIndex)) .Where(x => x is not null) .Select(x => x!) .OrderBy(x => x.RelationshipCategoryName) .ThenBy(x => x.CharacterAName) .ThenBy(x => x.CharacterBName) .ToList(); if (focusCharacterId.HasValue && !showFullNetwork) { relationships = relationships .Where(x => x.CharacterAID == focusCharacterId || x.CharacterBID == focusCharacterId) .ToList(); } var activeCharacterIds = relationships .SelectMany(x => new[] { x.CharacterAID, x.CharacterBID }) .ToHashSet(); if (focusCharacterId.HasValue) { activeCharacterIds.Add(focusCharacterId.Value); } var nodes = data.Characters .Where(x => activeCharacterIds.Contains(x.CharacterID) || (!focusCharacterId.HasValue && !relationships.Any())) .OrderByDescending(x => x.CharacterID == focusCharacterId) .ThenBy(x => x.CharacterName) .Select(x => new RelationshipMapNodeViewModel { CharacterID = x.CharacterID, CharacterName = x.CharacterName, CharacterImportance = x.CharacterImportance, IsFocus = x.CharacterID == focusCharacterId }) .ToList(); return new RelationshipMapViewModel { Project = project, BookID = point.BookID, ChapterID = point.ChapterID, SceneID = point.SceneID, FocusCharacterID = focusCharacterId, ShowFullNetwork = showFullNetwork, StoryPointLabel = point.Label, BookOptions = data.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == point.BookID)).ToList(), ChapterOptions = data.Chapters.OrderBy(x => BookSort(data, x.BookID)).ThenBy(x => x.SortOrder).Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == point.ChapterID)).ToList(), SceneOptions = data.Scenes.OrderBy(x => SceneIndex(sceneOrder, x.SceneID)).Select(x => new SelectListItem($"Ch {ChapterNumber(data, x.ChapterID)} / Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == point.SceneID)).ToList(), CharacterOptions = data.Characters.OrderBy(x => x.CharacterName).Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == focusCharacterId)).ToList(), Categories = data.RelationshipCategories.OrderBy(x => x.SortOrder).Select(x => new RelationshipMapCategoryViewModel { RelationshipCategoryID = x.RelationshipCategoryID, CategoryName = x.CategoryName, CssClass = RelationshipCategoryCss(x.CategoryName) }).ToList(), Nodes = nodes, Links = relationships.Select(x => new RelationshipMapLinkViewModel { CharacterRelationshipID = x.CharacterRelationshipID, SourceCharacterID = x.CharacterAID, TargetCharacterID = x.CharacterBID, RelationshipLabel = x.RelationshipTypeName, CategoryName = x.RelationshipCategoryName, CssClass = x.CssClass, Intensity = x.CurrentIntensity }).ToList(), Relationships = relationships }; } private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList events, IReadOnlyDictionary sceneOrder, int maxSceneIndex) { var visibleEvents = maxSceneIndex < 0 ? [] : events.Where(x => SceneIndex(sceneOrder, x.SceneID) <= maxSceneIndex).ToList(); var hasInitialState = relationship.IsInitialRelationship || !events.Any(); if (!hasInitialState && visibleEvents.Count == 0) { return null; } var latestEvent = visibleEvents .OrderBy(x => SceneIndex(sceneOrder, x.SceneID)) .ThenBy(x => x.CreatedDate) .ThenBy(x => x.RelationshipEventID) .LastOrDefault(); var initialState = relationship.InitialRelationshipStateName ?? relationship.RelationshipTypeName; var currentState = latestEvent?.RelationshipStateName ?? initialState; var history = new List { new() { Label = "Initial State", StateName = initialState, Intensity = relationship.InitialIntensity, Notes = relationship.Notes } }; history.AddRange(visibleEvents.Select(x => new RelationshipMapHistoryItemViewModel { Label = $"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}", StateName = x.RelationshipStateName, Intensity = x.Intensity, Notes = x.Description, SceneID = x.SceneID })); return new RelationshipMapRelationshipViewModel { CharacterRelationshipID = relationship.CharacterRelationshipID, CharacterAID = relationship.CharacterAID, CharacterAName = relationship.CharacterAName, CharacterBID = relationship.CharacterBID, CharacterBName = relationship.CharacterBName, RelationshipTypeName = relationship.RelationshipTypeName, RelationshipCategoryName = relationship.RelationshipCategoryName, CssClass = RelationshipCategoryCss(relationship.RelationshipCategoryName), InitialStateName = initialState, CurrentStateName = currentState, InitialIntensity = relationship.InitialIntensity, CurrentIntensity = latestEvent?.Intensity ?? relationship.InitialIntensity, IsReciprocal = relationship.IsReciprocal, Notes = latestEvent?.Description ?? relationship.Notes, History = history }; } private static IReadOnlyDictionary BuildSceneOrder(RelationshipMapData data) { var bookSort = data.Books.ToDictionary(x => x.BookID, x => x.SortOrder); var chapterSort = data.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder); return data.Scenes .OrderBy(x => bookSort.TryGetValue(data.Chapters.FirstOrDefault(c => c.ChapterID == x.ChapterID)?.BookID ?? 0, out var b) ? b : 0) .ThenBy(x => chapterSort.TryGetValue(x.ChapterID, out var c) ? c : 0) .ThenBy(x => x.SortOrder) .ThenBy(x => x.SceneNumber) .Select((scene, index) => new { scene.SceneID, Index = index }) .ToDictionary(x => x.SceneID, x => x.Index); } private static (int? BookID, int? ChapterID, int? SceneID, int MaxSceneIndex, string Label) ResolveStoryPoint(RelationshipMapData data, IReadOnlyDictionary sceneOrder, int? bookId, int? chapterId, int? sceneId) { if (sceneId.HasValue && sceneOrder.ContainsKey(sceneId.Value)) { var scene = data.Scenes.First(x => x.SceneID == sceneId.Value); var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == scene.ChapterID); var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID); return (book?.BookID, chapter?.ChapterID, scene.SceneID, sceneOrder[scene.SceneID], $"{book?.BookTitle ?? "Book"} / Ch {chapter?.ChapterNumber} / Scene {scene.SceneNumber}"); } if (chapterId.HasValue) { var chapterScenes = data.Scenes.Where(x => x.ChapterID == chapterId.Value && sceneOrder.ContainsKey(x.SceneID)).ToList(); if (chapterScenes.Count > 0) { var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId.Value); var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID); return (book?.BookID, chapter?.ChapterID, null, chapterScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookTitle ?? "Book"} / Ch {chapter?.ChapterNumber}"); } } if (bookId.HasValue) { var chapterIds = data.Chapters.Where(x => x.BookID == bookId.Value).Select(x => x.ChapterID).ToHashSet(); var bookScenes = data.Scenes.Where(x => chapterIds.Contains(x.ChapterID) && sceneOrder.ContainsKey(x.SceneID)).ToList(); if (bookScenes.Count > 0) { var book = data.Books.FirstOrDefault(x => x.BookID == bookId.Value); return (book?.BookID, null, null, bookScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookTitle ?? "Book"}"); } } return (null, null, null, -1, "Project Start"); } private static int SceneIndex(IReadOnlyDictionary sceneOrder, int sceneId) => sceneOrder.TryGetValue(sceneId, out var index) ? index : int.MaxValue; private static int BookSort(RelationshipMapData data, int bookId) => data.Books.FirstOrDefault(x => x.BookID == bookId)?.SortOrder ?? 0; private static decimal ChapterNumber(RelationshipMapData data, int chapterId) => data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId)?.ChapterNumber ?? 0; private static string RelationshipCategoryCss(string categoryName) => categoryName switch { "Family" => "relationship-category-family", "Friendship" => "relationship-category-friendship", "Romantic / Sexual" => "relationship-category-romantic", "Hostile / Conflict" => "relationship-category-hostile", "Professional / Social" => "relationship-category-professional", _ => "relationship-category-other" }; } public sealed class WriterWorkspaceService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, IWriterWorkspaceRepository writerWorkspace, ICurrentUserService currentUser) : IWriterWorkspaceService { public async Task GetDashboardAsync(int? projectId) { var projectRows = currentUser.UserId.HasValue ? await projects.ListForUserAsync(currentUser.UserId.Value) : Array.Empty(); var projectIds = projectId.HasValue ? projectRows.Where(project => project.ProjectID == projectId.Value).Select(project => project.ProjectID).ToList() : projectRows.Select(project => project.ProjectID).ToList(); return new WriterDashboardViewModel { ProjectID = projectId, ProjectOptions = projectRows.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == projectId)).ToList(), WriteNext = await GetDashboardScenesAsync(projectIds, "WriteNext"), ContinueWriting = await GetDashboardScenesAsync(projectIds, "ContinueWriting"), RevisionQueue = await GetDashboardScenesAsync(projectIds, "RevisionQueue"), PolishingQueue = await GetDashboardScenesAsync(projectIds, "PolishingQueue"), StoryHealthReminders = await GetDashboardRemindersAsync(projectIds) }; } private async Task> GetDashboardScenesAsync(IReadOnlyList projectIds, string queue) { var rows = new List(); foreach (var accessibleProjectId in projectIds) { rows.AddRange(await writerWorkspace.GetWriterDashboardScenesAsync(accessibleProjectId, queue)); } return rows; } private async Task> GetDashboardRemindersAsync(IReadOnlyList projectIds) { var rows = new List(); foreach (var accessibleProjectId in projectIds) { rows.AddRange(await writerWorkspace.GetWriterDashboardRemindersAsync(accessibleProjectId)); } return rows; } 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 async Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model) { var existing = await writerWorkspace.GetWorkflowAsync(model.SceneID); var priority = model.Priority.HasValue ? Math.Clamp(model.Priority.Value, 1, 10) : (int?)null; await writerWorkspace.SaveWorkflowAsync(new SceneWorkflow { SceneID = model.SceneID, DraftStatus = existing.DraftStatus, EstimatedWordCount = model.EstimatedWordCount, ActualWordCount = model.ActualWordCount, DraftedDate = model.DraftedDate, LastWorkedOn = model.LastWorkedOn, ReadyForDraft = existing.ReadyForDraft, ReadyForRevision = existing.ReadyForRevision, ReadyForPolish = existing.ReadyForPolish, IsBlocked = model.IsBlocked, BlockedReason = model.BlockedReason, Priority = 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) { var initial = r.InitialRelationshipName ?? r.RelationshipTypeName; if (!string.IsNullOrWhiteSpace(r.InitialStateName)) initial += $" / {r.InitialStateName}"; if (r.InitialIntensity.HasValue) initial += $" (intensity {r.InitialIntensity})"; var current = r.CurrentStateName ?? "No current state"; if (r.CurrentIntensity.HasValue) current += $" (intensity {r.CurrentIntensity})"; builder.AppendLine($"- {r.CharacterAName} / {r.CharacterBName} [{r.RelationshipCategoryName}]: Initial: {initial}; Current: {current}; Events: {r.EventHistory ?? "No event history"}"); } 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, IProjectActivityService activity, ICurrentUserService currentUser) : 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 = currentUser.UserId.HasValue ? await archives.ListProjectFiltersForUserAsync(currentUser.UserId.Value) : Array.Empty(); 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 = currentUser.UserId.HasValue ? await archives.ListForUserAsync(projectId, normalisedType, currentUser.UserId.Value) : Array.Empty() }; } public async Task ArchiveAsync(string entityType, int entityId, string? reason) { var item = await FindArchivedItemContextAsync(entityType, entityId); await archives.ArchiveAsync(entityType, entityId, reason); if (item?.ProjectID.HasValue == true) { await activity.RecordAsync(item.ProjectID.Value, "Archived", entityType, entityId, item.DisplayName, reason); } } public async Task RestoreAsync(string entityType, int entityId) { var item = await FindArchivedItemContextAsync(entityType, entityId); await archives.RestoreAsync(entityType, entityId); if (item?.ProjectID.HasValue == true) { await activity.RecordAsync(item.ProjectID.Value, "Restored", entityType, entityId, item.DisplayName); } } public async Task DeleteForeverAsync(string entityType, int entityId, string confirmationText) { var item = await FindArchivedItemContextAsync(entityType, entityId); var result = await archives.DeleteForeverAsync(entityType, entityId, confirmationText); if (result.Succeeded && item?.ProjectID.HasValue == true) { await activity.RecordAsync(item.ProjectID.Value, "Deleted", entityType, entityId, item.DisplayName); } return result; } private async Task FindArchivedItemContextAsync(string entityType, int entityId) { if (!currentUser.UserId.HasValue) { return null; } var rows = await archives.ListForUserAsync(null, entityType, currentUser.UserId.Value); return rows.FirstOrDefault(x => x.EntityType == entityType && x.EntityID == entityId); } } public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance, ICurrentUserService currentUser) : IAcceptanceService { public async Task GetPhase1ChecklistAsync(int? projectId) { var projectRows = currentUser.UserId.HasValue ? await projects.ListForUserAsync(currentUser.UserId.Value) : Array.Empty(); 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 StorageService( IBookRepository books, IChapterRepository chapters, ISceneRepository scenes, IProjectCollaborationRepository collaboration, ISubscriptionService subscriptions, IUserFileRepository userFiles) : IStorageService { public async Task GetSceneStorageUsageAsync(int sceneId) { var context = await GetSceneOwnerContextAsync(sceneId); return context is null ? null : await subscriptions.GetStorageUsageAsync(context.Value.OwnerUserID); } public async Task CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes) { var context = await GetSceneOwnerContextAsync(sceneId); return context is null ? null : await subscriptions.CanUploadFileAsync(context.Value.OwnerUserID, fileSizeBytes); } public async Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes) { var context = await GetSceneOwnerContextAsync(sceneId); if (context is null || string.IsNullOrWhiteSpace(storagePath)) { return; } await userFiles.CreateAsync(new UserFile { UserID = context.Value.OwnerUserID, ProjectID = context.Value.ProjectID, EntityType = "SceneAttachment", EntityID = sceneAttachmentId, FileName = Path.GetFileName(storagePath), OriginalFileName = originalFileName, ContentType = contentType, FileSizeBytes = fileSizeBytes, StoragePath = storagePath }); } public Task MarkDeletedByStoragePathAsync(string? storagePath) { return string.IsNullOrWhiteSpace(storagePath) ? Task.CompletedTask : userFiles.MarkDeletedByStoragePathAsync(storagePath); } private async Task<(int ProjectID, int OwnerUserID)?> GetSceneOwnerContextAsync(int sceneId) { var scene = await scenes.GetAsync(sceneId); var chapter = scene is null ? null : await chapters.GetAsync(scene.ChapterID); var book = chapter is null ? null : await books.GetAsync(chapter.BookID); if (book is null) { return null; } var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID); return ownerUserId.HasValue ? (book.ProjectID, ownerUserId.Value) : null; } } public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations, IProjectActivityService activity) : 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 async Task SaveAssetAsync(StoryAssetEditViewModel model) { var isNew = model.StoryAssetID == 0; var assetId = await 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 }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Asset", assetId, model.AssetName); return assetId; } public async Task ArchiveAssetAsync(int storyAssetId) { var asset = await assets.GetAssetAsync(storyAssetId); await assets.ArchiveAssetAsync(storyAssetId); if (asset is not null) { await activity.RecordAsync(asset.ProjectID, "Archived", "Asset", storyAssetId, asset.AssetName); } } 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 }); await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Asset", assetId, model.NewAssetName.Trim()); } 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 assetEventId = await assets.SaveEventAsync(new AssetEvent { StoryAssetID = assetId, SceneID = model.SceneID, AssetEventTypeID = eventType.AssetEventTypeID, FromStateID = model.FromStateID, ToStateID = model.ToStateID, EventTitle = title, EventDescription = model.EventDescription }); var asset = await assets.GetAssetAsync(assetId); if (asset is not null) { await activity.RecordAsync(asset.ProjectID, "Created", "Asset Event", assetEventId, title); } return assetEventId; } 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, IProjectActivityService activity) : 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 async Task SaveAsync(LocationEditViewModel model) { var isNew = model.LocationID == 0; var locationId = await 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 }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); return locationId; } public async Task ArchiveAsync(int locationId) { var location = await locations.GetAsync(locationId); await locations.ArchiveAsync(locationId); if (location is not null) { await activity.RecordAsync(location.ProjectID, "Archived", "Location", locationId, location.LocationName); } } public async Task SaveRelationshipAsync(LocationRelationshipEditViewModel model) { var isNew = model.LocationRelationshipID == 0; var relationshipId = await 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 }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Location relationship"); return relationshipId; } 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, IBookRepository books, ICharacterRepository characters, ISubscriptionService subscriptions, IProjectActivityService activity, ICurrentUserService currentUser) : 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; } var projectCharacters = await characters.ListCharactersAsync(project.ProjectID); var relationshipLookups = await characters.GetLookupsAsync(); var projectBooks = await books.ListByProjectAsync(project.ProjectID); var relationshipContainers = await characters.ListRelationshipsByCharacterAsync(character.CharacterID); var initialRelationships = await characters.ListInitialRelationshipsByCharacterAsync(character.CharacterID); var relationshipEvents = await characters.ListRelationshipEventsByCharacterAsync(character.CharacterID); var eventsByRelationship = relationshipEvents .GroupBy(x => x.CharacterRelationshipID) .ToDictionary(x => x.Key, x => (IReadOnlyList)x.ToList()); 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), InitialRelationships = initialRelationships, CurrentRelationships = relationshipContainers .Select(relationship => { eventsByRelationship.TryGetValue(relationship.CharacterRelationshipID, out var events); events ??= []; return new CurrentRelationshipViewModel { Relationship = relationship, LatestEvent = events.LastOrDefault(), Events = events }; }) .OrderBy(x => x.Relationship.CategorySortOrder == 0 ? 900 : x.Relationship.CategorySortOrder) .ThenBy(x => x.Relationship.RelationshipTypeSortOrder) .ThenBy(x => x.Relationship.CharacterAName) .ThenBy(x => x.Relationship.CharacterBName) .ThenBy(x => x.Relationship.RelationshipTypeName) .ToList(), RelationshipEvents = relationshipEvents, NewInitialRelationship = new InitialRelationshipEditViewModel { ProjectID = project.ProjectID, CharacterID = character.CharacterID, ReturnCharacterID = character.CharacterID, InitialIntensity = 5, ReaderInitiallyKnows = true, IsReciprocal = true }, CharacterOptions = projectCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString())).ToList(), RelationshipTypeOptions = ToRelationshipTypeSelectList(relationshipLookups.RelationshipTypes), RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(), BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString())).ToList() }; } public async Task SaveCharacterAsync(CharacterEditViewModel model) { if (model.CharacterID == 0) { await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId); } var isNew = model.CharacterID == 0; var characterId = await 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 }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName); return characterId; } public async Task ArchiveCharacterAsync(int characterId) { var character = await characters.GetCharacterAsync(characterId); await characters.ArchiveCharacterAsync(characterId); if (character is not null) { await activity.RecordAsync(character.ProjectID, "Archived", "Character", characterId, character.CharacterName); } } public async Task ArchiveRelationshipAsync(int characterRelationshipId) { var relationship = await characters.GetRelationshipAsync(characterRelationshipId); await characters.ArchiveRelationshipAsync(characterRelationshipId); if (relationship is not null) { await activity.RecordAsync(relationship.ProjectID, "Archived", "Relationship", characterRelationshipId, relationship.RelationshipTypeName); } } public async Task SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model) { if (model.RelatedCharacterID == 0) { throw new InvalidOperationException("Choose a related character."); } if (model.RelationshipTypeID == 0) { throw new InvalidOperationException("Choose a relationship type."); } var isNew = model.CharacterRelationshipID == 0; var relationshipId = await characters.SaveRelationshipAsync(new CharacterRelationship { CharacterRelationshipID = model.CharacterRelationshipID, ProjectID = model.ProjectID, CharacterAID = model.CharacterID, CharacterBID = model.RelatedCharacterID, RelationshipTypeID = model.RelationshipTypeID, IsPermanent = true, IsKnownToReader = model.ReaderInitiallyKnows, Notes = model.Notes, IsInitialRelationship = true, InitialBookID = model.InitialBookID, ReaderInitiallyKnows = model.ReaderInitiallyKnows, InitialRelationshipStateID = model.InitialRelationshipStateID, InitialIntensity = model.InitialIntensity, IsReciprocal = model.IsReciprocal }); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Character relationship"); return relationshipId; } 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() }); await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Character", characterId, 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 }); } var relationshipEventId = await characters.SaveRelationshipEventAsync(new RelationshipEvent { CharacterRelationshipID = relationshipId, SceneID = model.SceneID, RelationshipStateID = model.RelationshipStateID, Intensity = model.Intensity, IsKnownToOtherCharacter = model.IsKnownToOtherCharacter, Description = model.Description }); var relationship = await characters.GetRelationshipAsync(relationshipId); if (relationship is not null) { await activity.RecordAsync(relationship.ProjectID, "Created", "Relationship Event", relationshipEventId, relationship.RelationshipTypeName); } return relationshipEventId; } 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 static IReadOnlyList ToRelationshipTypeSelectList(IEnumerable relationshipTypes) { var groups = relationshipTypes .GroupBy(x => string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName) .ToDictionary(x => x.Key, x => new SelectListGroup { Name = x.Key }); return relationshipTypes .OrderBy(x => x.CategorySortOrder == 0 ? 900 : x.CategorySortOrder) .ThenBy(x => x.SortOrder) .ThenBy(x => x.TypeName) .Select(x => new SelectListItem(x.TypeName, x.RelationshipTypeID.ToString()) { Group = groups[string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName] }) .ToList(); } 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(); } }