PlotDirector/PlotLine/Services/CoreServices.cs
2026-06-11 19:41:03 +01:00

4390 lines
199 KiB
C#

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<ProjectIndexViewModel> ListAsync();
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
Task<ProjectEditViewModel> GetCreateAsync();
Task<ProjectEditViewModel?> GetEditAsync(int projectId);
Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model);
Task<int> SaveAsync(ProjectEditViewModel model);
Task<bool> ArchiveAsync(int projectId);
Task<bool> RestoreAsync(int projectId);
}
public interface IProjectCollaborationService
{
Task<ProjectCollaboratorsViewModel?> GetCollaboratorsAsync(int projectId);
Task<ProjectCollaborationResult> AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model);
Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId);
}
public interface IProjectActivityService
{
Task<ProjectActivityViewModel?> 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<ProjectActivityViewModel?> 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<BookDetailViewModel?> GetDetailAsync(int bookId);
Task<BookEditViewModel?> GetCreateAsync(int projectId);
Task<BookEditViewModel?> GetEditAsync(int bookId);
Task<int> SaveAsync(BookEditViewModel model);
Task ArchiveAsync(int bookId);
}
public interface IChapterService
{
Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId);
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
Task<int> SaveAsync(ChapterEditViewModel model);
Task ArchiveAsync(int chapterId);
}
public interface ISceneService
{
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
Task<int> SaveAsync(SceneEditViewModel model);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
Task<SceneMovePreviewViewModel?> GetMovePreviewAsync(SceneMoveRequestViewModel request);
Task<ContinuityValidationSummary?> MoveToChapterAsync(SceneMoveRequestViewModel request);
Task RenumberChapterAsync(int chapterId);
Task<int> AddDependencyAsync(SceneDependencyCreateViewModel model);
Task DeleteDependencyAsync(int sceneDependencyId);
}
public interface IPlotService
{
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
Task<PlotLineEditViewModel?> GetCreatePlotLineAsync(int projectId);
Task<PlotLineEditViewModel?> GetEditPlotLineAsync(int plotLineId);
Task<int> SavePlotLineAsync(PlotLineEditViewModel model);
Task ArchivePlotLineAsync(int plotLineId);
Task<PlotThreadListViewModel?> GetPlotThreadsAsync(int projectId);
Task<PlotThreadEditViewModel?> GetCreatePlotThreadAsync(int projectId, int? plotLineId);
Task<PlotThreadEditViewModel?> GetEditPlotThreadAsync(int plotThreadId);
Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model);
Task ArchivePlotThreadAsync(int plotThreadId);
Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model);
Task DeleteThreadEventAsync(int threadEventId);
}
public interface IAssetService
{
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
Task<StoryAssetDetailViewModel?> GetAssetDetailAsync(int storyAssetId);
Task<int> SaveAssetAsync(StoryAssetEditViewModel model);
Task ArchiveAssetAsync(int storyAssetId);
Task<int> AddAssetEventAsync(AssetEventCreateViewModel model);
Task DeleteAssetEventAsync(int assetEventId);
Task<int> AddCustodyEventAsync(AssetCustodyCreateViewModel model);
Task DeleteCustodyEventAsync(int assetCustodyEventId);
Task<int> SaveDependencyAsync(AssetDependencyEditViewModel model);
Task DeleteDependencyAsync(int assetDependencyId);
Task<IReadOnlyList<AssetDependencyIssue>> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId);
}
public interface ILocationService
{
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
Task<LocationEditViewModel?> GetEditAsync(int locationId);
Task<LocationDetailViewModel?> GetDetailAsync(int locationId);
Task<int> SaveAsync(LocationEditViewModel model);
Task ArchiveAsync(int locationId);
Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model);
Task ArchiveRelationshipAsync(int locationRelationshipId);
Task<int> SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model);
Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId);
}
public interface IContinuityValidationService
{
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional, ContinuityValidationSummary? summary = null);
Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId);
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId);
Task<ContinuityValidationSummary> ValidateAssetAsync(int assetId);
Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId);
Task<IReadOnlyList<ContinuityWarning>> ListSceneWarningsAsync(int sceneId);
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
Task DismissAsync(int warningId);
Task MarkIntentionalAsync(int warningId);
Task RestoreAsync(int warningId);
}
public interface ICharacterService
{
Task<CharacterListViewModel?> GetCharactersAsync(int projectId);
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
Task ArchiveCharacterAsync(int characterId);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<int> SaveInitialRelationshipAsync(InitialRelationshipEditViewModel model);
Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model);
Task<int> UpdateSceneCharacterAsync(SceneCharacterEditViewModel model);
Task DeleteSceneCharacterAsync(int sceneCharacterId);
Task<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model);
Task<int> AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model);
Task DeleteKnowledgeAsync(int characterKnowledgeId);
Task<int> AddRelationshipEventAsync(RelationshipEventCreateViewModel model);
Task DeleteRelationshipEventAsync(int relationshipEventId);
string DisplayAge(Character character, DateTime? sceneStartDateTime = null);
}
public interface ITimelineService
{
Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter);
Task<TimelineSettingsViewModel?> GetSettingsAsync(int projectId);
Task SaveSettingsAsync(TimelineSettingsViewModel model);
Task<int> SavePresetAsync(TimelinePresetSaveViewModel model);
Task<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId);
Task ArchivePresetAsync(int timelineViewPresetId);
Task<ContinuityValidationSummary?> MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model);
Task AddCharacterByDropAsync(TimelineSceneCharacterDropViewModel model);
Task AddAssetEventByDropAsync(TimelineAssetEventDropViewModel model);
Task AddAssetDependencyByDropAsync(TimelineAssetDependencyDropViewModel model);
Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model);
}
public interface ISceneMetricTypeService
{
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
Task<SceneMetricTypeEditViewModel?> GetEditAsync(int projectId, int metricTypeId);
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
public interface ISceneMetricValueService
{
Task<int> UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model);
}
public interface IAnalyticsService
{
Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
}
public interface IStoryBibleService
{
Task<StoryBibleViewModel?> GetAsync(int projectId, string? query);
}
public interface IRelationshipMapService
{
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
}
public interface IWriterWorkspaceService
{
Task<WriterDashboardViewModel> GetDashboardAsync(int? projectId);
Task<ChapterWorkflowViewModel?> GetChapterWorkflowAsync(int chapterId);
Task SaveChapterGoalAsync(ChapterGoalEditViewModel model);
Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model);
Task<int> SaveNoteAsync(SceneNoteEditViewModel model);
Task DeleteNoteAsync(int sceneNoteId);
Task<int> SaveChecklistItemAsync(SceneChecklistItemEditViewModel model);
Task DeleteChecklistItemAsync(int sceneChecklistItemId);
Task<int> SaveAttachmentAsync(SceneAttachmentEditViewModel model);
Task DeleteAttachmentAsync(int sceneAttachmentId);
}
public interface IStorageService
{
Task<StorageUsage?> GetSceneStorageUsageAsync(int sceneId);
Task<StorageLimitResult?> 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<CharacterArcViewModel?> GetCharacterArcAsync(int characterId);
Task<RelationshipEvolutionViewModel?> GetRelationshipEvolutionAsync(int characterRelationshipId);
Task<KnowledgeTimelineViewModel?> GetKnowledgeTimelineAsync(int characterId);
Task<StoryDynamicsViewModel?> GetStoryDynamicsAsync(int projectId);
Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId);
}
public interface IScenarioService
{
Task<ScenarioListViewModel?> ListAsync(int projectId);
Task<ScenarioCreateViewModel?> GetCreateAsync(int projectId, int? bookId);
Task<int> CreateAsync(ScenarioCreateViewModel model);
Task<ScenarioTimelineViewModel?> GetTimelineAsync(int scenarioId);
Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position);
Task<int> ValidateAsync(int scenarioId);
Task<ScenarioCompareViewModel?> CompareAsync(int scenarioId);
Task ApplyAsync(int scenarioId);
Task ArchiveAsync(int scenarioId);
}
public interface IArchiveService
{
Task<ArchivedItemsViewModel> GetArchivedItemsAsync(int? projectId, string? entityType);
Task ArchiveAsync(string entityType, int entityId, string? reason);
Task RestoreAsync(string entityType, int entityId);
Task<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText);
}
public interface IAcceptanceService
{
Task<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId);
}
public sealed class ProjectService(IProjectRepository projects, IBookRepository books, IProjectActivityService activity, ICurrentUserService currentUser) : IProjectService
{
public async Task<ProjectIndexViewModel> 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<ProjectDetailViewModel?> 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 async Task<ProjectEditViewModel> GetCreateAsync()
{
var model = new ProjectEditViewModel();
await PopulateGenrePresetOptionsAsync(model);
return model;
}
public async Task<ProjectEditViewModel?> 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<int> 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<bool> 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<bool> 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 sealed class ProjectCollaborationService(
IProjectRepository projects,
IProjectCollaborationRepository collaboration,
IUserRepository users,
IProjectAccessService access,
ISubscriptionService subscriptions,
IEmailQueueRepository emailQueue,
IProjectActivityService activity,
ICurrentUserService currentUser,
IOptions<EmailSettings> emailOptions,
ILogger<ProjectCollaborationService> logger) : IProjectCollaborationService
{
private readonly EmailSettings _emailSettings = emailOptions.Value;
public async Task<ProjectCollaboratorsViewModel?> 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<ProjectCollaborationResult> 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<ProjectCollaborationResult> 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 = $"""
<!doctype html>
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotDirector</p>
<h1 style="margin:0 0 16px;font-size:24px;">You have been added as a collaborator</h1>
<p>Hello {WebUtility.HtmlEncode(CleanDisplayName(user.DisplayName))},</p>
<p>{WebUtility.HtmlEncode(inviterName)} added you as a collaborator on <strong>{WebUtility.HtmlEncode(projectName)}</strong>.</p>
<p>Sign in to PlotDirector to open the shared project.</p>
</div>
</body>
</html>
""";
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<int, Task<SubscriptionLimitResult>> 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<BookDetailViewModel?> 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<BookEditViewModel?> 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<BookEditViewModel?> 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<int> 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<ChapterDetailViewModel?> 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<ChapterEditViewModel?> 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<ChapterEditViewModel?> 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<int> 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<SelectListItem> ToSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> 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<SceneEditViewModel?> 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<SceneEditViewModel?> 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<int> 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<SceneEditViewModel> 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.DraftStatusOptions = DraftStatuses()
.Select(x => new SelectListItem(x, x, string.Equals(x, model.Workflow.DraftStatus, StringComparison.OrdinalIgnoreCase)))
.ToList();
model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}");
model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName);
model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName);
model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName);
model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName);
model.AssetEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName);
model.AssetCustodyEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetCustodyEventTypes, x => x.AssetCustodyEventTypeID, x => x.TypeName);
model.CustodyRoleOptions = ChapterService.ToSelectList(assetLookups.CustodyRoles, x => x.CustodyRoleID, x => x.RoleName);
model.CharacterOptions = ToOptionalSelectList(projectCharacters, x => x.CharacterID, x => x.CharacterName);
model.CharacterRoleOptions = ToOptionalSelectList(characterLookups.RoleTypes, x => x.CharacterRoleInSceneTypeID, x => x.TypeName);
model.PresenceTypeOptions = ToOptionalSelectList(characterLookups.PresenceTypes, x => x.PresenceTypeID, x => x.TypeName);
model.CharacterAttributeTypeOptions = ChapterService.ToSelectList(characterLookups.AttributeTypes, x => x.CharacterAttributeTypeID, x => x.AttributeName);
model.KnowledgeStateOptions = ChapterService.ToSelectList(characterLookups.KnowledgeStates, x => x.KnowledgeStateID, x => x.StateName);
model.RelationshipOptions = ToOptionalSelectList(projectRelationships, x => x.CharacterRelationshipID, x => $"{x.CharacterAName} -> {x.CharacterBName} ({x.RelationshipTypeName})");
model.RelationshipTypeOptions = 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,
EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID
?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0
};
model.NewAssetEvent = new AssetEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetEventTypeID = assetLookups.AssetEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.AssetEventTypeID
?? assetLookups.AssetEventTypes.FirstOrDefault()?.AssetEventTypeID
?? 0,
NewAssetKindID = assetLookups.AssetKinds.FirstOrDefault(x => x.KindName == "Other")?.AssetKindID
};
model.NewAssetCustodyEvent = new AssetCustodyCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetCustodyEventTypeID = assetLookups.AssetCustodyEventTypes.FirstOrDefault(x => x.TypeName == "Has")?.AssetCustodyEventTypeID
?? assetLookups.AssetCustodyEventTypes.FirstOrDefault()?.AssetCustodyEventTypeID
?? 0,
CustodyRoleID = assetLookups.CustodyRoles.FirstOrDefault(x => x.RoleName == "Has Custody")?.CustodyRoleID
?? assetLookups.CustodyRoles.FirstOrDefault()?.CustodyRoleID
?? 0
};
model.NewSceneAssetLocation = new SceneAssetLocationCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
UpdateCurrentLocation = true
};
model.NewSceneCharacter = new SceneCharacterCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RoleInSceneTypeID = characterLookups.RoleTypes.FirstOrDefault(x => x.TypeName == "Main Participant")?.CharacterRoleInSceneTypeID,
PresenceTypeID = characterLookups.PresenceTypes.FirstOrDefault(x => x.TypeName == "Present")?.PresenceTypeID
};
model.NewCharacterAttributeEvent = new CharacterAttributeEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
CharacterAttributeTypeID = characterLookups.AttributeTypes.FirstOrDefault()?.CharacterAttributeTypeID ?? 0
};
model.NewCharacterKnowledge = new CharacterKnowledgeCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
KnowledgeStateID = characterLookups.KnowledgeStates.FirstOrDefault(x => x.StateName == "Knows")?.KnowledgeStateID
?? characterLookups.KnowledgeStates.FirstOrDefault()?.KnowledgeStateID
?? 0
};
model.NewRelationshipEvent = new RelationshipEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RelationshipStateID = characterLookups.RelationshipStates.FirstOrDefault(x => x.StateName == "Unknown")?.RelationshipStateID
?? characterLookups.RelationshipStates.FirstOrDefault()?.RelationshipStateID
?? 0
};
model.NewDependency = new SceneDependencyCreateViewModel
{
ProjectID = model.Project.ProjectID,
CurrentSceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneDependencyTypeID = dependencyLookups.DependencyTypes.FirstOrDefault(x => x.TypeName == "Must Occur Before")?.SceneDependencyTypeID
?? dependencyLookups.DependencyTypes.FirstOrDefault()?.SceneDependencyTypeID
?? 0
};
model.NewNote = new SceneNoteEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneNoteTypeID = noteTypes.FirstOrDefault(x => x.TypeName == "Drafting Note")?.SceneNoteTypeID
?? noteTypes.FirstOrDefault()?.SceneNoteTypeID
?? 0
};
model.NewChecklistItem = new SceneChecklistItemEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.NewAttachment = new SceneAttachmentEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneAttachmentTypeID = attachmentTypes.FirstOrDefault(x => x.TypeName == "Link")?.SceneAttachmentTypeID
?? attachmentTypes.FirstOrDefault()?.SceneAttachmentTypeID
?? 0
};
model.MoveRequest = new SceneMoveRequestViewModel
{
SceneID = model.SceneID,
TargetChapterID = model.ChapterID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
internal static IReadOnlyList<string> DraftStatuses() =>
[
"Planned",
"Ready To Draft",
"Drafting",
"Drafted",
"Revising",
"Polishing",
"Complete",
"On Hold"
];
private static IReadOnlyList<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
private static IReadOnlyList<SelectListItem> ToRelationshipTypeSelectList(IEnumerable<RelationshipType> 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<SceneMovePreviewViewModel?> 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<ContinuityValidationSummary?> 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<int> 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<string> BuildLocationConsistencyMessages(SceneEditViewModel model)
{
if (model.SceneID == 0)
{
return [];
}
var messages = new List<string>();
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<TimelineService> logger) : ITimelineService
{
public async Task<TimelineViewModel?> 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<int, int>();
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<StoryAsset> Assets, IReadOnlyList<AssetEvent> Events) assetTimeline = needsAssetTimelineData
? await TimeTimelineLoadAsync("dbo.AssetTimeline_GetByProject", () => assets.GetTimelineAsync(projectId, bookId), projectId, bookId)
: ([], []);
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> 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<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId) => presets.GetAsync(timelineViewPresetId);
public Task ArchivePresetAsync(int timelineViewPresetId) => presets.ArchiveAsync(timelineViewPresetId);
public async Task<TimelineSettingsViewModel?> 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<T> TimeTimelineLoadAsync<T>(string operationName, Func<Task<T>> 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<ContinuityValidationSummary?> 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<int> 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<IReadOnlySet<int>> BuildMatchingSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
var matches = orderedScenes.Select(x => x.SceneID).ToHashSet();
void Intersect(IEnumerable<int> 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<IReadOnlySet<int>> BuildFocusSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue)
{
return new HashSet<int>();
}
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<int>()
};
}
private static string? BuildFocusLabel(TimelineFilterViewModel filter, IReadOnlyList<PlotLineItem> plotLines, IReadOnlyList<PlotThread> plotThreads, IReadOnlyList<StoryAsset> assets, IReadOnlyList<Character> characters, IReadOnlyList<LocationItem> 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<MetricGraphViewModel> BuildMetricGraphs(TimelineData timeline, IReadOnlyList<Scene> 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<PlotLineLaneViewModel> BuildPlotLanes(TimelineData timeline, IReadOnlyList<Scene> 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<AssetLaneViewModel> BuildAssetLanes(IReadOnlyList<StoryAsset> assets, IReadOnlyList<AssetEvent> events, IReadOnlyList<Scene> 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<CharacterLaneViewModel> BuildCharacterLanes(IReadOnlyList<Character> characters, IReadOnlyList<SceneCharacter> appearances, IReadOnlyList<Scene> 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<SceneCharacter> appearances)
{
var description = character.DefaultDescription ?? string.Empty;
var appearanceList = appearances as IReadOnlyCollection<SceneCharacter> ?? 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<PlotLineListViewModel?> 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<PlotLineEditViewModel?> 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<PlotLineEditViewModel?> 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<int> 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<PlotThreadListViewModel?> 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<PlotThreadEditViewModel?> 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<PlotThreadEditViewModel?> 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<int> 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<int> AddThreadEventAsync(ThreadEventCreateViewModel model)
{
var plotThreadId = model.PlotThreadID.GetValueOrDefault();
var lookupData = await plots.GetLookupsAsync();
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? lookupData.ThreadEventTypes.First();
var title = string.IsNullOrWhiteSpace(model.EventTitle)
? eventType.TypeName
: model.EventTitle.Trim();
if (plotThreadId == 0)
{
if (!model.PlotLineID.HasValue)
{
throw new InvalidOperationException("Choose a plot line or an existing plot thread.");
}
var plannedStatusId = lookupData.ThreadStatuses.First(x => x.StatusName == "Planned").ThreadStatusID;
var threadTypeId = model.NewThreadTypeID
?? GetDefaultThreadTypeId(lookupData, eventType.TypeName);
var threadTitle = string.IsNullOrWhiteSpace(model.NewThreadTitle)
? title
: model.NewThreadTitle.Trim();
plotThreadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = model.PlotLineID.Value,
ThreadTitle = threadTitle,
ThreadTypeID = threadTypeId,
ThreadStatusID = plannedStatusId,
Importance = 5
});
}
var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = plotThreadId,
SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID,
PlotEventTypeID = lookupData.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID ?? 2,
EventTitle = title,
EventDescription = model.EventDescription
});
var plotThread = await plots.GetPlotThreadAsync(plotThreadId);
if (plotThread is not null)
{
await activity.RecordAsync(plotThread.ProjectID, "Created", "Thread Event", threadEventId, title);
}
return threadEventId;
}
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<PlotLineEditViewModel> PopulatePlotLineListsAsync(PlotLineEditViewModel model, PlotLookupData lookupData, IEnumerable<PlotLineItem> parentCandidates)
{
model.PlotLineTypes = ChapterService.ToSelectList(lookupData.PlotLineTypes, x => x.PlotLineTypeID, x => x.TypeName);
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<PlotThreadEditViewModel> 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<WarningDashboardViewModel?> 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<ContinuityValidationSummary> 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<ContinuityValidationSummary> 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<ContinuityValidationSummary> 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<ContinuityValidationSummary> 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<ContinuityValidationSummary> ValidateAssetAsync(int assetId)
{
var asset = await assets.GetAssetAsync(assetId);
return asset is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(asset.ProjectID);
}
public async Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
return character is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(character.ProjectID);
}
public Task<IReadOnlyList<ContinuityWarning>> ListSceneWarningsAsync(int sceneId) => warnings.ListBySceneAsync(sceneId);
public Task<IReadOnlyList<SceneWarningCount>> 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<ProjectSceneMetricsViewModel?> 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<SceneMetricTypeEditViewModel?> 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<int> 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<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive) => metricTypes.SetActiveAsync(metricTypeId, projectId, isActive);
public Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
public Task<bool> 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<int> 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<AnalyticsViewModel?> 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<AnalyticsReviewNote> BuildPacingNotes(IReadOnlyList<AnalyticsMetricPoint> points)
{
var notes = new List<AnalyticsReviewNote>();
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<AnalyticsReviewNote> notes, IReadOnlyList<AnalyticsMetricPoint> points, bool high)
{
var threshold = high ? 8 : 2;
var minRun = high ? 6 : 8;
var run = new List<AnalyticsMetricPoint>();
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<AnalyticsReviewNote> notes, IReadOnlyList<AnalyticsMetricPoint> 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<StoryBibleViewModel?> 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<RelationshipMapViewModel?> 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<RelationshipEvent> events, IReadOnlyDictionary<int, int> 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<RelationshipMapHistoryItemViewModel>
{
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<int, int> 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<int, int> 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<int, int> 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<WriterDashboardViewModel> GetDashboardAsync(int? projectId)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: Array.Empty<Project>();
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"),
NeedsAttention = await GetDashboardScenesAsync(projectIds, "NeedsAttention"),
RevisionQueue = await GetDashboardScenesAsync(projectIds, "RevisionQueue"),
PolishingQueue = await GetDashboardScenesAsync(projectIds, "PolishingQueue"),
StoryHealthReminders = await GetDashboardRemindersAsync(projectIds)
};
}
private async Task<IReadOnlyList<WriterDashboardScene>> GetDashboardScenesAsync(IReadOnlyList<int> projectIds, string queue)
{
var rows = new List<WriterDashboardScene>();
foreach (var accessibleProjectId in projectIds)
{
rows.AddRange(await writerWorkspace.GetWriterDashboardScenesAsync(accessibleProjectId, queue));
}
return rows;
}
private async Task<IReadOnlyList<StoryBibleAttentionItem>> GetDashboardRemindersAsync(IReadOnlyList<int> projectIds)
{
var rows = new List<StoryBibleAttentionItem>();
foreach (var accessibleProjectId in projectIds)
{
rows.AddRange(await writerWorkspace.GetWriterDashboardRemindersAsync(accessibleProjectId));
}
return rows;
}
public async Task<ChapterWorkflowViewModel?> GetChapterWorkflowAsync(int chapterId)
{
var chapter = await chapters.GetAsync(chapterId);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
var project = book is null ? null : await projects.GetAsync(book.ProjectID);
if (chapter is null || book is null || project is null)
{
return null;
}
return new ChapterWorkflowViewModel
{
Project = project,
Book = book,
Chapter = chapter,
Goal = await writerWorkspace.GetChapterGoalAsync(chapterId),
Scenes = await writerWorkspace.GetChapterWorkflowAsync(chapterId)
};
}
public Task SaveChapterGoalAsync(ChapterGoalEditViewModel model) => writerWorkspace.SaveChapterGoalAsync(new ChapterGoal
{
ChapterID = model.ChapterID,
GoalSummary = model.GoalSummary,
EmotionalGoal = model.EmotionalGoal,
ReaderTakeaway = model.ReaderTakeaway,
MustInclude = model.MustInclude,
RisksOrConcerns = model.RisksOrConcerns
});
public Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model) => writerWorkspace.SaveWorkflowAsync(new SceneWorkflow
{
SceneID = model.SceneID,
DraftStatus = model.DraftStatus,
EstimatedWordCount = model.EstimatedWordCount,
ActualWordCount = model.ActualWordCount,
DraftedDate = model.DraftedDate,
LastWorkedOn = model.LastWorkedOn,
ReadyForDraft = model.ReadyForDraft,
ReadyForRevision = model.ReadyForRevision,
ReadyForPolish = model.ReadyForPolish,
IsBlocked = model.IsBlocked,
BlockedReason = model.BlockedReason,
Priority = model.Priority
});
public Task<int> 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<int> 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<int> 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 $"<h3>{text[4..]}</h3>";
if (text.StartsWith("## ")) return $"<h2>{text[3..]}</h2>";
if (text.StartsWith("# ")) return $"<h1>{text[2..]}</h1>";
if (text.StartsWith("- ")) return $"<li>{text[2..]}</li>";
return string.IsNullOrWhiteSpace(text) ? "" : $"<p>{text}</p>";
});
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{WebUtility.HtmlEncode(packet.Title)}}</title>
<style>
body { font-family: Georgia, 'Times New Roman', serif; line-height: 1.55; max-width: 920px; margin: 32px auto; padding: 0 24px; color: #24201a; }
h1, h2, h3 { font-family: Arial, sans-serif; line-height: 1.2; }
h1 { border-bottom: 2px solid #d8d0c2; padding-bottom: 12px; }
h2 { margin-top: 32px; border-bottom: 1px solid #e6dfd2; padding-bottom: 6px; }
li { margin: 5px 0; }
@media print { body { margin: 0; max-width: none; } a { color: inherit; } }
</style>
</head>
<body>
{{string.Join(Environment.NewLine, body)}}
</body>
</html>
""";
}
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<StoryBibleTimelineSummary> 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<StoryBibleCharacterSummary> 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<StoryBibleThreadSummary> 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<StoryBibleAssetSummary> 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<StoryBibleRelationshipSummary> 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<StoryBibleLocationSummary> 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<ExportBriefItem> 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<ExportResearchItem> 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<StoryBibleSearchResult> 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<StoryBibleAttentionItem> 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<StoryBibleRevisionItem> 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<CharacterArcViewModel?> 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<RelationshipEvolutionViewModel?> 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<KnowledgeTimelineViewModel?> 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<StoryDynamicsViewModel?> 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<ScenarioListViewModel?> 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<ScenarioCreateViewModel?> 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<int> CreateAsync(ScenarioCreateViewModel model) =>
scenarios.CreateAsync(model.ProjectID, model.BookID, model.ScenarioName, model.Description);
public async Task<ScenarioTimelineViewModel?> 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<int> ValidateAsync(int scenarioId) => scenarios.ValidateAsync(scenarioId);
public async Task<ScenarioCompareViewModel?> 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<ArchivedItemsViewModel> 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<Project>();
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<ArchivedItem>()
};
}
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<ArchiveDeleteResult> 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<ArchivedItem?> 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<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: Array.Empty<Project>();
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<StorageUsage?> GetSceneStorageUsageAsync(int sceneId)
{
var context = await GetSceneOwnerContextAsync(sceneId);
return context is null ? null : await subscriptions.GetStorageUsageAsync(context.Value.OwnerUserID);
}
public async Task<StorageLimitResult?> 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<StoryAssetListViewModel?> 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<StoryAssetEditViewModel?> 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<StoryAssetEditViewModel?> 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<StoryAssetDetailViewModel?> 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<int> 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<int> 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<int> 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<int> 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<IReadOnlyList<AssetDependencyIssue>> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId)
=> assets.CheckDependencyIssuesAsync(storyAssetId, sceneId, toStateId);
private async Task<StoryAssetEditViewModel> 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<SelectListItem> OptionalStateList(IEnumerable<AssetState> states)
=> states.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList();
private static IReadOnlyList<SelectListItem> ToOptionalLocationList(IEnumerable<LocationItem> 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<LocationListViewModel?> 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<LocationEditViewModel?> 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<LocationEditViewModel?> 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<LocationDetailViewModel?> 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<int> 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<int> 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<int> 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<LocationEditViewModel> 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<CharacterListViewModel?> 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<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new CharacterEditViewModel { ProjectID = projectId, Project = project };
}
public async Task<CharacterEditViewModel?> 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<CharacterDetailViewModel?> 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<RelationshipEvent>)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<int> 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<int> 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<int> 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<int> 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<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model) => characters.SaveAttributeEventAsync(new CharacterAttributeEvent
{
CharacterID = model.CharacterID,
CharacterAttributeTypeID = model.CharacterAttributeTypeID,
SceneID = model.SceneID,
AttributeValue = model.AttributeValue,
Description = model.Description
});
public Task<int> 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<int> 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<SelectListItem> ToRelationshipTypeSelectList(IEnumerable<RelationshipType> 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<IReadOnlyList<SceneCharacter>> GetAppearancesByCharacterAsync(int characterId)
{
var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null);
return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList();
}
}