PlotDirector/PlotLine/Services/CoreServices.cs

9461 lines
418 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
internal static class AliasInput
{
public static IReadOnlyList<string> Clean(IEnumerable<string>? aliases) =>
(aliases ?? [])
.Select(alias => (alias ?? string.Empty).Trim())
.Where(alias => alias.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(50)
.ToList();
}
public interface IProjectService
{
Task<ProjectIndexViewModel> ListAsync();
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
Task<Project?> GetArchivedOwnerProjectAsync(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);
Task<ProjectHardDeleteResult> HardDeleteAsync(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 EntityImageUploadException(string message, int entityId) : InvalidOperationException(message)
{
public int EntityId { get; } = entityId;
}
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 PopulateEditContextAsync(BookEditViewModel model);
Task<BookSaveResult> SaveAsync(BookEditViewModel model);
Task ArchiveAsync(int bookId);
}
public sealed record BookSaveResult(int BookID, bool Succeeded, string? ErrorMessage = null);
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<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null);
Task SaveOccupancyAsync(SceneOccupancySaveViewModel 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 IStoryStateService
{
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter);
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter);
Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
Task<bool> IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId);
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
}
public interface IPlotService
{
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
Task<PlotLineEditViewModel?> GetCreatePlotLineAsync(int projectId);
Task<PlotLineEditViewModel?> GetEditPlotLineAsync(int plotLineId);
Task<PlotLineEditViewModel?> PopulatePlotLineFormAsync(PlotLineEditViewModel model);
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 UpdateThreadEventAsync(ThreadEventEditViewModel 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 IFloorPlanService
{
Task<FloorPlanListViewModel?> ListAsync(int projectId);
Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId);
Task<int> SavePlanAsync(FloorPlanEditViewModel model);
Task DeletePlanAsync(int floorPlanId);
Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model);
Task DeleteFloorAsync(int floorPlanFloorId);
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
Task<FloorPlanBlockEditViewModel?> GetBlockAsync(int floorPlanBlockId);
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload);
Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model);
Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId);
Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId);
Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model);
Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId);
Task DeleteBlockAsync(int floorPlanBlockId);
}
public interface IContinuityValidationService
{
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, string? category, bool includeDismissed, bool includeIntentional, bool showAcknowledgedWarnings, 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);
Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
}
public interface ICharacterService
{
Task<CharacterListViewModel?> GetCharactersAsync(int projectId);
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
Task RemoveCharacterAvatarAsync(int characterId);
Task DeleteCharacterImageAsync(int characterImageId);
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 sealed record CharacterSaveResult(int CharacterID, int? UploadedCharacterImageID = 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 IWritingScheduleService
{
Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId);
Task<WritingPlanViewModel?> GetWritingPlanByIDAsync(int writingPlanId);
Task<int> CreateWritingPlanAsync(WritingPlanViewModel model);
Task UpdateWritingPlanAsync(WritingPlanViewModel model);
Task DeleteWritingPlanAsync(int writingPlanId);
Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive);
Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByPlanAsync(int writingPlanId);
Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate);
Task<int> CreateScheduleItemAsync(WritingScheduleItemViewModel model);
Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model);
Task DeleteScheduleItemAsync(int writingScheduleItemId);
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId);
Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId);
Task DeleteScheduleAsync(int writingPlanId);
Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync();
Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId);
Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model);
Task ActivateWritingPlanAsync(int writingPlanId);
Task DeactivateWritingPlanAsync(int writingPlanId);
Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId);
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync(int? projectId = null);
Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId);
Task<bool> SkipScheduleItemAsync(int writingScheduleItemId);
Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int? projectId = null);
Task<WritingScheduleRebalancePreviewViewModel> PreviewRebalanceAsync(int writingPlanId);
Task<WritingScheduleRebalancePreviewViewModel> ApplyRebalanceAsync(int writingPlanId);
}
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,
IProjectActivityRepository projectActivity,
IWarningRepository warnings,
IWritingScheduleRepository writingSchedule,
IProjectActivityService activity,
ISubscriptionService subscriptions,
ICurrentUserService currentUser,
IHardDeleteFileCleanupService fileCleanup,
ILogger<ProjectService> logger) : IProjectService
{
public async Task<ProjectIndexViewModel> ListAsync()
{
if (!currentUser.UserId.HasValue)
{
return new ProjectIndexViewModel();
}
var activeProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value);
var archivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value);
var projectBooks = await books.ListByProjectsAsync(activeProjects.Concat(archivedProjects).Select(project => project.ProjectID));
var booksByProject = projectBooks
.GroupBy(book => book.ProjectID)
.ToDictionary(group => group.Key, group => group.ToList());
return new ProjectIndexViewModel
{
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow)
};
}
private static ProjectIndexCardViewModel ToIndexCard(Project project, IReadOnlyDictionary<int, List<Book>> booksByProject)
{
booksByProject.TryGetValue(project.ProjectID, out var projectBooks);
projectBooks ??= [];
return new ProjectIndexCardViewModel
{
ProjectID = project.ProjectID,
ProjectName = project.ProjectName,
Description = project.Description,
CreatedDate = project.CreatedDate,
UpdatedDate = project.UpdatedDate,
IsArchived = project.IsArchived,
ArchivedDate = project.ArchivedDate,
AccessRole = project.AccessRole,
BookCount = projectBooks.Count,
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
LastBookUpdatedDate = projectBooks.Count == 0 ? null : projectBooks.Max(book => book.UpdatedDate),
CoverThumbnailPaths = projectBooks
.Where(book => !string.IsNullOrWhiteSpace(book.CoverThumbnailPath))
.OrderBy(book => book.SortOrder)
.ThenBy(book => book.BookNumber)
.ThenBy(book => book.BookTitle)
.Select(book => book.CoverThumbnailPath!)
.Take(3)
.ToList()
};
}
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
{
if (!currentUser.UserId.HasValue)
{
return null;
}
var userId = currentUser.UserId.Value;
var project = await projects.GetForUserAsync(projectId, userId);
if (project is null)
{
return null;
}
var projectBooks = await books.ListByProjectAsync(projectId);
var metrics = await projects.GetDashboardMetricsAsync(projectId);
var recentActivity = await projectActivity.ListRecentForProjectAsync(projectId, 5);
var dashboardWarnings = await warnings.ListDashboardUnresolvedAsync(projectId, 5);
var attentionThreads = await projects.ListDashboardAttentionThreadsAsync(projectId, 5);
var writingPlans = await writingSchedule.GetWritingPlanSummariesByUserAsync(userId, projectId);
return new ProjectDetailViewModel
{
Project = project,
Summary = new ProjectDashboardSummaryViewModel
{
TotalBooks = projectBooks.Count,
PublishedBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)),
DraftingBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase)),
PlanningBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase)),
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
CharacterCount = metrics.CharacterCount,
LocationCount = metrics.LocationCount,
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
ContinuityWarningCount = metrics.ContinuityWarningCount
},
SeriesProgress = BuildSeriesProgress(projectBooks),
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
NeedsAttention = new ProjectDashboardAttentionViewModel
{
Warnings = dashboardWarnings.Select(ToDashboardWarningItem).ToList(),
Threads = attentionThreads.Select(ToDashboardThreadItem).ToList(),
OverdueBooks = projectBooks
.Where(IsOverdueDashboardBook)
.OrderBy(book => book.TargetCompletionDate)
.ThenBy(book => book.BookNumber)
.Take(5)
.Select(book => new ProjectDashboardOverdueBookViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
TargetCompletionDate = book.TargetCompletionDate!.Value
})
.ToList(),
BookItems = BuildBookAttentionItems(projectBooks),
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
},
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
{
BookID = book.BookID,
BookNumber = book.BookNumber,
BookTitle = book.BookTitle,
Subtitle = book.Subtitle,
Status = book.Status,
StatusCssClass = DashboardStatusCssClass(book.Status),
CurrentWordCount = book.CurrentWordCount,
TargetWordCount = book.TargetWordCount,
ProgressPercentage = book.ProgressPercentage,
ChapterCount = book.ChapterCount,
SceneCount = book.SceneCount,
UpdatedDate = book.UpdatedDate,
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath
}).ToList()
};
}
private static ProjectDashboardSeriesProgressViewModel BuildSeriesProgress(IReadOnlyList<Book> books)
{
var published = books.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase));
var drafting = books.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase));
var planning = books.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase));
var targetWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.TargetWordCount!.Value);
if (targetWordTotal > 0)
{
var currentWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.CurrentWordCount);
return new ProjectDashboardSeriesProgressViewModel
{
Percentage = Math.Min(100m, Math.Round(currentWordTotal * 100m / targetWordTotal, 1)),
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
CalculationLabel = "Based on target word counts"
};
}
// Fallback keeps projects without target word counts feeling active without inventing new story logic.
var statusProgress = books.Count == 0
? 0m
: books.Average(book => DashboardStatusProgressWeight(book.Status));
return new ProjectDashboardSeriesProgressViewModel
{
Percentage = Math.Round(statusProgress, 1),
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
CalculationLabel = "Based on book statuses"
};
}
private static IReadOnlyList<ProjectDashboardBookAttentionItemViewModel> BuildBookAttentionItems(IReadOnlyList<Book> books)
{
return books
.Where(book => !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase))
.SelectMany(book => DashboardBookAttentionItems(book))
.Take(5)
.ToList();
}
private static IEnumerable<ProjectDashboardBookAttentionItemViewModel> DashboardBookAttentionItems(Book book)
{
if (book.SceneCount == 0)
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = "No scenes have been added yet."
};
}
if (book.TargetWordCount is null or <= 0)
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = "No target word count is set."
};
}
if (book.UpdatedDate.Date <= DateTime.Today.AddDays(-90))
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = $"Not modified since {book.UpdatedDate:dd MMM yyyy}."
};
}
}
private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
{
return new ProjectDashboardActivityItemViewModel
{
ActivityType = item.ActivityType,
EntityType = item.EntityType,
EntityName = item.EntityName,
Description = item.Description,
UserLabel = item.UserLabel,
TimeLabel = RelativeTimeLabel(item.CreatedDateUTC),
IconLabel = DashboardIconLabel(item.EntityType, item.ActivityType)
};
}
private static ProjectDashboardWarningItemViewModel ToDashboardWarningItem(ContinuityWarning warning)
{
return new ProjectDashboardWarningItemViewModel
{
SeverityName = warning.SeverityName,
WarningTypeName = warning.WarningTypeName,
Message = warning.Message,
LocationLabel = DashboardWarningLocation(warning)
};
}
private static ProjectDashboardThreadItemViewModel ToDashboardThreadItem(ProjectDashboardAttentionThread thread)
{
return new ProjectDashboardThreadItemViewModel
{
PlotThreadID = thread.PlotThreadID,
ThreadTitle = thread.ThreadTitle,
PlotLineName = thread.PlotLineName,
ThreadStatusName = thread.ThreadStatusName,
Importance = thread.Importance,
AttentionLabel = string.Equals(thread.ThreadStatusName, "Dormant", StringComparison.OrdinalIgnoreCase)
? "Dormant"
: "High importance open"
};
}
private static bool IsOverdueDashboardBook(Book book)
{
return book.TargetCompletionDate.HasValue
&& book.TargetCompletionDate.Value.Date < DateTime.Today
&& !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase);
}
private static decimal DashboardStatusProgressWeight(string status)
{
return status switch
{
BookStatuses.Published or BookStatuses.ReadyForPublication => 100m,
BookStatuses.Proofreading => 90m,
BookStatuses.Editing => 80m,
BookStatuses.Revising => 70m,
BookStatuses.Drafting => 45m,
BookStatuses.Paused => 35m,
BookStatuses.Outlining => 20m,
BookStatuses.Research => 15m,
BookStatuses.Planning => 10m,
_ => 0m
};
}
private static string DashboardStatusCssClass(string status)
{
return status switch
{
BookStatuses.Published => "dashboard-status-published",
BookStatuses.Drafting => "dashboard-status-drafting",
BookStatuses.Planning => "dashboard-status-planning",
BookStatuses.Research => "dashboard-status-research",
BookStatuses.Outlining => "dashboard-status-outlining",
BookStatuses.Revising => "dashboard-status-revising",
BookStatuses.Editing => "dashboard-status-editing",
BookStatuses.Proofreading => "dashboard-status-proofreading",
BookStatuses.ReadyForPublication => "dashboard-status-ready",
BookStatuses.Paused => "dashboard-status-paused",
BookStatuses.Archived => "dashboard-status-archived",
_ => "dashboard-status-planning"
};
}
private static string DashboardIconLabel(string entityType, string activityType)
{
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;
return string.IsNullOrWhiteSpace(source) ? "A" : source.Trim()[0].ToString().ToUpperInvariant();
}
private static string DashboardWarningLocation(ContinuityWarning warning)
{
if (warning.SceneNumber.HasValue)
{
return string.IsNullOrWhiteSpace(warning.SceneTitle)
? $"Scene {warning.SceneNumber.Value:g}"
: $"Scene {warning.SceneNumber.Value:g}: {warning.SceneTitle}";
}
if (warning.ChapterNumber.HasValue)
{
return string.IsNullOrWhiteSpace(warning.ChapterTitle)
? $"Chapter {warning.ChapterNumber.Value:g}"
: $"Chapter {warning.ChapterNumber.Value:g}: {warning.ChapterTitle}";
}
return string.IsNullOrWhiteSpace(warning.BookDisplayTitle) ? warning.EntityType : warning.BookDisplayTitle;
}
private static string RelativeTimeLabel(DateTime createdDateUtc)
{
var elapsed = DateTime.UtcNow - DateTime.SpecifyKind(createdDateUtc, DateTimeKind.Utc);
if (elapsed.TotalMinutes < 1)
{
return "Just now";
}
if (elapsed.TotalMinutes < 60)
{
var minutes = Math.Max(1, (int)Math.Floor(elapsed.TotalMinutes));
return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago";
}
if (elapsed.TotalHours < 24)
{
var hours = Math.Max(1, (int)Math.Floor(elapsed.TotalHours));
return hours == 1 ? "1 hour ago" : $"{hours} hours ago";
}
if (elapsed.TotalDays < 7)
{
var days = Math.Max(1, (int)Math.Floor(elapsed.TotalDays));
return days == 1 ? "Yesterday" : $"{days} days ago";
}
return createdDateUtc.ToString("dd MMM yyyy");
}
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
{
return currentUser.UserId.HasValue
? projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value)
: Task.FromResult<Project?>(null);
}
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 async Task<ProjectHardDeleteResult> HardDeleteAsync(int projectId)
{
if (!currentUser.UserId.HasValue)
{
logger.LogWarning("Hard project delete requested without an authenticated user for project {ProjectID}.", projectId);
return ProjectHardDeleteResult.Inaccessible();
}
var userId = currentUser.UserId.Value;
var activeProject = await projects.GetForUserAsync(projectId, userId);
if (activeProject is not null)
{
if (!activeProject.IsOwner)
{
logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: user is not owner.", projectId, userId);
return ProjectHardDeleteResult.Inaccessible();
}
logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId);
return ProjectHardDeleteResult.ActiveProject();
}
var project = await projects.GetArchivedForOwnerAsync(projectId, userId);
if (project is null)
{
logger.LogWarning("Hard project delete denied for archived project {ProjectID} and user {UserID}: project not found or user is not owner.", projectId, userId);
return ProjectHardDeleteResult.Inaccessible();
}
IReadOnlyList<string> filePaths;
try
{
filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId);
await projects.HardDeleteForOwnerAsync(projectId, userId);
logger.LogInformation("Database hard delete completed for archived project {ProjectID} owned by user {UserID}. {FilePathCount} file paths queued for cleanup.",
projectId,
userId,
filePaths.Count);
}
catch (Exception ex)
{
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
projectId,
userId);
return ProjectHardDeleteResult.Failure("Project could not be permanently deleted. No files were removed.");
}
var cleanup = fileCleanup.DeleteFiles("project hard delete", projectId, filePaths);
if (cleanup.Failed > 0)
{
logger.LogWarning("Project {ProjectID} was hard deleted from the database but file cleanup had {FailedFileCount} failures, {DeletedFileCount} deleted files, and {MissingFileCount} missing files.",
projectId,
cleanup.Failed,
cleanup.Deleted,
cleanup.Missing);
}
else
{
logger.LogInformation("File cleanup completed for hard-deleted project {ProjectID}: {DeletedFileCount} deleted files, {MissingFileCount} missing files.",
projectId,
cleanup.Deleted,
cleanup.Missing);
}
return ProjectHardDeleteResult.Success(cleanup.Deleted, cleanup.Missing, cleanup.Failed);
}
}
public sealed class ProjectCollaborationService(
IProjectRepository projects,
IProjectCollaborationRepository collaboration,
IUserRepository users,
IProjectAccessService access,
ISubscriptionService subscriptions,
IEmailQueueRepository emailQueue,
IProjectActivityService activity,
ICurrentUserService currentUser,
IOptions<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,
IBookCoverService bookCovers,
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,
Status = BookStatuses.Planning,
CoverThumbnailPath = BookCoverService.PlaceholderPath,
StatusOptions = BuildStatusOptions(BookStatuses.Planning)
};
}
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,
Subtitle = book.Subtitle,
Tagline = book.Tagline,
ShortDescription = book.ShortDescription,
Status = book.Status,
BookNumber = book.BookNumber,
Description = book.Description,
TargetWordCount = book.TargetWordCount,
WritingStartDate = book.WritingStartDate,
TargetCompletionDate = book.TargetCompletionDate,
PublicationDate = book.PublicationDate,
UsesExplicitScenes = book.UsesExplicitScenes,
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
CoverStandardPath = book.CoverStandardPath,
Project = await projects.GetAsync(book.ProjectID),
StatusOptions = BuildStatusOptions(book.Status)
};
}
public async Task PopulateEditContextAsync(BookEditViewModel model)
{
model.Project = await projects.GetAsync(model.ProjectID);
model.StatusOptions = BuildStatusOptions(model.Status);
if (model.BookID != 0)
{
var book = await books.GetAsync(model.BookID);
model.CoverThumbnailPath = book?.CoverThumbnailPath ?? BookCoverService.PlaceholderPath;
model.CoverStandardPath = book?.CoverStandardPath;
}
}
public async Task<BookSaveResult> SaveAsync(BookEditViewModel model)
{
if (model.BookID == 0)
{
await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId);
}
if (!BookStatuses.IsValid(model.Status))
{
model.Status = BookStatuses.Planning;
}
var isNew = model.BookID == 0;
var bookId = await books.SaveAsync(new Book
{
BookID = model.BookID,
ProjectID = model.ProjectID,
BookTitle = model.BookTitle,
Subtitle = model.Subtitle,
Tagline = model.Tagline,
ShortDescription = model.ShortDescription,
Status = model.Status,
BookNumber = model.BookNumber,
Description = model.Description,
TargetWordCount = model.TargetWordCount,
WritingStartDate = model.WritingStartDate,
TargetCompletionDate = model.TargetCompletionDate,
PublicationDate = model.PublicationDate,
UsesExplicitScenes = model.UsesExplicitScenes
});
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle);
if (model.CoverUpload is { Length: > 0 })
{
var savedBook = await books.GetAsync(bookId);
if (savedBook is null)
{
return new(bookId, false, "Book was saved, but the cover could not be linked.");
}
var coverResult = await bookCovers.UploadCoverAsync(savedBook, model.CoverUpload);
if (!coverResult.Succeeded)
{
return new(bookId, false, coverResult.ErrorMessage);
}
}
return new(bookId, true);
}
private static IReadOnlyList<SelectListItem> BuildStatusOptions(string? selectedStatus)
=> BookStatuses.All
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))
.ToList();
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,
IFloorPlanRepository floorPlans,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies,
IWriterWorkspaceRepository writerWorkspace,
ISubscriptionService subscriptions,
IProjectActivityService activity,
IStorageService storage,
IStoryStateService storyState,
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,
TimeModeName = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeName ?? lookupData.TimeModes.First().TimeModeName,
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,
TimeModeName = scene.TimeModeName,
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,
FloorPlanID = scene.FloorPlanID,
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
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,
FloorPlanID = model.FloorPlanID,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
});
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.CharacterAttributeEvents = model.SceneID == 0 ? [] : await characters.ListAttributeEventsBySceneAsync(model.SceneID);
model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID);
model.AgeContinuityWarnings = await BuildAgeContinuityWarningsAsync(model);
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.StoryState = model.SceneID == 0 ? new StoryStateViewModel() : await storyState.GetStoryStateAsync(model.SceneID);
model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName);
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}");
model.PlotLineEventOptions = plotLines;
model.PlotThreadEventOptions = plotThreads;
model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName);
model.PlotEventTypeOptions = ChapterService.ToSelectList(plotLookups.PlotEventTypes, x => x.PlotEventTypeID, x => x.TypeName);
model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName);
model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName);
model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName);
model.AssetEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName);
model.AssetCustodyEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetCustodyEventTypes, x => x.AssetCustodyEventTypeID, x => x.TypeName);
model.CustodyRoleOptions = ChapterService.ToSelectList(assetLookups.CustodyRoles, x => x.CustodyRoleID, x => x.RoleName);
model.CharacterOptions = ToOptionalSelectList(projectCharacters, x => x.CharacterID, x => x.CharacterName);
model.CharacterRoleOptions = ToOptionalSelectList(characterLookups.RoleTypes, x => x.CharacterRoleInSceneTypeID, x => x.TypeName);
model.PresenceTypeOptions = ToOptionalSelectList(characterLookups.PresenceTypes, x => x.PresenceTypeID, x => x.TypeName);
model.CharacterAttributeTypeOptions = ChapterService.ToSelectList(characterLookups.AttributeTypes, x => x.CharacterAttributeTypeID, x => x.AttributeName);
model.KnowledgeStateOptions = ChapterService.ToSelectList(characterLookups.KnowledgeStates, x => x.KnowledgeStateID, x => x.StateName);
model.RelationshipOptions = ToOptionalSelectList(projectRelationships, x => x.CharacterRelationshipID, x => $"{x.CharacterAName} -> {x.CharacterBName} ({x.RelationshipTypeName})");
model.RelationshipTypeOptions = ToRelationshipTypeSelectList(characterLookups.RelationshipTypes);
model.RelationshipStateOptions = ChapterService.ToSelectList(characterLookups.RelationshipStates, x => x.RelationshipStateID, x => x.StateName);
model.LocationOptions = ToOptionalSelectList(projectLocations, x => x.LocationID, x => new string(' ', x.Depth * 2) + x.LocationName);
model.SceneOptions = ToOptionalSelectList(allSceneOptions.Where(x => x.SceneID != model.SceneID), x => x.SceneID, x => $"{x.BookDisplayTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}");
model.ChapterOptions = ChapterService.ToSelectList(chapterOptions, x => x.ChapterID, x => $"{x.BookDisplayTitle} / Chapter {x.ChapterNumber}: {x.ChapterTitle}");
model.SceneDependencyTypeOptions = ChapterService.ToSelectList(dependencyLookups.DependencyTypes, x => x.SceneDependencyTypeID, x => x.TypeName);
model.NewThreadEvent = new ThreadEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
EventTitle = string.Empty,
PlotEventTypeID = plotLookups.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID
?? plotLookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
?? 0,
EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID
?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0
};
model.NewAssetEvent = new AssetEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetEventTypeID = assetLookups.AssetEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.AssetEventTypeID
?? assetLookups.AssetEventTypes.FirstOrDefault()?.AssetEventTypeID
?? 0,
NewAssetKindID = assetLookups.AssetKinds.FirstOrDefault(x => x.KindName == "Other")?.AssetKindID
};
model.NewAssetCustodyEvent = new AssetCustodyCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetCustodyEventTypeID = assetLookups.AssetCustodyEventTypes.FirstOrDefault(x => x.TypeName == "Has")?.AssetCustodyEventTypeID
?? assetLookups.AssetCustodyEventTypes.FirstOrDefault()?.AssetCustodyEventTypeID
?? 0,
CustodyRoleID = assetLookups.CustodyRoles.FirstOrDefault(x => x.RoleName == "Has Custody")?.CustodyRoleID
?? assetLookups.CustodyRoles.FirstOrDefault()?.CustodyRoleID
?? 0
};
model.NewSceneAssetLocation = new SceneAssetLocationCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
UpdateCurrentLocation = true
};
model.NewSceneCharacter = new SceneCharacterCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RoleInSceneTypeID = characterLookups.RoleTypes.FirstOrDefault(x => x.TypeName == "Main Participant")?.CharacterRoleInSceneTypeID,
PresenceTypeID = characterLookups.PresenceTypes.FirstOrDefault(x => x.TypeName == "Present")?.PresenceTypeID
};
model.NewCharacterAttributeEvent = new CharacterAttributeEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
CharacterAttributeTypeID = characterLookups.AttributeTypes.FirstOrDefault()?.CharacterAttributeTypeID ?? 0
};
model.NewCharacterKnowledge = new CharacterKnowledgeCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
KnowledgeStateID = characterLookups.KnowledgeStates.FirstOrDefault(x => x.StateName == "Knows")?.KnowledgeStateID
?? characterLookups.KnowledgeStates.FirstOrDefault()?.KnowledgeStateID
?? 0
};
model.NewRelationshipEvent = new RelationshipEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RelationshipStateID = characterLookups.RelationshipStates.FirstOrDefault(x => x.StateName == "Unknown")?.RelationshipStateID
?? characterLookups.RelationshipStates.FirstOrDefault()?.RelationshipStateID
?? 0
};
model.NewDependency = new SceneDependencyCreateViewModel
{
ProjectID = model.Project.ProjectID,
CurrentSceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneDependencyTypeID = dependencyLookups.DependencyTypes.FirstOrDefault(x => x.TypeName == "Must Occur Before")?.SceneDependencyTypeID
?? dependencyLookups.DependencyTypes.FirstOrDefault()?.SceneDependencyTypeID
?? 0
};
model.NewNote = new SceneNoteEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneNoteTypeID = noteTypes.FirstOrDefault(x => x.TypeName == "Drafting Note")?.SceneNoteTypeID
?? noteTypes.FirstOrDefault()?.SceneNoteTypeID
?? 0
};
model.NewChecklistItem = new SceneChecklistItemEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.NewAttachment = new SceneAttachmentEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneAttachmentTypeID = attachmentTypes.FirstOrDefault(x => x.TypeName == "Link")?.SceneAttachmentTypeID
?? attachmentTypes.FirstOrDefault()?.SceneAttachmentTypeID
?? 0
};
model.MoveRequest = new SceneMoveRequestViewModel
{
SceneID = model.SceneID,
TargetChapterID = model.ChapterID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.Occupancy = model.SceneID == 0
? new SceneOccupancyEditorViewModel()
: await BuildOccupancyEditorAsync(model, projectAssets);
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
public async Task<SceneOccupancyEditorViewModel?> GetOccupancyEditorAsync(int sceneId, int? floorPlanId = null)
{
var sceneModel = await GetEditAsync(sceneId);
if (sceneModel is null)
{
return null;
}
if (floorPlanId.HasValue)
{
sceneModel.FloorPlanID = floorPlanId;
}
var projectAssets = sceneModel.Project is null ? [] : await assets.ListAssetsAsync(sceneModel.Project.ProjectID);
return await BuildOccupancyEditorAsync(sceneModel, projectAssets);
}
public async Task SaveOccupancyAsync(SceneOccupancySaveViewModel model)
{
var scene = await scenes.GetAsync(model.SceneID) ?? throw new InvalidOperationException("Scene not found.");
var context = await GetContextAsync(scene.ChapterID) ?? throw new InvalidOperationException("Scene context not found.");
if (model.FloorPlanID.HasValue)
{
var floorPlan = await floorPlans.GetAsync(model.FloorPlanID.Value);
if (floorPlan is null || floorPlan.ProjectID != context.Project.ProjectID)
{
throw new InvalidOperationException("Floor plan not found.");
}
if (model.InitialFloorPlanFloorID.HasValue)
{
var floors = await floorPlans.ListFloorsByPlanAsync(model.FloorPlanID.Value);
if (!floors.Any(x => x.FloorPlanFloorID == model.InitialFloorPlanFloorID.Value))
{
throw new InvalidOperationException("Initial floor does not belong to the selected floor plan.");
}
}
}
else
{
model.InitialFloorPlanFloorID = null;
}
var allowedLocations = model.FloorPlanID.HasValue
? (await floorPlans.ListBlocksByPlanAsync(model.FloorPlanID.Value)).Select(x => x.LocationID).ToHashSet()
: [];
var sceneCharacterIds = (await characters.ListSceneCharactersAsync(model.SceneID)).Select(x => x.CharacterID).ToHashSet();
var sceneAssetIds = (await assets.ListEventsBySceneAsync(model.SceneID)).Select(x => x.StoryAssetID).ToHashSet();
var rows = new List<SceneFloorPlanOccupancy>();
foreach (var character in model.Characters.Where(x => sceneCharacterIds.Contains(x.CharacterID)))
{
if (character.LocationID.HasValue && !allowedLocations.Contains(character.LocationID.Value))
{
throw new InvalidOperationException("Character location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
CharacterID = character.CharacterID,
LocationID = character.LocationID ?? 0
});
}
foreach (var asset in model.Assets.Where(x => sceneAssetIds.Contains(x.StoryAssetID)))
{
if (asset.LocationID.HasValue && !allowedLocations.Contains(asset.LocationID.Value))
{
throw new InvalidOperationException("Asset location must be on the selected floor plan.");
}
rows.Add(new SceneFloorPlanOccupancy
{
StoryAssetID = asset.StoryAssetID,
LocationID = asset.LocationID ?? 0
});
}
await sceneOccupancy.SaveAsync(model.SceneID, model.FloorPlanID, model.InitialFloorPlanFloorID, rows);
await activity.RecordAsync(context.Project.ProjectID, "Updated", "Scene Occupancy", model.SceneID, scene.SceneTitle);
}
private async Task<SceneOccupancyEditorViewModel> BuildOccupancyEditorAsync(SceneEditViewModel model, IReadOnlyList<StoryAsset> projectAssets)
{
var floorPlanList = model.Project is null ? [] : await floorPlans.ListByProjectAsync(model.Project.ProjectID);
var selectedFloorPlanId = model.FloorPlanID ?? floorPlanList.FirstOrDefault()?.FloorPlanID;
var existingRows = model.SceneID == 0 ? [] : await sceneOccupancy.ListBySceneAsync(model.SceneID);
var floors = new List<SceneOccupancyFloorOption>();
var locationsOnPlans = new List<SceneOccupancyLocationOption>();
foreach (var plan in floorPlanList)
{
var planFloors = await floorPlans.ListFloorsByPlanAsync(plan.FloorPlanID);
var blocks = await floorPlans.ListBlocksByPlanAsync(plan.FloorPlanID);
var floorsById = planFloors.ToDictionary(x => x.FloorPlanFloorID);
floors.AddRange(planFloors.Select(floor => new SceneOccupancyFloorOption
{
FloorPlanID = floor.FloorPlanID,
FloorPlanFloorID = floor.FloorPlanFloorID,
Name = floor.Name,
SortOrder = floor.SortOrder
}));
locationsOnPlans.AddRange(blocks
.GroupBy(x => new { x.LocationID, x.FloorPlanFloorID })
.Select(group =>
{
floorsById.TryGetValue(group.Key.FloorPlanFloorID, out var floor);
var first = group.First();
return new SceneOccupancyLocationOption
{
FloorPlanID = plan.FloorPlanID,
FloorPlanFloorID = group.Key.FloorPlanFloorID,
FloorName = floor?.Name ?? "Floor",
FloorSortOrder = floor?.SortOrder ?? 0,
LocationID = group.Key.LocationID,
LocationName = first.LocationName,
LocationPath = first.LocationPath
};
}));
}
var existingByCharacter = existingRows.Where(x => x.CharacterID.HasValue).ToDictionary(x => x.CharacterID!.Value, x => x.LocationID);
var existingByAsset = existingRows.Where(x => x.StoryAssetID.HasValue).ToDictionary(x => x.StoryAssetID!.Value, x => x.LocationID);
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
return new SceneOccupancyEditorViewModel
{
SceneID = model.SceneID,
FloorPlanID = selectedFloorPlanId,
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID,
FloorPlanOptions = floorPlanList.OrderBy(x => x.Name).Select(x => new SelectListItem(x.Name, x.FloorPlanID.ToString())).ToList(),
Floors = floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList(),
Locations = locationsOnPlans.OrderBy(x => x.FloorSortOrder).ThenBy(x => x.FloorName).ThenBy(x => x.LocationPath).ToList(),
Characters = model.SceneCharacters
.GroupBy(x => x.CharacterID)
.Select(group => new SceneCharacterOccupancyRowViewModel
{
CharacterID = group.Key,
CharacterName = group.First().CharacterName,
LocationID = existingByCharacter.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.CharacterName)
.ToList(),
Assets = model.AssetEvents
.GroupBy(x => x.StoryAssetID)
.Select(group => new SceneAssetOccupancyRowViewModel
{
StoryAssetID = group.Key,
AssetName = assetNames.GetValueOrDefault(group.Key, group.First().AssetName),
LocationID = existingByAsset.GetValueOrDefault(group.Key)
})
.OrderBy(x => x.AssetName)
.ToList()
};
}
private async Task<IReadOnlyList<ContinuityWarning>> BuildAgeContinuityWarningsAsync(SceneEditViewModel model)
{
if (model.SceneID == 0 || model.Project is null || model.Book is null)
{
return [];
}
var chapterRows = await chapters.ListByBookAsync(model.Book.BookID);
var scenesInBook = new List<CharacterAgeContinuityScene>();
foreach (var chapter in chapterRows)
{
var chapterScenes = await scenes.ListByChapterAsync(chapter.ChapterID);
scenesInBook.AddRange(chapterScenes.Select(scene => new CharacterAgeContinuityScene
{
SceneID = scene.SceneID,
ProjectID = model.Project.ProjectID,
BookID = model.Book.BookID,
ChapterID = chapter.ChapterID,
ChapterNumber = chapter.ChapterNumber,
ChapterTitle = chapter.ChapterTitle,
SceneNumber = scene.SceneNumber,
SceneTitle = scene.SceneTitle,
ChapterSortOrder = chapter.SortOrder,
SceneSortOrder = scene.SortOrder,
TimeModeName = scene.TimeModeName,
StartDateTime = scene.StartDateTime
}));
}
var currentScene = scenesInBook.FirstOrDefault(scene => scene.SceneID == model.SceneID)
?? new CharacterAgeContinuityScene
{
SceneID = model.SceneID,
ProjectID = model.Project.ProjectID,
BookID = model.Book.BookID,
ChapterID = model.ChapterID,
ChapterNumber = model.Chapter?.ChapterNumber,
ChapterTitle = model.Chapter?.ChapterTitle,
SceneNumber = model.SceneNumber,
SceneTitle = model.SceneTitle,
ChapterSortOrder = model.Chapter?.SortOrder ?? 0,
SceneSortOrder = 0,
TimeModeName = model.TimeModeName,
StartDateTime = model.StartDateTime
};
var characterTimeline = await characters.GetTimelineAsync(model.Project.ProjectID, model.Book.BookID);
return CharacterAgeContinuityService.GetWarningsForScene(
currentScene,
scenesInBook,
characterTimeline.Appearances,
model.SceneCharacters);
}
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), projectId, bookId);
var characterLookupData = await TimeTimelineLoadAsync("dbo.CharacterLookup_All", () => characters.GetLookupsAsync(projectId), 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 timelineWarnings = needsWarnings
? await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId)
: [];
var warningsByScene = await TimeTimelineLoadAsync(
"TimelineService.PrepareWarnings",
() => Task.FromResult(timelineWarnings
.Where(x => x.SceneID.HasValue)
.GroupBy(x => x.SceneID!.Value)
.ToDictionary(x => x.Key, x => (IReadOnlyList<ContinuityWarning>)x.ToList())),
projectId,
bookId);
var warningCounts = warningsByScene.ToDictionary(x => x.Key, x => x.Value.Count);
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)
: ([], []);
ApplyTimelineCharacterAvatars(characterTimeline, allCharacters);
var sceneOverviewRows = settings.ShowSceneCards
? await TimeTimelineLoadAsync("dbo.Timeline_SceneOverviews", () => timelineRepository.GetSceneOverviewsAsync(projectId, bookId), projectId, bookId)
: [];
var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID);
foreach (var threadEvent in timeline.ThreadEvents)
{
if (plotThreadDetailsById.TryGetValue(threadEvent.PlotThreadID, out var thread))
{
threadEvent.ThreadStatusName = thread.ThreadStatusName;
threadEvent.Importance = thread.Importance;
}
}
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 = await TimeTimelineLoadAsync("TimelineService.BuildFocusLabel", () => Task.FromResult(BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations)), projectId, bookId);
var sceneOverviews = settings.ShowSceneCards
? await TimeTimelineLoadAsync("TimelineService.BuildSceneOverviews", () => Task.FromResult(BuildSceneOverviews(orderedScenes, sceneOverviewRows)), projectId, bookId)
: new Dictionary<int, TimelineSceneOverviewViewModel>();
var metricGraphs = settings.ShowMetricShape
? await TimeTimelineLoadAsync("TimelineService.BuildMetricGraphs", () => Task.FromResult(BuildMetricGraphs(timeline, orderedScenes)), projectId, bookId)
: [];
var plotLanes = settings.ShowPlotLines
? await TimeTimelineLoadAsync("TimelineService.BuildPlotLanes", () => Task.FromResult(BuildPlotLanes(timeline, orderedScenes)), projectId, bookId)
: [];
var assetLanes = settings.ShowStoryAssets
? await TimeTimelineLoadAsync("TimelineService.BuildAssetLanes", () => Task.FromResult(BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes)), projectId, bookId)
: [];
var characterLanes = settings.ShowCharacterAppearances
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
: [];
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.BookDisplayTitle}", 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 = metricGraphs,
PlotLanes = plotLanes,
AssetLanes = assetLanes,
CharacterLanes = characterLanes,
SceneOverviews = sceneOverviews,
WarningsByScene = warningsByScene,
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 void ApplyTimelineCharacterAvatars(
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline,
IReadOnlyList<Character> allCharacters)
{
var characterImages = allCharacters.ToDictionary(x => x.CharacterID);
foreach (var character in characterTimeline.Characters)
{
if (!characterImages.TryGetValue(character.CharacterID, out var source))
{
continue;
}
character.ImagePath = source.ImagePath;
character.ThumbnailPath = source.ThumbnailPath;
character.AvatarImagePath = source.AvatarImagePath;
character.AvatarThumbnailPath = source.AvatarThumbnailPath;
character.AvatarSourceCharacterImageID = source.AvatarSourceCharacterImageID;
}
foreach (var appearance in characterTimeline.Appearances)
{
if (!characterImages.TryGetValue(appearance.CharacterID, out var source))
{
continue;
}
appearance.CharacterImagePath = source.AvatarImagePath ?? source.ImagePath;
appearance.CharacterThumbnailPath = source.AvatarThumbnailPath ?? source.ThumbnailPath;
}
}
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 IReadOnlyDictionary<int, TimelineSceneOverviewViewModel> BuildSceneOverviews(
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<TimelineSceneOverviewRow> overviewRows)
{
var charactersByScene = overviewRows
.Where(x => x.CharacterID.HasValue && !string.IsNullOrWhiteSpace(x.CharacterName))
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.GroupBy(x => x.CharacterID!.Value)
.Select(characterGroup =>
{
var first = characterGroup
.OrderByDescending(x => x.IsPov)
.ThenBy(x => x.CharacterName)
.First();
return new TimelineSceneCharacterOverviewViewModel
{
CharacterID = first.CharacterID!.Value,
CharacterName = first.CharacterName ?? string.Empty,
IsPov = first.IsPov
};
})
.OrderByDescending(x => x.IsPov)
.ThenBy(x => x.CharacterName)
.ToList());
var assetsByScene = overviewRows
.Where(x => x.StoryAssetID.HasValue && !string.IsNullOrWhiteSpace(x.AssetName))
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.GroupBy(x => x.StoryAssetID!.Value)
.Select(assetGroup =>
{
var first = assetGroup.First();
return new TimelineSceneAssetOverviewViewModel
{
StoryAssetID = first.StoryAssetID!.Value,
AssetName = first.AssetName ?? string.Empty,
Importance = first.AssetImportance.GetValueOrDefault(),
EventTypeName = first.AssetEventTypeName
};
})
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.ToList());
var locationsByScene = overviewRows
.Where(x => !string.IsNullOrWhiteSpace(x.LocationName))
.GroupBy(x => x.SceneID)
.ToDictionary(
sceneGroup => sceneGroup.Key,
sceneGroup => sceneGroup
.Select(x => x.LocationName ?? string.Empty)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList());
return orderedScenes.ToDictionary(scene => scene.SceneID, scene =>
{
var locations = new List<string>();
if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationPath))
{
locations.Add(scene.PrimaryLocationPath);
}
else if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName))
{
locations.Add(scene.PrimaryLocationName);
}
if (locationsByScene.TryGetValue(scene.SceneID, out var characterLocations))
{
locations.AddRange(characterLocations);
}
return new TimelineSceneOverviewViewModel
{
Characters = charactersByScene.GetValueOrDefault(scene.SceneID, []),
Assets = assetsByScene.GetValueOrDefault(scene.SceneID, []),
Locations = locations
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(6)
.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 StoryStateService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ITimelineRepository timelineRepository,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IContinuityWarningAcknowledgementRepository warningAcknowledgements,
ICurrentUserService currentUser) : IStoryStateService
{
public async Task<StoryStateViewModel> GetStoryStateAsync(int sceneId)
{
var currentScene = await scenes.GetAsync(sceneId);
if (currentScene is null)
{
return new StoryStateViewModel();
}
var chapter = await chapters.GetAsync(currentScene.ChapterID);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
if (book is null)
{
return new StoryStateViewModel();
}
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
if (currentSceneIndex < 0)
{
return new StoryStateViewModel();
}
var sceneIndexes = BuildSceneIndexes(orderedScenes);
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
var allLocations = await locations.ListByProjectAsync(book.ProjectID);
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
var storyState = new StoryStateViewModel
{
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID))
};
storyState.Locations = BuildLocationState(allLocations, storyState);
return storyState;
}
public async Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter)
{
NormaliseFilter(filter);
var project = await projects.GetAsync(filter.ProjectID);
if (project is null)
{
return null;
}
var timeline = await timelineRepository.GetByProjectAsync(filter.ProjectID, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
var allCharacters = await characters.ListCharactersAsync(filter.ProjectID);
var allAssets = await assets.ListAssetsAsync(filter.ProjectID);
var allLocations = await locations.ListByProjectAsync(filter.ProjectID);
filter.SelectedSceneID ??= scopedScenes.FirstOrDefault()?.SceneID ?? orderedScenes.FirstOrDefault()?.SceneID;
var model = new ContinuityExplorerViewModel
{
Project = project,
Filter = filter,
ShowBookContext = ShouldShowBookContext(filter, scopedScenes, timeline.Chapters),
ShowChapterContext = ShouldShowChapterContext(filter, scopedScenes, timeline.Chapters),
BookOptions = timeline.Books.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString(), x.BookID == filter.BookID)).ToList(),
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterID)).ToList(),
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber:g}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SelectedSceneID)).ToList(),
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), filter.CharacterIDs.Contains(x.CharacterID))).ToList(),
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), filter.AssetIDs.Contains(x.StoryAssetID))).ToList(),
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), filter.LocationIDs.Contains(x.LocationID))).ToList()
};
if (string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase) && filter.SelectedSceneID.HasValue)
{
model.StoryState = await GetStoryStateAsync(filter.SelectedSceneID.Value);
FilterStoryState(model.StoryState, filter);
}
else if (string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase))
{
var characterIds = filter.IncludeCharacters
? (filter.CharacterIDs.Any() ? filter.CharacterIDs : allCharacters.Select(x => x.CharacterID)).ToList()
: [];
var assetIds = filter.IncludeAssets
? (filter.AssetIDs.Any() ? filter.AssetIDs : allAssets.Select(x => x.StoryAssetID)).ToList()
: [];
var characterJourney = new List<CharacterJourneyViewModel>();
foreach (var characterId in characterIds)
{
characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter));
}
var assetJourney = new List<AssetJourneyViewModel>();
foreach (var assetId in assetIds)
{
assetJourney.AddRange(await GetAssetJourneyAsync(filter.ProjectID, assetId, filter));
}
model.CharacterJourney = characterJourney;
model.AssetJourney = assetJourney;
}
else if (string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
{
var locationIds = filter.IncludeLocations
? (filter.LocationIDs.Any() ? filter.LocationIDs : allLocations.Select(x => x.LocationID)).ToList()
: [];
var activity = new List<LocationActivityViewModel>();
foreach (var locationId in locationIds)
{
activity.AddRange(await GetLocationActivityAsync(filter.ProjectID, locationId, filter));
}
model.LocationActivity = activity
.OrderBy(x => scopedScenes.FindIndex(scene => scene.SceneID == x.SceneID))
.ThenBy(x => x.LocationName)
.ToList();
}
return model;
}
public async Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var character = characterTimeline.Characters.FirstOrDefault(x => x.CharacterID == characterId);
if (character is null)
{
return [];
}
var rows = characterTimeline.Appearances
.Where(x => x.CharacterID == characterId && x.LocationID.HasValue && context.AllowedSceneIds.Contains(x.SceneID))
.GroupBy(x => x.SceneID)
.Select(x => x.OrderByDescending(row => row.UpdatedDate).First())
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ToList();
var result = new List<CharacterJourneyViewModel>();
int? previousLocationId = null;
foreach (var row in rows)
{
if (row.LocationID == previousLocationId)
{
continue;
}
previousLocationId = row.LocationID;
var sceneContext = context.SceneContexts.GetValueOrDefault(row.SceneID);
result.Add(new CharacterJourneyViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
BookID = sceneContext?.BookID ?? 0,
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
ChapterID = sceneContext?.ChapterID ?? 0,
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
SceneID = row.SceneID,
SceneTitle = sceneContext?.SceneTitle ?? string.Empty,
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
SceneDisplayName = sceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
Location = row.LocationPath ?? row.LocationName ?? "Unknown"
});
}
return result;
}
public async Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var asset = assetTimeline.Assets.FirstOrDefault(x => x.StoryAssetID == assetId);
if (asset is null)
{
return [];
}
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
var assetCustody = await assets.ListCustodyByAssetAsync(assetId);
var assignments = BuildAssetAssignments(assetTimeline.Events, assetLocations, assetCustody, context.AllowedSceneIds);
var rows = assignments
.Where(x => x.StoryAssetID == assetId)
.GroupBy(x => x.SceneID)
.Select(x => x.OrderByDescending(row => row.Priority).ThenByDescending(row => row.UpdatedDate).First())
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ToList();
var result = new List<AssetJourneyViewModel>();
string? previousLabel = null;
foreach (var row in rows)
{
if (string.Equals(previousLabel, row.Label, StringComparison.OrdinalIgnoreCase))
{
continue;
}
previousLabel = row.Label;
var sceneContext = context.SceneContexts.GetValueOrDefault(row.SceneID);
result.Add(new AssetJourneyViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
BookID = sceneContext?.BookID ?? 0,
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
ChapterID = sceneContext?.ChapterID ?? 0,
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
SceneID = row.SceneID,
SceneTitle = sceneContext?.SceneTitle ?? string.Empty,
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
SceneDisplayName = sceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
HolderOrLocation = row.Label
});
}
return result;
}
public async Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var allLocations = await locations.ListByProjectAsync(projectId);
var location = allLocations.FirstOrDefault(x => x.LocationID == locationId);
if (location is null)
{
return [];
}
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var maxScopedSceneIndex = context.ScopedScenes
.Select(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1))
.DefaultIfEmpty(-1)
.Max();
var scenesThroughRange = context.OrderedScenes
.Where(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1) <= maxScopedSceneIndex)
.ToList();
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughRange);
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
var characterIds = filter.CharacterIDs.ToHashSet();
var assetIds = filter.AssetIDs.ToHashSet();
var characterAssignments = characterTimeline.Appearances
.Where(x => x.LocationID.HasValue)
.GroupBy(x => x.CharacterID)
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
var assetAssignments = BuildAssetLocationAssignments(assetLocations, custodyCharacters)
.GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
var result = new List<LocationActivityViewModel>();
foreach (var scene in context.ScopedScenes)
{
var sceneIndex = context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1);
var sceneCharacters = filter.IncludeCharacters
? characterTimeline.Characters
.Where(character => !characterIds.Any() || characterIds.Contains(character.CharacterID))
.Select(character => BuildCurrentCharacterPresence(character, characterAssignments.GetValueOrDefault(character.CharacterID, []), locationId, scene.SceneID, sceneIndex, context))
.Where(x => x is not null)
.Select(x => x!)
.OrderBy(x => x.EntityName)
.ToList()
: [];
var sceneAssets = filter.IncludeAssets
? assetTimeline.Assets
.Where(asset => !assetIds.Any() || assetIds.Contains(asset.StoryAssetID))
.Select(asset => BuildCurrentAssetPresence(asset, assetAssignments.GetValueOrDefault(asset.StoryAssetID, []), characterAssignments, locationId, scene.SceneID, sceneIndex, context))
.Where(x => x is not null)
.Select(x => x!)
.OrderBy(x => x.EntityName)
.ToList()
: [];
if (!sceneCharacters.Any() && !sceneAssets.Any())
{
continue;
}
var sceneContext = context.SceneContexts.GetValueOrDefault(scene.SceneID);
result.Add(new LocationActivityViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationPath,
BookID = sceneContext?.BookID ?? 0,
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
ChapterID = sceneContext?.ChapterID ?? 0,
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
SceneID = scene.SceneID,
SceneTitle = sceneContext?.SceneTitle ?? scene.SceneTitle,
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
SceneDisplayName = sceneContext?.SceneDisplayName ?? SceneLabel(scene.SceneNumber, scene.SceneTitle),
Characters = sceneCharacters,
Assets = sceneAssets,
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters.Select(x => x.EntityName)) : "None",
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets.Select(x => x.EntityName)) : "None"
});
}
return result;
}
public async Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var allAssetLocations = await GetAssetLocationsThroughCurrentAsync(context.OrderedScenes);
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
var enabledCharacterIds = filter.CharacterIDs.ToHashSet();
var enabledAssetIds = filter.AssetIDs.ToHashSet();
var warnings = new List<ContinuityWarningViewModel>();
if (filter.IncludeCharacters)
{
warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context));
warnings.AddRange(await BuildKnowledgeRegressionWarningsAsync(characterTimeline.Characters, enabledCharacterIds, context));
}
if (filter.IncludeAssets)
{
var scopedAssetLocations = allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
var scopedCustody = custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
warnings.AddRange(BuildAssetLocationConflictWarnings(assetTimeline.Assets, scopedAssetLocations, scopedCustody, enabledAssetIds, context));
warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context));
}
var acknowledgements = await GetAcknowledgementsAsync(projectId);
ApplyAcknowledgements(warnings, acknowledgements);
return warnings
.Where(x => filter.ShowAcknowledgedWarnings || !x.IsAcknowledged)
.OrderBy(x => WarningSeveritySort(x.Severity))
.ThenBy(x => x.BookDisplayName)
.ThenBy(x => x.ChapterDisplayName)
.ThenBy(x => x.SceneID ?? int.MaxValue)
.ThenBy(x => x.WarningTypeDisplayName)
.ThenBy(x => x.Message)
.ToList();
}
public async Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement)
{
acknowledgement.WarningType = acknowledgement.WarningType.Trim();
acknowledgement.Notes = string.IsNullOrWhiteSpace(acknowledgement.Notes) ? null : acknowledgement.Notes.Trim();
acknowledgement.AcknowledgedByUserID = currentUser.UserId;
await warningAcknowledgements.SaveAsync(acknowledgement);
}
public Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement)
{
return warningAcknowledgements.DeleteAsync(
acknowledgement.ProjectID,
acknowledgement.WarningType.Trim(),
acknowledgement.SceneID,
acknowledgement.CharacterID,
acknowledgement.AssetID);
}
public async Task<bool> IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId)
{
var acknowledgement = await warningAcknowledgements.GetAsync(projectId, warningType, sceneId, characterId, assetId);
return acknowledgement is not null;
}
public async Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId)
{
return (await warningAcknowledgements.ListByProjectAsync(projectId)).ToList();
}
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
{
NormaliseFilter(filter);
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
return new RangeContext(
orderedScenes,
scopedScenes,
scopedScenes.Select(x => x.SceneID).ToHashSet(),
BuildSceneIndexes(orderedScenes),
orderedScenes.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)),
BuildSceneContexts(timeline));
}
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
{
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
var bookSortOrders = timeline.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
var chapterSortOrders = timeline.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
return timeline.Scenes
.OrderBy(scene => bookSortOrders.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0))
.ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID))
.ThenBy(scene => scene.SortOrder)
.ThenBy(scene => scene.SceneNumber)
.ThenBy(scene => scene.SceneID);
}
private static IEnumerable<Scene> ApplySceneRange(IReadOnlyList<Scene> orderedScenes, IReadOnlyList<Chapter> chapters, ContinuityFilter filter)
{
if (string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase) && filter.BookID.HasValue)
{
var chapterIds = chapters.Where(x => x.BookID == filter.BookID.Value).Select(x => x.ChapterID).ToHashSet();
return orderedScenes.Where(x => chapterIds.Contains(x.ChapterID));
}
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase) && filter.ChapterID.HasValue)
{
return orderedScenes.Where(x => x.ChapterID == filter.ChapterID.Value);
}
if (string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase) && (filter.StartSceneID.HasValue || filter.EndSceneID.HasValue))
{
var startIndex = filter.StartSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.StartSceneID.Value) : 0;
var endIndex = filter.EndSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.EndSceneID.Value) : orderedScenes.Count - 1;
startIndex = startIndex < 0 ? 0 : startIndex;
endIndex = endIndex < 0 ? orderedScenes.Count - 1 : endIndex;
if (startIndex > endIndex)
{
(startIndex, endIndex) = (endIndex, startIndex);
}
return orderedScenes.Skip(startIndex).Take(endIndex - startIndex + 1);
}
return orderedScenes;
}
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
{
var rows = new List<SceneAssetLocation>();
foreach (var scene in scenesThroughCurrent)
{
rows.AddRange(await locations.ListSceneAssetLocationsAsync(scene.SceneID));
}
return rows;
}
private async Task<IReadOnlyList<AssetCustodyEvent>> GetAssetCustodyThroughCurrentAsync(IReadOnlyList<StoryAsset> projectAssets)
{
var rows = new List<AssetCustodyEvent>();
foreach (var asset in projectAssets)
{
rows.AddRange(await assets.ListCustodyByAssetAsync(asset.StoryAssetID));
}
return rows;
}
private static List<CharacterStoryStateViewModel> BuildCharacterState(
IReadOnlyList<Character> projectCharacters,
IReadOnlyList<SceneCharacter> appearances,
int currentSceneId,
IReadOnlySet<int> allowedSceneIds,
IReadOnlyDictionary<int, int> sceneIndexes)
{
var appearancesWithLocations = appearances
.Where(x => allowedSceneIds.Contains(x.SceneID) && x.LocationID.HasValue)
.ToList();
return projectCharacters
.OrderByDescending(x => x.CharacterImportance ?? 0)
.ThenBy(x => x.CharacterName)
.Select(character =>
{
var latest = appearancesWithLocations
.Where(x => x.CharacterID == character.CharacterID)
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return new CharacterStoryStateViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
};
}
return new CharacterStoryStateViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
LocationID = latest.LocationID,
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
};
})
.ToList();
}
private static List<AssetStoryStateViewModel> BuildAssetState(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyEvent> assetCustody,
int currentSceneId,
IReadOnlySet<int> allowedSceneIds,
IReadOnlyDictionary<int, int> sceneIndexes,
IReadOnlyDictionary<int, Scene> scenesById)
{
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
return projectAssets
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.Select(asset =>
{
var latest = assignments
.Where(x => x.StoryAssetID == asset.StoryAssetID)
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.Priority)
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return new AssetStoryStateViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName
};
}
return new AssetStoryStateViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
LocationID = latest.LocationID,
CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
? SceneLabel(scene.SceneNumber, scene.SceneTitle)
: "Unknown"
};
})
.ToList();
}
private static List<AssetStateAssignment> BuildAssetAssignments(
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyEvent> assetCustody,
IReadOnlySet<int> allowedSceneIds)
{
var stateAssignments = assetEvents
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.ToStateName!, 1, x.UpdatedDate));
var locationAssignments = assetLocations
.Where(x => allowedSceneIds.Contains(x.SceneID))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationID, x.LocationPath, 2, x.UpdatedDate));
var custodyAssignments = assetCustody
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.CustodianSummary!, 3, x.UpdatedDate));
return stateAssignments.Concat(locationAssignments).Concat(custodyAssignments).ToList();
}
private static List<LocationStoryStateViewModel> BuildLocationState(IReadOnlyList<LocationItem> allLocations, StoryStateViewModel storyState)
{
return allLocations
.OrderBy(x => x.LocationPath)
.Select(location => new LocationStoryStateViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath,
CharactersPresent = storyState.Characters
.Where(x => x.LocationID == location.LocationID)
.Select(x => x.CharacterName)
.OrderBy(x => x)
.ToList(),
AssetsPresent = storyState.Assets
.Where(x => x.LocationID == location.LocationID)
.Select(x => x.AssetName)
.OrderBy(x => x)
.ToList()
})
.Where(x => x.CharactersPresent.Any() || x.AssetsPresent.Any())
.ToList();
}
private static List<ContinuityWarningViewModel> BuildMultipleCharacterLocationWarnings(
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
return appearances
.Where(x => context.AllowedSceneIds.Contains(x.SceneID)
&& x.LocationID.HasValue
&& (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
.GroupBy(x => new { x.SceneID, x.CharacterID, x.CharacterName })
.Where(group => group.Select(x => x.LocationID).Distinct().Count() > 1)
.Select(group => BuildWarning(
"Multiple Character Locations",
"Critical",
$"{group.Key.CharacterName} appears in multiple locations during {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
characterId: group.Key.CharacterID))
.ToList();
}
private static List<ContinuityWarningViewModel> BuildImpossibleMovementWarnings(
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
// Disabled for now: consecutive-scene location changes are often ordinary story movement.
// Re-enable when this warning can use scene dates/times, overlapping scene spans,
// location distance, or author-configurable movement rules.
return [];
}
private static List<ContinuityWarningViewModel> BuildUnknownCharacterLocationWarnings(
IReadOnlyList<Character> projectCharacters,
IReadOnlyList<SceneCharacter> appearances,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var appearancesByCharacter = appearances
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
.GroupBy(x => x.CharacterID)
.ToDictionary(x => x.Key, x => x.ToList());
var locationCharacterIds = appearances
.Where(x => x.LocationID.HasValue)
.Select(x => x.CharacterID)
.ToHashSet();
return projectCharacters
.Where(character => (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
&& appearancesByCharacter.ContainsKey(character.CharacterID)
&& !locationCharacterIds.Contains(character.CharacterID))
.Select(character =>
{
var firstSceneId = appearancesByCharacter[character.CharacterID]
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.First()
.SceneID;
return BuildWarning(
"Characters With Unknown Locations",
"Information",
$"{character.CharacterName} has no known location.",
firstSceneId,
context,
characterId: character.CharacterID);
})
.ToList();
}
private static List<ContinuityWarningViewModel> BuildAssetLocationConflictWarnings(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<SceneAssetLocation> sceneAssetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
IReadOnlySet<int> enabledAssetIds,
RangeContext context)
{
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
var directLocationGroups = sceneAssetLocations
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
.GroupBy(x => new { x.SceneID, x.StoryAssetID });
var custodyGroups = custodyCharacters
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
.GroupBy(x => new { x.SceneID, x.StoryAssetID })
.ToDictionary(x => x.Key, x => x.ToList());
var warnings = new List<ContinuityWarningViewModel>();
foreach (var group in directLocationGroups)
{
var directLocationCount = group.Select(x => x.LocationID).Distinct().Count();
if (directLocationCount <= 1)
{
continue;
}
warnings.Add(BuildWarning(
"Asset Location Conflict",
"Critical",
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit locations in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
assetId: group.Key.StoryAssetID));
}
foreach (var group in custodyGroups.Where(x => !directLocationGroups.Any(locationGroup => locationGroup.Key.SceneID == x.Key.SceneID && locationGroup.Key.StoryAssetID == x.Key.StoryAssetID)))
{
var holderCount = group.Value.Select(x => x.CharacterID?.ToString() ?? x.CharacterNameText ?? string.Empty).Where(x => x.Length > 0).Distinct().Count();
if (holderCount <= 1)
{
continue;
}
warnings.Add(BuildWarning(
"Asset Location Conflict",
"Critical",
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit holders in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
group.Key.SceneID,
context,
assetId: group.Key.StoryAssetID));
}
return warnings;
}
private static List<ContinuityWarningViewModel> BuildUnknownAssetLocationWarnings(
IReadOnlyList<StoryAsset> projectAssets,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> allAssetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
IReadOnlySet<int> enabledAssetIds,
RangeContext context)
{
var appearedAssetSceneIds = assetEvents
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
.Select(x => new { x.StoryAssetID, x.SceneID })
.Concat(allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
.Concat(custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
.GroupBy(x => x.StoryAssetID)
.ToDictionary(x => x.Key, x => x.Min(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, int.MaxValue)));
var locatedAssetIds = allAssetLocations.Select(x => x.StoryAssetID)
.Concat(custodyCharacters.Select(x => x.StoryAssetID))
.ToHashSet();
return projectAssets
.Where(asset => (!enabledAssetIds.Any() || enabledAssetIds.Contains(asset.StoryAssetID))
&& appearedAssetSceneIds.ContainsKey(asset.StoryAssetID)
&& !locatedAssetIds.Contains(asset.StoryAssetID))
.Select(asset =>
{
var firstSceneId = context.OrderedScenes.FirstOrDefault(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, int.MaxValue) == appearedAssetSceneIds[asset.StoryAssetID])?.SceneID;
return BuildWarning(
"Assets With Unknown Locations",
"Information",
$"{asset.AssetName} has no known location.",
firstSceneId,
context,
assetId: asset.StoryAssetID);
})
.ToList();
}
private async Task<List<ContinuityWarningViewModel>> BuildKnowledgeRegressionWarningsAsync(
IReadOnlyList<Character> projectCharacters,
IReadOnlySet<int> enabledCharacterIds,
RangeContext context)
{
var warnings = new List<ContinuityWarningViewModel>();
var charactersToCheck = projectCharacters
.Where(character => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
.ToList();
foreach (var character in charactersToCheck)
{
var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
.Where(x => context.SceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
.ThenBy(x => x.CreatedDate)
.ThenBy(x => x.CharacterKnowledgeID)
.ToList();
foreach (var group in knowledgeEvents.GroupBy(KnowledgeIdentity))
{
var progressionEvents = group
.Select(x => new { Item = x, Rank = KnowledgeStateRank(x.KnowledgeStateName) })
.Where(x => x.Rank.HasValue)
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.Item.SceneID, int.MaxValue))
.ThenBy(x => x.Item.CreatedDate)
.ThenBy(x => x.Item.CharacterKnowledgeID)
.ToList();
for (var i = 1; i < progressionEvents.Count; i++)
{
var previous = progressionEvents[i - 1];
var current = progressionEvents[i];
if (current.Rank!.Value >= previous.Rank!.Value || !context.AllowedSceneIds.Contains(current.Item.SceneID))
{
continue;
}
var knowledgeDescription = KnowledgeTitle(current.Item);
var warning = BuildWarning(
$"KnowledgeRegression|Knowledge:{NormaliseWarningKeyPart(knowledgeDescription)}",
"Warning",
KnowledgeRegressionMessage(character.CharacterName, knowledgeDescription, previous.Item.KnowledgeStateName, current.Item.KnowledgeStateName),
current.Item.SceneID,
context,
characterId: character.CharacterID);
warning.WarningTypeDisplayName = "Knowledge Regression";
warning.CharacterName = character.CharacterName;
warning.KnowledgeId = current.Item.CharacterKnowledgeID;
warning.KnowledgeTitle = knowledgeDescription;
warning.KnowledgeDescription = knowledgeDescription;
warning.PreviousKnowledgeState = previous.Item.KnowledgeStateName;
warning.CurrentKnowledgeState = current.Item.KnowledgeStateName;
var previousSceneContext = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID);
var currentSceneContext = context.SceneContexts.GetValueOrDefault(current.Item.SceneID);
warning.PreviousBookId = previousSceneContext?.BookID;
warning.PreviousChapterId = previousSceneContext?.ChapterID;
warning.PreviousSceneId = previous.Item.SceneID;
warning.PreviousSceneDisplayName = previousSceneContext?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(previous.Item.SceneID, SceneLabel(previous.Item.SceneNumber, previous.Item.SceneTitle));
warning.CurrentBookId = currentSceneContext?.BookID;
warning.CurrentChapterId = currentSceneContext?.ChapterID;
warning.CurrentSceneId = current.Item.SceneID;
warning.CurrentSceneDisplayName = currentSceneContext?.SceneDisplayName
?? context.SceneLabels.GetValueOrDefault(current.Item.SceneID, SceneLabel(current.Item.SceneNumber, current.Item.SceneTitle));
warnings.Add(warning);
}
}
}
return warnings
.GroupBy(x => new { x.CharacterID, x.KnowledgeDescription, x.CurrentSceneId })
.Select(x => x.First())
.ToList();
}
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
Character character,
IReadOnlyList<SceneCharacter> assignments,
int locationId,
int currentSceneId,
int currentSceneIndex,
RangeContext context)
{
var latest = assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest?.LocationID != locationId)
{
return null;
}
return new LocationActivityEntityViewModel
{
EntityID = character.CharacterID,
EntityName = character.CharacterName,
EntityType = "Character",
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, SceneLabel(latest.SceneNumber, latest.SceneTitle))
};
}
private static LocationActivityEntityViewModel? BuildCurrentAssetPresence(
StoryAsset asset,
IReadOnlyList<AssetLocationActivityAssignment> assignments,
IReadOnlyDictionary<int, List<SceneCharacter>> characterAssignments,
int locationId,
int currentSceneId,
int currentSceneIndex,
RangeContext context)
{
var latest = assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.Priority)
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
if (latest is null)
{
return null;
}
var note = latest.Note;
if (latest.LocationID != locationId)
{
if (!latest.HolderCharacterIDs.Any())
{
return null;
}
var holderInLocation = latest.HolderCharacterIDs
.Select(holderId => ResolveCharacterLocationAtScene(characterAssignments.GetValueOrDefault(holderId, []), currentSceneIndex, context))
.FirstOrDefault(x => x?.LocationID == locationId);
if (holderInLocation is null)
{
return null;
}
note = string.IsNullOrWhiteSpace(holderInLocation.CharacterName)
? latest.Note
: $"Via {holderInLocation.CharacterName}";
}
return new LocationActivityEntityViewModel
{
EntityID = asset.StoryAssetID,
EntityName = asset.AssetName,
EntityType = "Asset",
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmedSceneID = latest.SceneID,
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, "Unknown scene"),
Note = note
};
}
private static SceneCharacter? ResolveCharacterLocationAtScene(
IReadOnlyList<SceneCharacter> assignments,
int currentSceneIndex,
RangeContext context)
{
return assignments
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ThenByDescending(x => x.UpdatedDate)
.FirstOrDefault();
}
private static List<AssetLocationActivityAssignment> BuildAssetLocationAssignments(
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyCharacter> custodyCharacters)
{
var locationAssignments = assetLocations.Select(x => new AssetLocationActivityAssignment(
x.StoryAssetID,
x.SceneID,
x.LocationID,
[],
x.LocationPath,
2,
x.UpdatedDate,
null));
var custodyAssignments = custodyCharacters
.GroupBy(x => new { x.AssetCustodyEventID, x.StoryAssetID, x.SceneID })
.Select(group =>
{
var first = group.First();
var holderIds = group
.Select(x => x.CharacterID)
.Where(x => x.HasValue)
.Select(x => x!.Value)
.Distinct()
.ToList();
var holderNames = group
.Select(x => x.CharacterName ?? x.CharacterNameText)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase);
return new AssetLocationActivityAssignment(
group.Key.StoryAssetID,
group.Key.SceneID,
null,
holderIds,
string.Join(", ", holderNames),
3,
first.UpdatedDate,
holderIds.Any() ? null : "Named holder has no known character location");
});
return locationAssignments.Concat(custodyAssignments).ToList();
}
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
{
storyState.Characters = filter.IncludeCharacters
? storyState.Characters.Where(x => !filter.CharacterIDs.Any() || filter.CharacterIDs.Contains(x.CharacterID)).ToList()
: [];
storyState.Assets = filter.IncludeAssets
? storyState.Assets.Where(x => !filter.AssetIDs.Any() || filter.AssetIDs.Contains(x.StoryAssetID)).ToList()
: [];
storyState.Locations = filter.IncludeLocations
? storyState.Locations.Where(x => !filter.LocationIDs.Any() || filter.LocationIDs.Contains(x.LocationID)).ToList()
: [];
}
private static ContinuityWarningViewModel BuildWarning(
string warningType,
string severity,
string message,
int? sceneId,
RangeContext context,
int? characterId = null,
int? assetId = null)
{
var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null;
return new ContinuityWarningViewModel
{
WarningKey = BuildWarningKey(warningType, sceneId, characterId, assetId),
WarningType = warningType,
WarningTypeDisplayName = warningType,
Severity = severity,
Message = message,
CharacterID = characterId,
AssetID = assetId,
BookID = sceneContext?.BookID,
ChapterID = sceneContext?.ChapterID,
SceneID = sceneId,
SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty),
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty
};
}
private static string KnowledgeIdentity(CharacterKnowledgeItem item)
{
if (item.StoryAssetID.HasValue)
{
return $"Asset:{item.StoryAssetID.Value}";
}
if (item.PlotThreadID.HasValue)
{
return $"Thread:{item.PlotThreadID.Value}";
}
return $"Description:{(item.Description ?? string.Empty).Trim().ToUpperInvariant()}";
}
private static string KnowledgeTitle(CharacterKnowledgeItem item)
{
if (!string.IsNullOrWhiteSpace(item.AssetName))
{
return item.AssetName;
}
if (!string.IsNullOrWhiteSpace(item.ThreadTitle))
{
return item.ThreadTitle;
}
return string.IsNullOrWhiteSpace(item.Description) ? "Knowledge" : item.Description.Trim();
}
private static int? KnowledgeStateRank(string stateName)
{
return stateName switch
{
"Unaware" => 0,
"Suspects" => 1,
"Partially Knows" => 2,
"Knows" => 3,
_ => null
};
}
private static string KnowledgeRegressionMessage(string characterName, string knowledgeDescription, string previousState, string currentState)
{
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears unaware of \"{knowledgeDescription}\" after previously knowing it.";
}
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Suspects", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to regress from knowing to suspecting \"{knowledgeDescription}\".";
}
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
&& string.Equals(currentState, "Partially Knows", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to lose understanding of \"{knowledgeDescription}\".";
}
if (string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
{
return $"{characterName} appears to lose awareness of \"{knowledgeDescription}\".";
}
return $"{characterName} appears to regress from {KnowledgeStatePhrase(previousState)} to {KnowledgeStatePhrase(currentState)} \"{knowledgeDescription}\".";
}
private static string KnowledgeStatePhrase(string stateName)
{
return stateName switch
{
"Unaware" => "being unaware of",
"Suspects" => "suspecting",
"Partially Knows" => "partially knowing",
"Knows" => "knowing",
_ => stateName.ToLowerInvariant()
};
}
private static string NormaliseWarningKeyPart(string value)
{
return string.Join(" ", value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
private static void ApplyAcknowledgements(
IReadOnlyList<ContinuityWarningViewModel> warnings,
IReadOnlyList<ContinuityWarningAcknowledgement> acknowledgements)
{
var acknowledgementsByKey = acknowledgements.ToDictionary(
x => BuildWarningKey(x.WarningType, x.SceneID, x.CharacterID, x.AssetID),
StringComparer.OrdinalIgnoreCase);
foreach (var warning in warnings)
{
if (!acknowledgementsByKey.TryGetValue(warning.WarningKey, out var acknowledgement))
{
continue;
}
warning.IsAcknowledged = true;
warning.AcknowledgementNotes = acknowledgement.Notes;
}
}
private static string BuildWarningKey(string warningType, int? sceneId, int? characterId, int? assetId)
{
var parts = new List<string> { warningType.Trim() };
if (sceneId.HasValue)
{
parts.Add($"Scene:{sceneId.Value}");
}
if (characterId.HasValue)
{
parts.Add($"Character:{characterId.Value}");
}
if (assetId.HasValue)
{
parts.Add($"Asset:{assetId.Value}");
}
return string.Join("|", parts);
}
private static int WarningSeveritySort(string severity)
{
return severity switch
{
"Critical" => 0,
"Warning" => 1,
_ => 2
};
}
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
{
return orderedScenes
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
}
private static Dictionary<int, SceneContext> BuildSceneContexts(TimelineData timeline)
{
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
var booksById = timeline.Books.ToDictionary(x => x.BookID);
return timeline.Scenes.ToDictionary(
scene => scene.SceneID,
scene =>
{
chaptersById.TryGetValue(scene.ChapterID, out var chapter);
var book = chapter is null ? null : booksById.GetValueOrDefault(chapter.BookID);
return new SceneContext(
book?.BookID ?? 0,
book is null ? string.Empty : $"Book {book.BookNumber}: {book.BookDisplayTitle}",
chapter?.ChapterID ?? 0,
chapter is null ? string.Empty : $"Ch {chapter.ChapterNumber:g}: {chapter.ChapterTitle}",
scene.SceneID,
scene.SceneTitle,
SceneLabel(scene.SceneNumber, scene.SceneTitle));
});
}
private static bool ShouldShowBookContext(ContinuityFilter filter, IReadOnlyList<Scene> scopedScenes, IReadOnlyList<Chapter> chapters)
{
if (string.Equals(filter.SceneRangeMode, "EntireProject", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var chapterBookIds = chapters.ToDictionary(x => x.ChapterID, x => x.BookID);
return scopedScenes
.Select(scene => chapterBookIds.GetValueOrDefault(scene.ChapterID))
.Where(bookId => bookId > 0)
.Distinct()
.Count() > 1;
}
private static bool ShouldShowChapterContext(ContinuityFilter filter, IReadOnlyList<Scene> scopedScenes, IReadOnlyList<Chapter> chapters)
{
if (string.Equals(filter.SceneRangeMode, "EntireProject", StringComparison.OrdinalIgnoreCase)
|| string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var showBookContext = ShouldShowBookContext(filter, scopedScenes, chapters);
return showBookContext || scopedScenes.Select(scene => scene.ChapterID).Distinct().Count() > 1;
}
private static void NormaliseFilter(ContinuityFilter filter)
{
if (filter.ProjectID <= 0)
{
return;
}
if (!filter.EntityTypes.Any())
{
filter.EntityTypes = ["Characters", "Assets", "Locations"];
}
if (string.IsNullOrWhiteSpace(filter.SceneRangeMode))
{
filter.SceneRangeMode = "EntireProject";
}
if (string.IsNullOrWhiteSpace(filter.DisplayMode))
{
filter.DisplayMode = "StoryState";
}
else if (!string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
{
filter.DisplayMode = "StoryState";
}
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{
return string.IsNullOrWhiteSpace(sceneTitle)
? $"Scene {sceneNumber:g}"
: $"Scene {sceneNumber:g}: {sceneTitle}";
}
private sealed record AssetStateAssignment(
int StoryAssetID,
int SceneID,
int? LocationID,
string Label,
int Priority,
DateTime UpdatedDate);
private sealed record AssetLocationActivityAssignment(
int StoryAssetID,
int SceneID,
int? LocationID,
IReadOnlyList<int> HolderCharacterIDs,
string Label,
int Priority,
DateTime UpdatedDate,
string? Note);
private sealed record RangeContext(
IReadOnlyList<Scene> OrderedScenes,
IReadOnlyList<Scene> ScopedScenes,
IReadOnlySet<int> AllowedSceneIds,
IReadOnlyDictionary<int, int> SceneIndexes,
IReadOnlyDictionary<int, string> SceneLabels,
IReadOnlyDictionary<int, SceneContext> SceneContexts);
private sealed record SceneContext(
int BookID,
string BookDisplayName,
int ChapterID,
string ChapterDisplayName,
int SceneID,
string SceneTitle,
string SceneDisplayName);
}
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<PlotLineEditViewModel?> PopulatePlotLineFormAsync(PlotLineEditViewModel model)
{
var project = await projects.GetAsync(model.ProjectID);
if (project is null)
{
return null;
}
var lookupData = await plots.GetLookupsAsync();
var plotLines = await plots.ListPlotLinesAsync(model.ProjectID);
model.Project = project;
if (model.PlotLineTypeID == 0)
{
model.PlotLineTypeID = lookupData.PlotLineTypes.FirstOrDefault(x => x.TypeName == "Side Plot")?.PlotLineTypeID
?? lookupData.PlotLineTypes.First().PlotLineTypeID;
}
return await PopulatePlotLineListsAsync(model, lookupData, plotLines.Where(x => x.PlotLineID != model.PlotLineID));
}
public async Task<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();
if (model.PlotEventTypeID <= 0)
{
throw new InvalidOperationException("Choose an event type.");
}
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? lookupData.ThreadEventTypes.First();
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
?? throw new InvalidOperationException("Choose a valid event type.");
var title = string.IsNullOrWhiteSpace(model.EventTitle)
? eventType.TypeName
: model.EventTitle.Trim();
var sourcePlotLineId = model.PlotLineID;
if (plotThreadId == 0)
{
if (!model.PlotLineID.HasValue)
{
throw new InvalidOperationException("Choose a plot line or an existing plot thread.");
}
var plannedStatusId = lookupData.ThreadStatuses.First(x => x.StatusName == "Planned").ThreadStatusID;
var threadTypeId = model.NewThreadTypeID
?? GetDefaultThreadTypeId(lookupData, eventType.TypeName);
var threadTitle = string.IsNullOrWhiteSpace(model.NewThreadTitle)
? title
: model.NewThreadTitle.Trim();
plotThreadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = model.PlotLineID.Value,
ThreadTitle = threadTitle,
ThreadTypeID = threadTypeId,
ThreadStatusID = plannedStatusId,
Importance = 5
});
}
else
{
var existingPlotThread = await plots.GetPlotThreadAsync(plotThreadId);
if (existingPlotThread is null)
{
throw new InvalidOperationException("Choose a valid plot thread.");
}
sourcePlotLineId = existingPlotThread.PlotLineID;
}
if (!sourcePlotLineId.HasValue)
{
throw new InvalidOperationException("Choose a source plot line.");
}
var projectPlotLines = await plots.ListPlotLinesAsync(model.ReturnProjectID.GetValueOrDefault());
if (projectPlotLines.Count == 0)
{
var sourcePlotLine = await plots.GetPlotLineAsync(sourcePlotLineId.Value);
projectPlotLines = sourcePlotLine is null ? [] : await plots.ListPlotLinesAsync(sourcePlotLine.ProjectID);
}
ValidatePlotEventTargets(
plotEventType.TypeName,
sourcePlotLineId.Value,
projectPlotLines,
model.TargetPlotLineID,
model.SelectedTargetPlotLineIDs);
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
? model.TargetPlotLineID
: null;
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
? model.SelectedTargetPlotLineIDs
: [];
var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = plotThreadId,
SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID,
PlotEventTypeID = plotEventType.PlotEventTypeID,
TargetPlotLineID = targetPlotLineId,
EventTitle = title,
EventDescription = model.EventDescription
});
await plots.SaveThreadEventTargetsAsync(threadEventId, splitTargetPlotLineIds);
var plotThread = await plots.GetPlotThreadAsync(plotThreadId);
if (plotThread is not null)
{
await activity.RecordAsync(plotThread.ProjectID, "Created", "Thread Event", threadEventId, title);
}
return threadEventId;
}
public async Task UpdateThreadEventAsync(ThreadEventEditViewModel model)
{
var lookupData = await plots.GetLookupsAsync();
if (model.PlotEventTypeID <= 0)
{
throw new InvalidOperationException("Choose an event type.");
}
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? throw new InvalidOperationException("Choose a valid marker type.");
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
?? throw new InvalidOperationException("Choose a valid event type.");
var plotThread = await plots.GetPlotThreadAsync(model.PlotThreadID)
?? throw new InvalidOperationException("Choose a valid plot thread.");
var projectPlotLines = await plots.ListPlotLinesAsync(plotThread.ProjectID);
ValidatePlotEventTargets(
plotEventType.TypeName,
plotThread.PlotLineID,
projectPlotLines,
model.TargetPlotLineID,
model.SelectedTargetPlotLineIDs);
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
? model.TargetPlotLineID
: null;
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
? model.SelectedTargetPlotLineIDs
: [];
await plots.SaveThreadEventAsync(new ThreadEvent
{
ThreadEventID = model.ThreadEventID,
PlotThreadID = plotThread.PlotThreadID,
SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID,
PlotEventTypeID = plotEventType.PlotEventTypeID,
TargetPlotLineID = targetPlotLineId,
EventTitle = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(),
EventDescription = model.EventDescription
});
await plots.SaveThreadEventTargetsAsync(model.ThreadEventID, splitTargetPlotLineIds);
await activity.RecordAsync(plotThread.ProjectID, "Updated", "Thread Event", model.ThreadEventID, model.EventTitle);
}
private static void ValidatePlotEventTargets(
string plotEventTypeName,
int sourcePlotLineId,
IReadOnlyList<PlotLineItem> projectPlotLines,
int? targetPlotLineId,
IEnumerable<int> selectedTargetPlotLineIds)
{
var projectPlotLineIds = projectPlotLines.Select(x => x.PlotLineID).ToHashSet();
if (plotEventTypeName is "Branch" or "Merge")
{
if (!targetPlotLineId.HasValue)
{
throw new InvalidOperationException(plotEventTypeName == "Branch"
? "Choose a target plot line for the branch event."
: "Choose the plot line this event merges into.");
}
if (targetPlotLineId.Value == sourcePlotLineId)
{
throw new InvalidOperationException("Target plot line cannot be the source plot line.");
}
if (!projectPlotLineIds.Contains(targetPlotLineId.Value))
{
throw new InvalidOperationException("Target plot line must belong to this project.");
}
}
if (plotEventTypeName == "Split")
{
var targets = selectedTargetPlotLineIds.Where(id => id > 0).Distinct().ToList();
if (targets.Count < 2)
{
throw new InvalidOperationException("Choose at least two target plot lines for a split event.");
}
if (targets.Contains(sourcePlotLineId))
{
throw new InvalidOperationException("Split target plot lines cannot include the source plot line.");
}
if (targets.Any(id => !projectPlotLineIds.Contains(id)))
{
throw new InvalidOperationException("Split target plot lines must belong to this project.");
}
}
}
public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId);
private static int GetDefaultThreadTypeId(PlotLookupData lookupData, string eventTypeName)
{
var preferredTypeName = eventTypeName switch
{
"Clue Planted" => "Clue",
"Question Raised" or "Question Answered" => "Question",
"False Lead" => "Suspicion",
"Payoff" => "Payoff Setup",
"Promise Made" => "Promise",
"Threat Introduced" => "Threat",
_ => "Question"
};
return lookupData.ThreadTypes.FirstOrDefault(x => x.TypeName == preferredTypeName)?.ThreadTypeID
?? lookupData.ThreadTypes.First().ThreadTypeID;
}
private async Task<PlotLineEditViewModel> PopulatePlotLineListsAsync(PlotLineEditViewModel model, PlotLookupData lookupData, IEnumerable<PlotLineItem> parentCandidates)
{
model.PlotImportance = ChapterService.ToSelectList(lookupData.PlotImportance, x => x.PlotImportanceID, x => x.ImportanceName);
model.BookOptions = (await books.ListByProjectAsync(model.ProjectID))
.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", 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.BookDisplayTitle} / 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,
IStoryStateService storyState) : IContinuityValidationService
{
public async Task<WarningDashboardViewModel?> GetDashboardAsync(
int projectId,
int? bookId,
int? chapterId,
int? sceneId,
int? warningTypeId,
int? warningSeverityId,
string? entityType,
string? category,
bool includeDismissed,
bool includeIntentional,
bool showAcknowledgedWarnings,
ContinuityValidationSummary? summary = null)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var lookupData = await warnings.GetLookupsAsync();
var projectBooks = (await books.ListByProjectAsync(projectId)).ToList();
var projectChapters = new List<Chapter>();
foreach (var book in projectBooks)
{
projectChapters.AddRange(await chapters.ListByBookAsync(book.BookID));
}
var severityName = warningSeverityId.HasValue
? lookupData.Severities.FirstOrDefault(x => x.WarningSeverityID == warningSeverityId.Value)?.SeverityName
: null;
var rows = (await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, null, entityType, includeDismissed, includeIntentional))
.Where(x => !chapterId.HasValue || x.ChapterID == chapterId.Value)
.ToList();
var continuityWarnings = await storyState.GetContinuityWarningsAsync(projectId, BuildWarningContinuityFilter(projectId, bookId, chapterId, sceneId));
var booksById = projectBooks.ToDictionary(x => x.BookID);
var chaptersById = projectChapters.ToDictionary(x => x.ChapterID);
var dashboardWarnings = rows.Select(x => ToProjectWarning(x, booksById, chaptersById)).Concat(continuityWarnings.Select(ToProjectWarning)).ToList();
ApplyWarningDisplayContext(dashboardWarnings, bookId, chapterId, booksById, chaptersById);
var availableWarnings = dashboardWarnings.ToList();
var hasAnyWarnings = availableWarnings.Any();
dashboardWarnings = dashboardWarnings
.Where(x => string.IsNullOrWhiteSpace(severityName) || string.Equals(x.Severity, severityName, StringComparison.OrdinalIgnoreCase))
.Where(x => string.IsNullOrWhiteSpace(entityType) || string.Equals(x.EntityType, entityType, StringComparison.OrdinalIgnoreCase))
.Where(x => string.IsNullOrWhiteSpace(category) || string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase))
.Where(x => showAcknowledgedWarnings || !x.IsAcknowledged)
.OrderBy(x => WarningSeveritySort(x.Severity))
.ThenBy(x => x.BookDisplayName)
.ThenBy(x => x.ChapterDisplayName)
.ThenBy(x => x.SceneID ?? int.MaxValue)
.ThenBy(x => x.Category)
.ThenBy(x => x.WarningTypeDisplayName)
.ThenBy(x => x.Message)
.ToList();
return new WarningDashboardViewModel
{
Project = project,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
WarningTypeID = warningTypeId,
WarningSeverityID = warningSeverityId,
EntityType = entityType,
Category = category,
IncludeDismissed = includeDismissed,
IncludeIntentional = includeIntentional,
ShowAcknowledgedWarnings = showAcknowledgedWarnings,
Warnings = rows,
ProjectWarnings = dashboardWarnings,
WarningGroups = BuildWarningSeverityGroups(dashboardWarnings),
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
ChapterOptions = projectChapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == chapterId)).ToList(),
WarningTypeOptions = lookupData.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == warningTypeId)).ToList(),
SeverityOptions = lookupData.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == warningSeverityId)).ToList(),
EntityTypeOptions = availableWarnings.Select(x => x.EntityType).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, entityType, StringComparison.OrdinalIgnoreCase))).ToList(),
CategoryOptions = availableWarnings.Select(x => x.Category).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, category, StringComparison.OrdinalIgnoreCase))).ToList(),
CountsBySeverity = dashboardWarnings.GroupBy(x => x.Severity).ToDictionary(x => x.Key, x => x.Count()),
CountsByType = dashboardWarnings.GroupBy(x => x.WarningTypeDisplayName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()),
LastValidationSummary = summary,
HasAnyWarnings = hasAnyWarnings
};
}
private static ContinuityFilter BuildWarningContinuityFilter(int projectId, int? bookId, int? chapterId, int? sceneId)
{
var filter = new ContinuityFilter
{
ProjectID = projectId,
DisplayMode = "ContinuityWarnings",
ShowAcknowledgedWarnings = true
};
if (sceneId.HasValue)
{
filter.SceneRangeMode = "Custom";
filter.StartSceneID = sceneId;
filter.EndSceneID = sceneId;
}
else if (chapterId.HasValue)
{
filter.SceneRangeMode = "Chapter";
filter.ChapterID = chapterId;
}
else if (bookId.HasValue)
{
filter.SceneRangeMode = "Book";
filter.BookID = bookId;
}
return filter;
}
private static ProjectWarningViewModel ToProjectWarning(
ContinuityWarning warning,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
var bookDisplayName = warning.BookID.HasValue && booksById.TryGetValue(warning.BookID.Value, out var book)
? $"Book {book.BookNumber:g}: {book.BookDisplayTitle}"
: warning.BookDisplayTitle;
var chapterDisplayName = warning.ChapterID.HasValue && chaptersById.TryGetValue(warning.ChapterID.Value, out var chapter)
? $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}"
: warning.ChapterNumber.HasValue
? $"Chapter {warning.ChapterNumber:g}: {warning.ChapterTitle}"
: string.Empty;
return new ProjectWarningViewModel
{
Source = "Persisted",
ContinuityWarningID = warning.ContinuityWarningID,
WarningType = warning.WarningTypeName,
WarningTypeDisplayName = warning.WarningTypeName,
Category = WarningCategoryForPersisted(warning),
Severity = warning.SeverityName,
Message = warning.Message,
Details = warning.Details,
BookID = warning.BookID,
BookDisplayName = bookDisplayName,
ChapterID = warning.ChapterID,
ChapterDisplayName = chapterDisplayName,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneID.HasValue
? SceneLabel(warning.SceneNumber ?? 0, warning.SceneTitle ?? string.Empty)
: string.Empty,
EntityType = warning.EntityType,
IsDismissed = warning.IsDismissed,
IsIntentional = warning.IsIntentional,
LastDetectedDate = warning.LastDetectedDate
};
}
private static ProjectWarningViewModel ToProjectWarning(ContinuityWarningViewModel warning)
{
return new ProjectWarningViewModel
{
Source = "Generated",
WarningType = warning.WarningType,
WarningTypeDisplayName = warning.WarningTypeDisplayName,
Category = WarningCategoryForGenerated(warning),
Severity = warning.Severity,
Message = warning.Message,
BookID = warning.BookID,
BookDisplayName = warning.BookDisplayName,
ChapterID = warning.ChapterID,
ChapterDisplayName = warning.ChapterDisplayName,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneDisplayName,
CharacterID = warning.CharacterID,
CharacterName = warning.CharacterName,
AssetID = warning.AssetID,
EntityType = GeneratedEntityType(warning),
IsAcknowledged = warning.IsAcknowledged,
AcknowledgementNotes = warning.AcknowledgementNotes,
WarningKey = warning.WarningKey,
KnowledgeId = warning.KnowledgeId,
KnowledgeTitle = warning.KnowledgeTitle,
KnowledgeDescription = warning.KnowledgeDescription,
PreviousKnowledgeState = warning.PreviousKnowledgeState,
CurrentKnowledgeState = warning.CurrentKnowledgeState,
PreviousBookId = warning.PreviousBookId,
PreviousChapterId = warning.PreviousChapterId,
PreviousSceneId = warning.PreviousSceneId,
PreviousSceneDisplayName = warning.PreviousSceneDisplayName,
PreviousContextSceneDisplayName = warning.PreviousContextSceneDisplayName,
CurrentBookId = warning.CurrentBookId,
CurrentChapterId = warning.CurrentChapterId,
CurrentSceneId = warning.CurrentSceneId,
CurrentSceneDisplayName = warning.CurrentSceneDisplayName,
CurrentContextSceneDisplayName = warning.CurrentContextSceneDisplayName,
EstablishedSceneId = warning.EstablishedSceneId,
EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName,
ReferencedSceneId = warning.ReferencedSceneId,
ReferencedSceneDisplayName = warning.ReferencedSceneDisplayName,
LastDetectedDate = DateTime.UtcNow
};
}
private static void ApplyWarningDisplayContext(
List<ProjectWarningViewModel> warnings,
int? scopedBookId,
int? scopedChapterId,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
foreach (var warning in warnings)
{
warning.ContextSceneDisplayName = FormatWarningSceneContext(
warning.BookID,
warning.BookDisplayName,
warning.ChapterID,
warning.ChapterDisplayName,
warning.SceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.PreviousContextSceneDisplayName = FormatWarningSceneContext(
warning.PreviousBookId,
string.Empty,
warning.PreviousChapterId,
string.Empty,
warning.PreviousSceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.CurrentContextSceneDisplayName = FormatWarningSceneContext(
warning.CurrentBookId,
string.Empty,
warning.CurrentChapterId,
string.Empty,
warning.CurrentSceneDisplayName,
scopedBookId,
scopedChapterId,
booksById,
chaptersById);
warning.EstablishedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.EstablishedSceneDisplayName)
? string.Empty
: warning.EstablishedSceneDisplayName;
warning.ReferencedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.ReferencedSceneDisplayName)
? string.Empty
: warning.ReferencedSceneDisplayName;
ApplyInvestigationTarget(warning);
}
}
private static string FormatWarningSceneContext(
int? bookId,
string bookDisplayName,
int? chapterId,
string chapterDisplayName,
string sceneDisplayName,
int? scopedBookId,
int? scopedChapterId,
IReadOnlyDictionary<int, Book> booksById,
IReadOnlyDictionary<int, Chapter> chaptersById)
{
if (string.IsNullOrWhiteSpace(sceneDisplayName))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(bookDisplayName) && bookId.HasValue && booksById.TryGetValue(bookId.Value, out var book))
{
bookDisplayName = $"Book {book.BookNumber:g}: {book.BookDisplayTitle}";
}
if (string.IsNullOrWhiteSpace(chapterDisplayName) && chapterId.HasValue && chaptersById.TryGetValue(chapterId.Value, out var chapter))
{
chapterDisplayName = $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}";
}
if (scopedChapterId.HasValue)
{
return sceneDisplayName;
}
if (scopedBookId.HasValue || (bookId.HasValue && scopedBookId == bookId))
{
return string.IsNullOrWhiteSpace(chapterDisplayName)
? sceneDisplayName
: $"{chapterDisplayName} • {sceneDisplayName}";
}
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(bookDisplayName))
{
parts.Add(bookDisplayName);
}
if (!string.IsNullOrWhiteSpace(chapterDisplayName))
{
parts.Add(chapterDisplayName);
}
parts.Add(sceneDisplayName);
return string.Join(" • ", parts);
}
private static void ApplyInvestigationTarget(ProjectWarningViewModel warning)
{
if (warning.Source != "Generated")
{
return;
}
warning.CanInvestigate = true;
warning.InvestigationDisplayMode = warning.WarningTypeDisplayName switch
{
"Multiple Character Locations" => "LocationActivity",
_ => "Journey"
};
warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID;
}
private static IReadOnlyList<WarningSeverityGroupViewModel> BuildWarningSeverityGroups(IReadOnlyList<ProjectWarningViewModel> warnings)
{
var groups = warnings
.GroupBy(x => WarningSeverityGroupLabel(x.Severity))
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);
return new[] { "Critical", "Warning", "Information" }
.Where(groups.ContainsKey)
.Select(severity => new WarningSeverityGroupViewModel
{
Severity = severity,
IsExpanded = severity is "Critical" or "Warning",
Warnings = groups[severity]
})
.ToList();
}
private static string WarningSeverityGroupLabel(string severity)
{
return severity switch
{
"Critical" or "Error" => "Critical",
"Warning" => "Warning",
_ => "Information"
};
}
private static string WarningCategoryForPersisted(ContinuityWarning warning)
{
return warning.EntityType switch
{
"Character" => "Character",
"Asset" or "StoryAsset" => "Asset",
"Scene" => "Timeline",
"Relationship" => "Character",
"PlotThread" => "Plot Thread",
_ => "General"
};
}
private static string WarningCategoryForGenerated(ContinuityWarningViewModel warning)
{
if (string.Equals(warning.WarningTypeDisplayName, "Knowledge Regression", StringComparison.OrdinalIgnoreCase))
{
return "Knowledge";
}
return warning.WarningTypeDisplayName switch
{
"Multiple Character Locations" => "Character",
"Characters With Unknown Locations" => "Character",
"Asset Location Conflict" => "Asset",
"Assets With Unknown Locations" => "Asset",
_ => "Continuity"
};
}
private static string GeneratedEntityType(ContinuityWarningViewModel warning)
{
if (warning.CharacterID.HasValue)
{
return "Character";
}
if (warning.AssetID.HasValue)
{
return "Asset";
}
return "Continuity";
}
private static int WarningSeveritySort(string severity)
{
return severity switch
{
"Critical" => 0,
"Warning" => 1,
_ => 2
};
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{
return string.IsNullOrWhiteSpace(sceneTitle)
? $"Scene {sceneNumber:g}"
: $"Scene {sceneNumber:g}: {sceneTitle}";
}
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 Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
storyState.AcknowledgeWarningAsync(acknowledgement);
public Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
storyState.RestoreWarningAsync(acknowledgement);
}
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.BookDisplayTitle}", 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 characterImageLookup = (await characters.ListCharactersAsync(projectId)).ToDictionary(x => x.CharacterID);
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,
AvatarImagePath = characterImageLookup.TryGetValue(x.CharacterID, out var characterImages) ? characterImages.AvatarImagePath : x.AvatarImagePath,
AvatarThumbnailPath = characterImages?.AvatarThumbnailPath ?? x.AvatarThumbnailPath,
ImagePath = characterImages?.ImagePath ?? x.ImagePath,
ThumbnailPath = characterImages?.ThumbnailPath ?? x.ThumbnailPath,
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.BookDisplayTitle}", 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.BookDisplayTitle} / 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?.BookDisplayTitle ?? "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?.BookDisplayTitle ?? "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?.BookDisplayTitle ?? "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"),
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 async Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model)
{
var existing = await writerWorkspace.GetWorkflowAsync(model.SceneID);
var priority = model.Priority.HasValue
? Math.Clamp(model.Priority.Value, 1, 10)
: (int?)null;
await writerWorkspace.SaveWorkflowAsync(new SceneWorkflow
{
SceneID = model.SceneID,
DraftStatus = existing.DraftStatus,
EstimatedWordCount = model.EstimatedWordCount,
ActualWordCount = model.ActualWordCount,
DraftedDate = model.DraftedDate,
LastWorkedOn = model.LastWorkedOn,
ReadyForDraft = existing.ReadyForDraft,
ReadyForRevision = existing.ReadyForRevision,
ReadyForPolish = existing.ReadyForPolish,
IsBlocked = model.IsBlocked,
BlockedReason = model.BlockedReason,
Priority = priority
});
}
public Task<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 WritingScheduleService(
IWritingScheduleRepository writingSchedule,
IProjectRepository projects,
IBookRepository books,
ICurrentUserService currentUser) : IWritingScheduleService
{
private static readonly IReadOnlyList<string> WritingDays =
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
];
private static readonly IReadOnlyList<int> AllowedSessionLengths = [30, 60, 90, 120, 150, 180, 240, 300, 360, 480, 600, 720];
private sealed record WritingDayAvailability(string Day, DayOfWeek DayOfWeek, int Minutes);
private sealed record AvailableWritingDate(DateTime Date, int Minutes);
private sealed record ScheduleTaskEstimate(WritingScheduleScene Scene, string TaskType, int EstimatedMinutes, int? TargetWords);
public async Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId)
{
var plans = await writingSchedule.GetWritingPlansByUserAsync(userId);
return plans.Select(ToViewModel).ToList();
}
public async Task<WritingPlanViewModel?> GetWritingPlanByIDAsync(int writingPlanId)
{
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
return plan is null ? null : ToViewModel(plan);
}
public Task<int> CreateWritingPlanAsync(WritingPlanViewModel model)
{
ValidatePlan(model);
return writingSchedule.CreateWritingPlanAsync(ToModel(model));
}
public Task UpdateWritingPlanAsync(WritingPlanViewModel model)
{
ValidatePlan(model);
return writingSchedule.UpdateWritingPlanAsync(ToModel(model));
}
public async Task DeleteWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
await writingSchedule.DeleteWritingPlanAsync(writingPlanId);
}
public async Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive);
}
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByPlanAsync(int writingPlanId)
{
var items = await writingSchedule.GetScheduleItemsByPlanAsync(writingPlanId);
return items.Select(ToViewModel).ToList();
}
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate)
{
var items = await writingSchedule.GetScheduleItemsByDateRangeAsync(writingPlanId, startDate, endDate);
return items.Select(ToViewModel).ToList();
}
public Task<int> CreateScheduleItemAsync(WritingScheduleItemViewModel model)
{
ValidateScheduleItem(model);
return writingSchedule.CreateScheduleItemAsync(ToModel(model));
}
public Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model)
{
ValidateScheduleItem(model);
return writingSchedule.UpdateScheduleItemAsync(ToModel(model));
}
public Task DeleteScheduleItemAsync(int writingScheduleItemId) =>
writingSchedule.DeleteScheduleItemAsync(writingScheduleItemId);
public Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) =>
writingSchedule.UpdateScheduleItemStatusAsync(writingScheduleItemId, scheduleStatus, completedDateUtc);
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId)
{
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId)
?? throw new InvalidOperationException("Writing plan not found.");
if (currentUser.UserId.HasValue && plan.UserID != currentUser.UserId.Value)
{
throw new InvalidOperationException("Writing plan not found.");
}
var planViewModel = ToViewModel(plan);
ValidatePlan(planViewModel);
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Available days must include at least one valid day.");
}
var scenes = await writingSchedule.GetSchedulingScenesAsync(plan.ProjectID, plan.BookID, plan.IncludeBlockedScenes);
if (scenes.Count == 0)
{
await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, []);
return [];
}
var availableDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availability);
var items = BuildGeneratedScheduleItems(plan.WritingPlanID, scenes, availableDates);
await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items);
var savedItems = await writingSchedule.GetScheduleItemsByPlanAsync(plan.WritingPlanID);
return savedItems.Select(ToViewModel).ToList();
}
public async Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId)
{
if (!currentUser.UserId.HasValue)
{
return new WritingSchedulePreviewViewModel();
}
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value);
var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today);
var selectedPlan = writingPlanId.HasValue
? plans.FirstOrDefault(x => x.WritingPlanID == writingPlanId.Value)
: plans.FirstOrDefault();
var items = selectedPlan is null
? []
: await writingSchedule.GetSchedulePreviewItemsAsync(selectedPlan.WritingPlanID);
return new WritingSchedulePreviewViewModel
{
SelectedWritingPlanID = selectedPlan?.WritingPlanID,
Plans = plans,
PlansRequiringRebalance = plansRequiringRebalance,
SelectedPlan = selectedPlan,
Items = items
};
}
public async Task DeleteScheduleAsync(int writingPlanId)
{
if (!currentUser.UserId.HasValue)
{
return;
}
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value);
if (plans.Any(x => x.WritingPlanID == writingPlanId))
{
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
}
}
public async Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync()
{
if (!currentUser.UserId.HasValue)
{
return new WritingPlansManagementViewModel();
}
var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value);
foreach (var plan in plans)
{
plan.AvailabilitySummary = FormatAvailabilitySummary(plan.AvailableDaysJson, plan.SessionLengthMinutes);
}
return new WritingPlansManagementViewModel { Plans = plans };
}
public async Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId)
{
var plan = await GetPlanForCurrentUserAsync(writingPlanId);
var project = await projects.GetAsync(plan.ProjectID);
var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null;
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
var selectedDays = availability
.Select(x => x.Day)
.ToList();
var model = new WritingPlanSettingsEditViewModel
{
WritingPlanID = plan.WritingPlanID,
PlanName = plan.PlanName,
ProjectName = project?.ProjectName ?? string.Empty,
BookName = book?.BookDisplayTitle ?? "All Books",
GoalType = plan.GoalType,
StartDate = plan.StartDate.Date,
DeadlineDate = plan.DeadlineDate.Date,
SessionLengthMinutes = plan.SessionLengthMinutes,
SelectedWritingDays = selectedDays,
WritingDayAvailability = BuildWritingDayAvailabilityInputs(availability, plan.SessionLengthMinutes).ToList(),
IncludeBlockedScenes = plan.IncludeBlockedScenes,
RebalanceMode = plan.RebalanceMode,
IsActive = plan.IsActive
};
PopulatePlanSettingsOptions(model);
return model;
}
public async Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model)
{
var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID);
ValidatePlanSettings(model);
var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
plan.PlanName = model.PlanName.Trim();
plan.DeadlineDate = model.DeadlineDate.Date;
plan.AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability));
plan.SessionLengthMinutes = availability.First().Minutes;
plan.IncludeBlockedScenes = model.IncludeBlockedScenes;
plan.RebalanceMode = model.RebalanceMode;
plan.IsActive = model.IsActive;
await writingSchedule.UpdateWritingPlanAsync(plan);
}
public async Task ActivateWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, true);
}
public async Task DeactivateWritingPlanAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, false);
}
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId)
{
await GetPlanForCurrentUserAsync(writingPlanId);
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
return await GenerateSchedule(writingPlanId);
}
private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model)
{
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
model.SessionLengthOptions = SessionLengthOptionList();
if (model.WritingDayAvailability.Count == 0)
{
model.WritingDayAvailability = BuildWritingDayAvailabilityInputs(
model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
}
public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: [];
var selectedProjectId = projectId.HasValue && projectRows.Any(x => x.ProjectID == projectId.Value)
? projectId
: projectRows.FirstOrDefault()?.ProjectID;
var model = new WritingScheduleSetupViewModel
{
ProjectID = selectedProjectId,
StartDate = DateTime.Today,
DeadlineDate = DateTime.Today.AddMonths(1)
};
await PopulateScheduleSetupOptionsAsync(model);
return model;
}
public async Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: [];
var selectedProject = model.ProjectID.HasValue
? projectRows.FirstOrDefault(x => x.ProjectID == model.ProjectID.Value)
: null;
var bookRows = selectedProject is null
? []
: await books.ListByProjectAsync(selectedProject.ProjectID);
model.ProjectOptions = projectRows
.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == model.ProjectID))
.ToList();
model.BookOptions = bookRows
.Select(x => new SelectListItem(x.BookDisplayTitle, x.BookID.ToString(), x.BookID == model.BookID))
.ToList();
model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType);
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
model.SessionLengthOptions = SessionLengthOptionList();
if (model.WritingDayAvailability.Count == 0)
{
model.WritingDayAvailability = BuildWritingDayAvailabilityInputs(
model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
}
public async Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model)
{
if (!currentUser.UserId.HasValue)
{
throw new InvalidOperationException("Sign in before creating a writing schedule.");
}
ValidateSetup(model);
var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
var projectRows = await projects.ListForUserAsync(currentUser.UserId.Value);
if (!model.ProjectID.HasValue || !projectRows.Any(x => x.ProjectID == model.ProjectID.Value))
{
throw new InvalidOperationException("Choose a valid project.");
}
if (model.BookID.HasValue)
{
var bookRows = await books.ListByProjectAsync(model.ProjectID.Value);
if (!bookRows.Any(x => x.BookID == model.BookID.Value))
{
throw new InvalidOperationException("Choose a valid book.");
}
}
var planId = await CreateWritingPlanAsync(new WritingPlanViewModel
{
UserID = currentUser.UserId.Value,
ProjectID = model.ProjectID!.Value,
BookID = model.BookID,
PlanName = model.PlanName.Trim(),
GoalType = model.GoalType,
StartDate = model.StartDate.Date,
DeadlineDate = model.DeadlineDate.Date,
AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability)),
SessionLengthMinutes = availability.First().Minutes,
IncludeBlockedScenes = model.IncludeBlockedScenes,
RebalanceMode = model.RebalanceMode,
IsActive = true
});
try
{
var generatedItems = await GenerateSchedule(planId);
return new WritingScheduleSetupResult
{
WritingPlanID = planId,
GeneratedItemCount = generatedItems.Count
};
}
catch (InvalidOperationException ex)
{
return new WritingScheduleSetupResult
{
WritingPlanID = planId,
ValidationMessage = ex.Message
};
}
}
public async Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync(int? projectId = null)
{
if (!currentUser.UserId.HasValue)
{
return new WritingScheduleDashboardViewModel();
}
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value, projectId);
var activePlanCount = plans.Count(x => x.IsActive);
var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today, projectId);
var items = await writingSchedule.GetActiveScheduleDashboardItemsAsync(currentUser.UserId.Value, projectId);
var today = DateTime.Today;
var plannedItems = items
.Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase))
.ToList();
return new WritingScheduleDashboardViewModel
{
ActivePlanCount = activePlanCount,
RebalancePlanCount = plansRequiringRebalance.Count,
TodayTasks = plannedItems
.Where(x => x.ScheduledDate.Date == today)
.OrderByDescending(x => x.Priority ?? 0)
.ThenBy(x => x.ScheduledDate)
.ThenBy(x => x.ChapterSortOrder)
.ThenBy(x => x.SceneSortOrder)
.ThenBy(x => x.WritingScheduleItemID)
.ToList(),
UpcomingTasks = plannedItems
.Where(x => x.ScheduledDate.Date > today)
.OrderBy(x => x.ScheduledDate)
.ThenBy(x => x.WritingScheduleItemID)
.Take(5)
.ToList(),
TotalPlannedTasks = items.Count,
CompletedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Done", StringComparison.OrdinalIgnoreCase)),
SkippedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Skipped", StringComparison.OrdinalIgnoreCase)),
RemainingTasks = plannedItems.Count,
ProjectedFinishDate = plannedItems.Count == 0 ? null : plannedItems.Max(x => x.ScheduledDate)
};
}
public async Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId)
{
if (!currentUser.UserId.HasValue)
{
return false;
}
var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync(
writingScheduleItemId,
currentUser.UserId.Value,
"Done",
DateTime.UtcNow);
return affectedRows > 0;
}
public async Task<bool> SkipScheduleItemAsync(int writingScheduleItemId)
{
if (!currentUser.UserId.HasValue)
{
return false;
}
var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync(
writingScheduleItemId,
currentUser.UserId.Value,
"Skipped",
null);
return affectedRows > 0;
}
public async Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int? projectId = null)
{
if (!currentUser.UserId.HasValue)
{
return [];
}
return await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today, projectId);
}
public async Task<WritingScheduleRebalancePreviewViewModel> PreviewRebalanceAsync(int writingPlanId)
{
var plan = await GetActivePlanForCurrentUserAsync(writingPlanId);
var plannedItems = (await writingSchedule.GetSchedulePreviewItemsAsync(plan.WritingPlanID))
.Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase))
.ToList();
if (plannedItems.Count == 0)
{
return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID };
}
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Available days must include at least one valid day.");
}
var proposedDates = GetNextAvailableDates(DateTime.Today, availability, plannedItems);
var changes = plannedItems
.Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] })
.Where(x => x.Item.ScheduledDate.Date != x.ProposedDate.Date)
.Select(x => new WritingScheduleRebalanceChangeViewModel
{
WritingScheduleItemID = x.Item.WritingScheduleItemID,
TaskType = x.Item.TaskType,
SceneName = $"Scene {x.Item.SceneNumber}: {x.Item.SceneTitle}",
CurrentDate = x.Item.ScheduledDate.Date,
ProposedDate = x.ProposedDate.Date
})
.ToList();
return new WritingScheduleRebalancePreviewViewModel
{
WritingPlanID = plan.WritingPlanID,
OriginalFinishDate = plannedItems.Max(x => x.ScheduledDate.Date),
ProposedFinishDate = proposedDates.Max(),
Changes = changes
};
}
public async Task<WritingScheduleRebalancePreviewViewModel> ApplyRebalanceAsync(int writingPlanId)
{
var preview = await PreviewRebalanceAsync(writingPlanId);
await writingSchedule.UpdatePlannedScheduleItemDatesAsync(
preview.WritingPlanID,
preview.Changes
.Select(x => new WritingScheduleItemDateChange
{
WritingScheduleItemID = x.WritingScheduleItemID,
ScheduledDate = x.ProposedDate
})
.ToList());
return preview;
}
private async Task<WritingPlan> GetActivePlanForCurrentUserAsync(int writingPlanId)
{
if (!currentUser.UserId.HasValue)
{
throw new InvalidOperationException("Sign in before rebalancing a writing schedule.");
}
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
if (plan is null || plan.UserID != currentUser.UserId.Value || !plan.IsActive)
{
throw new InvalidOperationException("Writing plan not found or is not active.");
}
return plan;
}
private async Task<WritingPlan> GetPlanForCurrentUserAsync(int writingPlanId)
{
if (!currentUser.UserId.HasValue)
{
throw new InvalidOperationException("Sign in before managing a writing schedule.");
}
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
if (plan is null || plan.UserID != currentUser.UserId.Value)
{
throw new InvalidOperationException("Writing plan not found.");
}
return plan;
}
private static void ValidateSetup(WritingScheduleSetupViewModel model)
{
if (string.IsNullOrWhiteSpace(model.PlanName))
{
throw new InvalidOperationException("Plan name is required.");
}
if (!model.ProjectID.HasValue)
{
throw new InvalidOperationException("Choose a project.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
throw new InvalidOperationException("Deadline date must be on or after the start date.");
}
ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
}
private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model)
{
if (string.IsNullOrWhiteSpace(model.PlanName))
{
throw new InvalidOperationException("Plan name is required.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
throw new InvalidOperationException("Deadline date must be on or after the start date.");
}
ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
}
private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue)
=> values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList();
private static IReadOnlyList<SelectListItem> SessionLengthOptionList()
=>
[
new("30 minutes", "30"),
new("1 hour", "60"),
new("1 1/2 hours", "90"),
new("2 hours", "120"),
new("2 1/2 hours", "150"),
new("3 hours", "180"),
new("4 hours", "240"),
new("5 hours", "300"),
new("6 hours", "360"),
new("8 hours", "480"),
new("10 hours", "600"),
new("12 hours", "720")
];
private static void ValidateAvailability(IReadOnlyList<WritingDayAvailabilityViewModel> availabilityInputs, int fallbackSessionLengthMinutes)
{
var availability = NormaliseWritingAvailability(availabilityInputs, fallbackSessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
}
private static IReadOnlyList<WritingDayAvailability> NormaliseWritingAvailability(IReadOnlyList<WritingDayAvailabilityViewModel>? availabilityInputs, int fallbackSessionLengthMinutes)
{
var inputs = availabilityInputs ?? [];
var availability = new List<WritingDayAvailability>();
foreach (var day in WritingDays)
{
var input = inputs.FirstOrDefault(x => string.Equals(x.Day, day, StringComparison.OrdinalIgnoreCase));
if (input is null || !input.IsAvailable)
{
continue;
}
var minutes = input.Minutes > 0 ? input.Minutes : fallbackSessionLengthMinutes;
if (!AllowedSessionLengths.Contains(minutes))
{
throw new InvalidOperationException("Selected writing days must have a valid session length.");
}
if (TryBuildAvailability(day, minutes, out var dayAvailability))
{
availability.Add(dayAvailability);
}
}
return availability;
}
private static IReadOnlyList<WritingDayAvailabilityViewModel> BuildWritingDayAvailabilityInputs(IReadOnlyList<WritingDayAvailability> availability, int fallbackSessionLengthMinutes)
{
var availabilityByDay = availability.ToDictionary(x => x.Day, StringComparer.OrdinalIgnoreCase);
var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes) ? fallbackSessionLengthMinutes : 60;
return WritingDays
.Select(day =>
{
availabilityByDay.TryGetValue(day, out var dayAvailability);
return new WritingDayAvailabilityViewModel
{
Day = day,
IsAvailable = dayAvailability is not null,
Minutes = dayAvailability?.Minutes ?? fallbackMinutes
};
})
.ToList();
}
private static IReadOnlyList<object> ToAvailabilityJson(IReadOnlyList<WritingDayAvailability> availability)
=> availability.Select(x => new { day = x.Day, minutes = x.Minutes }).Cast<object>().ToList();
private static bool TryBuildAvailability(string? dayName, int minutes, out WritingDayAvailability availability)
{
availability = default!;
if (string.IsNullOrWhiteSpace(dayName)
|| !Enum.TryParse<DayOfWeek>(dayName, ignoreCase: true, out var dayOfWeek)
|| !AllowedSessionLengths.Contains(minutes))
{
return false;
}
var canonicalDay = WritingDays.FirstOrDefault(day => string.Equals(day, dayName, StringComparison.OrdinalIgnoreCase));
if (canonicalDay is null)
{
return false;
}
availability = new WritingDayAvailability(canonicalDay, dayOfWeek, minutes);
return true;
}
private static IReadOnlyList<WritingDayAvailability> OrderAvailability(IEnumerable<WritingDayAvailability> availability)
{
var order = WritingDays.Select((day, index) => new { day, index }).ToDictionary(x => x.day, x => x.index, StringComparer.OrdinalIgnoreCase);
return availability
.OrderBy(x => order.TryGetValue(x.Day, out var index) ? index : int.MaxValue)
.ToList();
}
private static string FormatAvailabilitySummary(string? availableDaysJson, int fallbackSessionLengthMinutes)
{
var availability = ParseWritingAvailability(availableDaysJson, fallbackSessionLengthMinutes);
return availability.Count == 0
? "-"
: string.Join(", ", availability.Select(x => $"{x.Day[..3]} {FormatMinutes(x.Minutes)}"));
}
private static string FormatMinutes(int minutes)
{
if (minutes < 60)
{
return $"{minutes}m";
}
var hours = minutes / 60;
var remainder = minutes % 60;
return remainder == 0
? $"{hours}h"
: $"{hours}.{remainder / 30 * 5}h";
}
private static void ValidatePlan(WritingPlanViewModel model)
{
if (model.DeadlineDate.Date < model.StartDate.Date)
{
throw new InvalidOperationException("Deadline date must be on or after the start date.");
}
if (model.SessionLengthMinutes <= 0)
{
throw new InvalidOperationException("Session length must be greater than zero.");
}
}
private static void ValidateScheduleItem(WritingScheduleItemViewModel model)
{
if (model.TargetWords < 0)
{
throw new InvalidOperationException("Target words cannot be negative.");
}
if (model.EstimatedMinutes < 0)
{
throw new InvalidOperationException("Estimated minutes cannot be negative.");
}
}
private static IReadOnlyList<WritingDayAvailability> ParseWritingAvailability(string? availableDaysJson, int fallbackSessionLengthMinutes)
{
if (string.IsNullOrWhiteSpace(availableDaysJson))
{
return [];
}
try
{
using var document = JsonDocument.Parse(availableDaysJson);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return [];
}
var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes)
? fallbackSessionLengthMinutes
: 60;
var availability = new List<WritingDayAvailability>();
foreach (var element in document.RootElement.EnumerateArray())
{
string? dayName = null;
var minutes = fallbackMinutes;
if (element.ValueKind == JsonValueKind.String)
{
dayName = element.GetString();
}
else if (element.ValueKind == JsonValueKind.Object)
{
if (element.TryGetProperty("day", out var dayProperty) && dayProperty.ValueKind == JsonValueKind.String)
{
dayName = dayProperty.GetString();
}
else if (element.TryGetProperty("Day", out var pascalDayProperty) && pascalDayProperty.ValueKind == JsonValueKind.String)
{
dayName = pascalDayProperty.GetString();
}
if (element.TryGetProperty("minutes", out var minutesProperty) && minutesProperty.TryGetInt32(out var parsedMinutes))
{
minutes = parsedMinutes;
}
else if (element.TryGetProperty("Minutes", out var pascalMinutesProperty) && pascalMinutesProperty.TryGetInt32(out var parsedPascalMinutes))
{
minutes = parsedPascalMinutes;
}
}
if (TryBuildAvailability(dayName, minutes, out var dayAvailability)
&& !availability.Any(x => x.DayOfWeek == dayAvailability.DayOfWeek))
{
availability.Add(dayAvailability);
}
}
return OrderAvailability(availability);
}
catch (JsonException)
{
return [];
}
}
private static IReadOnlyList<AvailableWritingDate> GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlyList<WritingDayAvailability> availability)
{
var dates = new List<AvailableWritingDate>();
var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek);
for (var date = startDate.Date; date <= deadlineDate.Date; date = date.AddDays(1))
{
if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability))
{
dates.Add(new AvailableWritingDate(date, dayAvailability.Minutes));
}
}
return dates;
}
private static IReadOnlyList<DateTime> GetNextAvailableDates(DateTime startDate, IReadOnlyList<WritingDayAvailability> availability, IReadOnlyList<WritingSchedulePreviewItem> plannedItems)
{
var dates = new List<DateTime>();
var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek);
var maxAvailableMinutes = availability.Count == 0 ? 0 : availability.Max(x => x.Minutes);
if (plannedItems.Any(x => (x.EstimatedMinutes ?? 0) > maxAvailableMinutes))
{
throw new InvalidOperationException("One or more schedule items exceed the available session lengths for this plan.");
}
foreach (var item in plannedItems)
{
var estimatedMinutes = item.EstimatedMinutes ?? 0;
var placed = false;
for (var date = dates.Count == 0 ? startDate.Date : dates[^1].AddDays(1); !placed; date = date.AddDays(1))
{
if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability)
&& dayAvailability.Minutes >= estimatedMinutes)
{
dates.Add(date);
placed = true;
}
}
}
return dates;
}
private static IReadOnlyList<WritingScheduleItem> BuildGeneratedScheduleItems(int writingPlanId, IReadOnlyList<WritingScheduleScene> scenes, IReadOnlyList<AvailableWritingDate> availableDates)
{
var items = new List<WritingScheduleItem>();
var dateIndex = 0;
foreach (var scene in scenes)
{
var task = CreateTaskEstimate(scene);
var taskDates = new List<AvailableWritingDate>();
var remainingMinutes = task.EstimatedMinutes;
while (remainingMinutes > 0)
{
if (dateIndex >= availableDates.Count)
{
throw new InvalidOperationException("Insufficient available writing sessions before the selected deadline.");
}
var availableDate = availableDates[dateIndex++];
taskDates.Add(availableDate);
remainingMinutes -= Math.Min(remainingMinutes, availableDate.Minutes);
}
var partCount = taskDates.Count;
var remainingTaskMinutes = task.EstimatedMinutes;
var assignedWords = 0;
for (var partIndex = 0; partIndex < partCount; partIndex++)
{
var partMinutes = Math.Min(remainingTaskMinutes, taskDates[partIndex].Minutes);
remainingTaskMinutes -= partMinutes;
int? targetWords = null;
if (task.TargetWords.HasValue)
{
targetWords = partIndex == partCount - 1
? task.TargetWords.Value - assignedWords
: (int)Math.Round(task.TargetWords.Value * (partMinutes / (double)task.EstimatedMinutes), MidpointRounding.AwayFromZero);
assignedWords += targetWords.Value;
}
items.Add(new WritingScheduleItem
{
WritingPlanID = writingPlanId,
SceneID = task.Scene.SceneID,
ScheduledDate = taskDates[partIndex].Date,
TaskType = task.TaskType,
EstimatedMinutes = partMinutes,
TargetWords = targetWords,
ScheduleStatus = "Planned",
Notes = partCount > 1 ? $"Part {partIndex + 1} of {partCount}" : null
});
}
}
return items;
}
private static ScheduleTaskEstimate CreateTaskEstimate(WritingScheduleScene scene)
{
var (taskType, estimatedMinutes, targetWords) = scene.RevisionStatusName switch
{
"Outlined" => ("Draft", EstimateDraftMinutes(scene.EstimatedWordCount), scene.EstimatedWordCount > 0 ? scene.EstimatedWordCount : null),
"Needs Work" => ("Revise", 45, null),
"Revised" => ("Polish", 30, null),
_ => throw new InvalidOperationException("Scene revision status is not schedulable.")
};
return new ScheduleTaskEstimate(scene, taskType, estimatedMinutes, targetWords);
}
private static int EstimateDraftMinutes(int? estimatedWordCount)
=> estimatedWordCount is > 0
? (int)Math.Ceiling(estimatedWordCount.GetValueOrDefault() / 750.0 * 60)
: 90;
private static WritingPlanViewModel ToViewModel(WritingPlan plan) => new()
{
WritingPlanID = plan.WritingPlanID,
UserID = plan.UserID,
ProjectID = plan.ProjectID,
BookID = plan.BookID,
PlanName = plan.PlanName,
GoalType = plan.GoalType,
StartDate = plan.StartDate,
DeadlineDate = plan.DeadlineDate,
AvailableDaysJson = plan.AvailableDaysJson,
SessionLengthMinutes = plan.SessionLengthMinutes,
IncludeBlockedScenes = plan.IncludeBlockedScenes,
RebalanceMode = plan.RebalanceMode,
IsActive = plan.IsActive,
CreatedDateUtc = plan.CreatedDateUtc,
ModifiedDateUtc = plan.ModifiedDateUtc
};
private static WritingPlan ToModel(WritingPlanViewModel model) => new()
{
WritingPlanID = model.WritingPlanID,
UserID = model.UserID,
ProjectID = model.ProjectID,
BookID = model.BookID,
PlanName = model.PlanName,
GoalType = model.GoalType,
StartDate = model.StartDate,
DeadlineDate = model.DeadlineDate,
AvailableDaysJson = model.AvailableDaysJson,
SessionLengthMinutes = model.SessionLengthMinutes,
IncludeBlockedScenes = model.IncludeBlockedScenes,
RebalanceMode = model.RebalanceMode,
IsActive = model.IsActive,
CreatedDateUtc = model.CreatedDateUtc,
ModifiedDateUtc = model.ModifiedDateUtc
};
private static WritingScheduleItemViewModel ToViewModel(WritingScheduleItem item) => new()
{
WritingScheduleItemID = item.WritingScheduleItemID,
WritingPlanID = item.WritingPlanID,
SceneID = item.SceneID,
ScheduledDate = item.ScheduledDate,
TaskType = item.TaskType,
EstimatedMinutes = item.EstimatedMinutes,
TargetWords = item.TargetWords,
ScheduleStatus = item.ScheduleStatus,
CompletedDateUtc = item.CompletedDateUtc,
Notes = item.Notes,
CreatedDateUtc = item.CreatedDateUtc,
ModifiedDateUtc = item.ModifiedDateUtc
};
private static WritingScheduleItem ToModel(WritingScheduleItemViewModel model) => new()
{
WritingScheduleItemID = model.WritingScheduleItemID,
WritingPlanID = model.WritingPlanID,
SceneID = model.SceneID,
ScheduledDate = model.ScheduledDate,
TaskType = model.TaskType,
EstimatedMinutes = model.EstimatedMinutes,
TargetWords = model.TargetWords,
ScheduleStatus = model.ScheduleStatus,
CompletedDateUtc = model.CompletedDateUtc,
Notes = model.Notes,
CreatedDateUtc = model.CreatedDateUtc,
ModifiedDateUtc = model.ModifiedDateUtc
};
}
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.BookDisplayTitle} / 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.BookDisplayTitle} / 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.BookDisplayTitle }))
{
builder.AppendLine(markdown ? $"### {book.Key.BookDisplayTitle}" : book.Key.BookDisplayTitle);
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.BookDisplayTitle} / 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.BookDisplayTitle} / 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.BookDisplayTitle}", 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,
IBookRepository books,
IBookCoverService bookCovers,
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 bookCoverPaths = string.Equals(entityType, "Book", StringComparison.OrdinalIgnoreCase)
? await books.GetCoverPathsAsync(entityId)
: null;
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);
}
if (result.Succeeded && bookCoverPaths is not null)
{
await bookCovers.DeleteCoverFilesAsync(bookCoverPaths);
}
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,
IVisualIdentityImageService visualIdentityImages) : 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,
Aliases = (await assets.ListAliasesAsync(asset.StoryAssetID)).Select(x => x.Alias).ToList(),
AssetKindID = asset.AssetKindID,
Description = asset.Description,
Importance = asset.Importance,
CurrentStateID = asset.CurrentStateID,
CurrentLocationID = asset.CurrentLocationID,
IsResolved = asset.IsResolved,
ShowInQuickAddBar = asset.ShowInQuickAddBar,
ImagePath = asset.ImagePath,
ThumbnailPath = asset.ThumbnailPath,
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 existing = isNew ? null : await assets.GetAssetAsync(model.StoryAssetID);
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,
ImagePath = model.ImagePath ?? existing?.ImagePath,
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
});
await SyncAssetAliasesAsync(assetId, model.Aliases);
var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID };
imageHost.StoryAssetID = assetId;
imageHost.ProjectID = model.ProjectID;
if (model.ImageUpload is { Length: > 0 } upload)
{
var result = await visualIdentityImages.UploadAssetImageAsync(imageHost, upload);
if (!result.Succeeded)
{
throw new EntityImageUploadException(result.ErrorMessage ?? "Asset image could not be uploaded.", assetId);
}
}
else if (model.RemoveImage && existing is not null)
{
await visualIdentityImages.RemoveAssetImageAsync(existing);
}
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 async Task SyncAssetAliasesAsync(int storyAssetId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await assets.ListAliasesAsync(storyAssetId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await assets.UpdateAliasAsync(row.AssetAliasID, alias, sortOrder);
}
else
{
await assets.AddAliasAsync(storyAssetId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await assets.DeleteAliasAsync(alias.AssetAliasID);
}
}
private static IReadOnlyList<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,
Aliases = (await locations.ListAliasesAsync(location.LocationID)).Select(x => x.Alias).ToList(),
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 SyncLocationAliasesAsync(locationId, model.Aliases);
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;
}
private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await locations.ListAliasesAsync(locationId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await locations.UpdateAliasAsync(row.LocationAliasID, alias, sortOrder);
}
else
{
await locations.AddAliasAsync(locationId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await locations.DeleteAliasAsync(alias.LocationAliasID);
}
}
}
public sealed class FloorPlanService(
IProjectRepository projects,
ISceneRepository scenes,
ISceneFloorPlanOccupancyRepository sceneOccupancy,
IFloorPlanRepository floorPlans,
ILocationRepository locations,
ICharacterRepository characters,
IFloorPlanBackgroundImageService backgroundImages,
IProjectActivityService activity) : IFloorPlanService
{
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
var projectLocations = project is null ? [] : await locations.ListByProjectAsync(projectId);
return project is null ? null : new FloorPlanListViewModel
{
Project = project,
FloorPlans = await floorPlans.ListByProjectAsync(projectId),
NewFloorPlan = new FloorPlanEditViewModel { ProjectID = projectId, Name = "New floor plan" },
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create a matching Location")
};
}
public async Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId)
{
var data = await floorPlans.GetEditorAsync(floorPlanId);
if (data.FloorPlan is null)
{
return null;
}
var project = await projects.GetAsync(data.FloorPlan.ProjectID);
if (project is null)
{
return null;
}
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
var projectCharacters = (await characters.ListCharactersAsync(project.ProjectID)).ToDictionary(x => x.CharacterID);
var orderedFloors = data.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList();
var occupancyScenes = await scenes.ListByFloorPlanAsync(floorPlanId);
var occupancyRows = await sceneOccupancy.ListByFloorPlanAsync(floorPlanId);
var occupancyParticipants = await sceneOccupancy.ListParticipantsByFloorPlanAsync(floorPlanId);
var firstBlockByLocation = data.Blocks
.GroupBy(x => x.LocationID)
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
var firstBlockByFloorLocation = data.Blocks
.GroupBy(x => (x.FloorPlanFloorID, x.LocationID))
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
return new FloorPlanEditorViewModel
{
Project = project,
FloorPlan = new FloorPlanEditViewModel
{
FloorPlanID = data.FloorPlan.FloorPlanID,
ProjectID = data.FloorPlan.ProjectID,
LocationID = data.FloorPlan.LocationID,
LocationName = data.FloorPlan.LocationName,
LocationPath = data.FloorPlan.LocationPath,
Name = data.FloorPlan.Name,
Description = data.FloorPlan.Description
},
Floors = orderedFloors.Select(ToFloorEdit).ToList(),
Blocks = data.Blocks.Select(ToBlockEdit).ToList(),
Transitions = data.Transitions.Select(ToTransitionEdit).ToList(),
NewFloor = new FloorPlanFloorEditViewModel
{
FloorPlanID = data.FloorPlan.FloorPlanID,
Name = "New floor",
SortOrder = orderedFloors.Any() ? orderedFloors.Max(x => x.SortOrder) + 1 : 0,
GridWidth = 24,
GridHeight = 16
},
NewBlock = new FloorPlanBlockEditViewModel
{
FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
BlockType = FloorPlanBlockTypes.Room,
WidthCells = 2,
HeightCells = 1
},
NewTransition = new FloorPlanTransitionEditViewModel
{
FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
TransitionType = FloorPlanTransitionTypes.Door,
PositionWithinLocation = FloorPlanTransitionPositions.Centre
},
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
Locations = projectLocations,
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(),
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList(),
OccupancyScenes = occupancyScenes
.OrderBy(x => x.BookSortOrder)
.ThenBy(x => x.BookNumber)
.ThenBy(x => x.BookDisplayTitle)
.ThenBy(x => x.ChapterSortOrder)
.ThenBy(x => x.ChapterNumber)
.ThenBy(x => x.SceneNumber)
.ThenBy(x => x.SortOrder)
.ThenBy(x => x.SceneTitle)
.Select(x => new FloorPlanOccupancySceneViewModel
{
SceneID = x.SceneID,
InitialFloorPlanFloorID = x.InitialFloorPlanFloorID,
Label = OccupancySceneLabel(x)
})
.ToList(),
OccupancyTokens = occupancyRows
.Select(x =>
{
FloorPlanBlock? block = null;
if (x.FloorPlanFloorID.HasValue)
{
firstBlockByFloorLocation.TryGetValue((x.FloorPlanFloorID.Value, x.LocationID), out block);
}
else
{
firstBlockByLocation.TryGetValue(x.LocationID, out block);
}
if (block is null)
{
return null;
}
var characterImagePath = x.CharacterImagePath;
var characterThumbnailPath = x.CharacterThumbnailPath;
if (x.CharacterID.HasValue && projectCharacters.TryGetValue(x.CharacterID.Value, out var character))
{
characterImagePath = character.AvatarImagePath ?? character.ImagePath ?? characterImagePath;
characterThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath ?? characterThumbnailPath;
}
return new FloorPlanOccupancyTokenViewModel
{
SceneID = x.SceneID,
FloorPlanFloorID = block.FloorPlanFloorID,
LocationID = x.LocationID,
LocationName = x.LocationName,
EntityType = x.CharacterID.HasValue ? "Character" : "Asset",
EntityID = x.CharacterID ?? x.StoryAssetID ?? 0,
DisplayName = x.CharacterName ?? x.AssetName ?? "Occupant",
ImagePath = characterImagePath ?? x.AssetImagePath,
ThumbnailPath = characterThumbnailPath ?? x.AssetThumbnailPath
};
})
.Where(x => x is not null)
.Cast<FloorPlanOccupancyTokenViewModel>()
.ToList(),
OccupancyWarnings = BuildFloorPlanOccupancyWarnings(occupancyParticipants, occupancyRows, data.Blocks, data.Transitions)
};
}
private static IReadOnlyList<FloorPlanOccupancyWarningViewModel> BuildFloorPlanOccupancyWarnings(
IReadOnlyList<FloorPlanOccupancyParticipant> participants,
IReadOnlyList<SceneFloorPlanOccupancy> occupancyRows,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
{
var warnings = new List<FloorPlanOccupancyWarningViewModel>();
var blocksByLocation = blocks.GroupBy(x => x.LocationID).ToDictionary(x => x.Key, x => x.ToList());
var floors = blocks.Select(x => x.FloorPlanFloorID).Distinct().ToList();
var occupancyRowsByScene = occupancyRows.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.ToList());
var visibleEntranceLocationIds = transitions
.Where(x => IsVisibleEntranceTransition(x.TransitionType))
.SelectMany(x => new[] { x.FromLocationID, x.ToLocationID })
.ToHashSet();
foreach (var sceneGroup in participants.GroupBy(x => x.SceneID))
{
var sceneId = sceneGroup.Key;
var sceneRows = occupancyRowsByScene.GetValueOrDefault(sceneId) ?? [];
var occupiedCharacterIds = sceneRows.Where(x => x.CharacterID.HasValue).Select(x => x.CharacterID!.Value).ToHashSet();
var occupiedAssetIds = sceneRows.Where(x => x.StoryAssetID.HasValue).Select(x => x.StoryAssetID!.Value).ToHashSet();
foreach (var character in sceneGroup
.Where(x => string.Equals(x.EntityType, "Character", StringComparison.OrdinalIgnoreCase))
.GroupBy(x => x.EntityID)
.Select(x => x.First())
.Where(x => !occupiedCharacterIds.Contains(x.EntityID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = sceneId,
Severity = "warning",
Message = $"{character.DisplayName} appears in this scene but has no floor-plan location."
});
}
foreach (var asset in sceneGroup
.Where(x => string.Equals(x.EntityType, "Asset", StringComparison.OrdinalIgnoreCase))
.GroupBy(x => x.EntityID)
.Select(x => x.First())
.Where(x => !occupiedAssetIds.Contains(x.EntityID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = sceneId,
Severity = "warning",
Message = $"{asset.DisplayName} appears in this scene but has no floor-plan location."
});
}
foreach (var row in sceneRows.Where(x => !blocksByLocation.ContainsKey(x.LocationID)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = sceneId,
Severity = "warning",
Message = $"{OccupantName(row)} is assigned to {row.LocationName}, but that location is not placed on this floor plan."
});
}
var rowsOnPlacedLocations = sceneRows.Where(x => blocksByLocation.ContainsKey(x.LocationID)).ToList();
var occupiedFloorIds = rowsOnPlacedLocations
.SelectMany(x => blocksByLocation[x.LocationID].Select(block => block.FloorPlanFloorID))
.Distinct()
.ToHashSet();
foreach (var floorId in floors.Where(floorId => rowsOnPlacedLocations.Any() && !occupiedFloorIds.Contains(floorId)))
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = sceneId,
FloorPlanFloorID = floorId,
Severity = "info",
Message = "Some occupants are on other floors."
});
}
foreach (var row in rowsOnPlacedLocations.Where(x => !visibleEntranceLocationIds.Contains(x.LocationID)))
{
foreach (var floorId in blocksByLocation[row.LocationID].Select(x => x.FloorPlanFloorID).Distinct())
{
warnings.Add(new FloorPlanOccupancyWarningViewModel
{
SceneID = sceneId,
FloorPlanFloorID = floorId,
Severity = "info",
Message = $"{row.LocationName} has no visible entrance."
});
}
}
}
return warnings
.GroupBy(x => new { x.SceneID, x.FloorPlanFloorID, x.Severity, x.Message })
.Select(x => x.First())
.OrderByDescending(x => string.Equals(x.Severity, "warning", StringComparison.OrdinalIgnoreCase))
.ThenBy(x => x.Message)
.ToList();
}
private static string OccupantName(SceneFloorPlanOccupancy row)
=> row.CharacterName ?? row.AssetName ?? "Occupant";
private static string OccupancySceneLabel(Scene scene)
{
var chapter = $"Chapter {scene.ChapterNumber:0.00}";
var sceneLabel = $"Scene {scene.SceneNumber:0.00}: {scene.SceneTitle}";
return scene.BookNumber > 0
? $"Book {scene.BookNumber}, {chapter}, {sceneLabel}"
: $"{chapter}, {sceneLabel}";
}
private static bool IsVisibleEntranceTransition(string? transitionType)
{
var normalized = (transitionType ?? string.Empty)
.Trim()
.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
.ToLowerInvariant();
return normalized is "door"
or "doubledoor"
or "frenchdoor"
or "frenchdoors"
or "slidingdoor"
or "slidingdoors"
or "exteriordoor"
or "archway"
or "openpassage"
or "stairsup"
or "stairsdown";
}
public async Task<int> SavePlanAsync(FloorPlanEditViewModel model)
{
var isNew = model.FloorPlanID == 0;
var locationId = await ResolveFloorPlanLocationAsync(model);
var floorPlanId = await floorPlans.SavePlanAsync(new FloorPlan
{
FloorPlanID = model.FloorPlanID,
ProjectID = model.ProjectID,
LocationID = locationId,
Name = model.Name.Trim(),
Description = model.Description
});
if (isNew)
{
var floorLocationId = await ResolveFloorLocationAsync(floorPlanId, "Ground Floor", null);
await floorPlans.SaveFloorAsync(new FloorPlanFloor
{
FloorPlanID = floorPlanId,
LocationID = floorLocationId,
Name = "Ground Floor",
SortOrder = 0,
GridWidth = 24,
GridHeight = 16
});
}
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Floor Plan", floorPlanId, model.Name);
return floorPlanId;
}
public async Task DeletePlanAsync(int floorPlanId)
{
var floorPlan = await floorPlans.GetAsync(floorPlanId);
await floorPlans.DeletePlanAsync(floorPlanId);
if (floorPlan is not null)
{
await activity.RecordAsync(floorPlan.ProjectID, "Deleted", "Floor Plan", floorPlanId, floorPlan.Name);
}
}
public async Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model)
{
ValidateFloor(model);
var locationId = await ResolveFloorLocationAsync(model.FloorPlanID, model.Name, model.LocationID);
return await floorPlans.SaveFloorAsync(new FloorPlanFloor
{
FloorPlanFloorID = model.FloorPlanFloorID,
FloorPlanID = model.FloorPlanID,
LocationID = locationId,
Name = model.Name.Trim(),
Description = model.Description,
SortOrder = model.SortOrder,
GridWidth = model.GridWidth,
GridHeight = model.GridHeight
});
}
public Task DeleteFloorAsync(int floorPlanFloorId) => floorPlans.DeleteFloorAsync(floorPlanFloorId);
public async Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model)
{
var floor = await floorPlans.GetFloorAsync(model.FloorPlanFloorID)
?? throw new InvalidOperationException("Floor not found.");
var plan = await floorPlans.GetAsync(floor.FloorPlanID)
?? throw new InvalidOperationException("Floor plan not found.");
var projectId = plan.ProjectID;
if (model.FloorPlanBlockID == 0)
{
model.LocationID = await ResolveBlockLocationAsync(model, floor, plan);
var existingBlocks = await floorPlans.ListBlocksByFloorAsync(floor.FloorPlanFloorID);
(model.X, model.Y) = FindFirstAvailablePosition(floor, existingBlocks, model.WidthCells, model.HeightCells);
}
await ValidateBlockAsync(model, floor, projectId);
return await floorPlans.SaveBlockAsync(ToBlock(model));
}
public async Task<FloorPlanBlockEditViewModel?> GetBlockAsync(int floorPlanBlockId)
{
var block = await floorPlans.GetBlockAsync(floorPlanBlockId);
return block is null ? null : ToBlockEdit(block);
}
public async Task SaveLayoutAsync(FloorPlanEditorViewModel model)
{
var data = await floorPlans.GetEditorAsync(model.FloorPlan.FloorPlanID);
if (data.FloorPlan is null)
{
throw new InvalidOperationException("Floor plan not found.");
}
await SavePlanAsync(model.FloorPlan);
var floorsById = data.Floors.ToDictionary(x => x.FloorPlanFloorID);
foreach (var floorModel in model.Floors)
{
ValidateFloor(floorModel);
if (floorModel.FloorPlanID != data.FloorPlan.FloorPlanID)
{
throw new InvalidOperationException("Floor does not belong to this floor plan.");
}
await SaveFloorAsync(floorModel);
}
foreach (var blockModel in model.Blocks)
{
if (!floorsById.TryGetValue(blockModel.FloorPlanFloorID, out var floor))
{
throw new InvalidOperationException("Block floor not found.");
}
await ValidateBlockAsync(blockModel, floor, data.FloorPlan.ProjectID);
await floorPlans.SaveBlockAsync(ToBlock(blockModel));
}
}
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
public async Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model)
{
var data = await floorPlans.GetEditorAsync(floorPlanId);
if (data.FloorPlan is null)
{
throw new InvalidOperationException("Floor plan not found.");
}
var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID)
?? throw new InvalidOperationException("Floor not found.");
ValidateTransition(model, floor, data.Blocks, data.Transitions, data.Floors);
if (!IsInsideLocationTransition(model.TransitionType))
{
model.PositionWithinLocation = FloorPlanTransitionPositions.Centre;
}
else if (model.FloorPlanTransitionID == 0)
{
var existingPrimary = data.Transitions.FirstOrDefault(x =>
x.FloorPlanFloorID == model.FloorPlanFloorID
&& x.FromLocationID == model.FromLocationID!.Value
&& x.ToLocationID == model.ToLocationID!.Value
&& string.Equals(x.TransitionType, model.TransitionType, StringComparison.OrdinalIgnoreCase));
if (existingPrimary is not null)
{
model.FloorPlanTransitionID = existingPrimary.FloorPlanTransitionID;
}
}
var transitionId = await floorPlans.SaveTransitionAsync(ToTransition(model));
if (IsStairTransition(model.TransitionType))
{
await SaveReciprocalStairTransitionAsync(model, floor, data.Floors, data.Blocks, data.Transitions);
}
await activity.RecordAsync(data.FloorPlan.ProjectID, model.FloorPlanTransitionID == 0 ? "Created" : "Updated", "Floor Plan Transition", transitionId, model.TransitionType);
return transitionId;
}
private async Task SaveReciprocalStairTransitionAsync(
FloorPlanTransitionEditViewModel model,
FloorPlanFloor floor,
IReadOnlyList<FloorPlanFloor> floors,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions)
{
var transitionKind = NormalizeTransitionType(model.TransitionType);
var targetLevel = floor.SortOrder + (transitionKind == "stairsup" ? 1 : -1);
var targetFloor = floors
.Where(x => x.SortOrder == targetLevel)
.OrderBy(x => x.Name)
.FirstOrDefault();
if (targetFloor is null)
{
return;
}
var targetLocationIds = blocks
.Where(x => x.FloorPlanFloorID == targetFloor.FloorPlanFloorID)
.Select(x => x.LocationID)
.ToHashSet();
if (!targetLocationIds.Contains(model.ToLocationID!.Value))
{
return;
}
var reciprocalType = transitionKind == "stairsup"
? FloorPlanTransitionTypes.StairsDown
: FloorPlanTransitionTypes.StairsUp;
var existing = transitions.FirstOrDefault(x =>
x.FloorPlanFloorID == targetFloor.FloorPlanFloorID
&& x.FromLocationID == model.ToLocationID.Value
&& x.ToLocationID == model.FromLocationID!.Value
&& string.Equals(x.TransitionType, reciprocalType, StringComparison.OrdinalIgnoreCase));
await floorPlans.SaveTransitionAsync(new FloorPlanTransition
{
FloorPlanTransitionID = existing?.FloorPlanTransitionID ?? 0,
FloorPlanFloorID = targetFloor.FloorPlanFloorID,
FromLocationID = model.ToLocationID.Value,
ToLocationID = model.FromLocationID!.Value,
TransitionType = reciprocalType,
Notes = model.Notes,
IsLocked = model.IsLocked,
DisplayLabel = model.DisplayLabel,
PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation)
? FloorPlanTransitionPositions.Centre
: model.PositionWithinLocation
});
}
public async Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId)
{
var data = await floorPlans.GetEditorAsync(floorPlanId);
if (data.FloorPlan is null)
{
throw new InvalidOperationException("Floor plan not found.");
}
var transition = data.Transitions.FirstOrDefault(x => x.FloorPlanTransitionID == floorPlanTransitionId)
?? throw new InvalidOperationException("Transition not found.");
await floorPlans.DeleteTransitionAsync(floorPlanTransitionId);
await activity.RecordAsync(data.FloorPlan.ProjectID, "Deleted", "Floor Plan Transition", floorPlanTransitionId, transition.TransitionType);
}
public async Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload)
{
var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
var result = await backgroundImages.UploadAsync(plan, floor, upload);
if (!result.Succeeded)
{
return result;
}
var previousPath = floor.BackgroundImagePath;
floor.BackgroundImagePath = result.StoragePath;
floor.BackgroundImageOriginalFileName = result.OriginalFileName;
floor.BackgroundOpacity = Clamp(floor.BackgroundOpacity ?? 0.35m, 0.05m, 1m);
floor.BackgroundScale = Clamp(floor.BackgroundScale ?? 1m, 0.1m, 5m);
floor.BackgroundOffsetX = Clamp(floor.BackgroundOffsetX ?? 0m, -10000m, 10000m);
floor.BackgroundOffsetY = Clamp(floor.BackgroundOffsetY ?? 0m, -10000m, 10000m);
floor.BackgroundLocked = true;
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
{
backgroundImages.DeleteBackgroundFile(result.StoragePath);
return new(false, ErrorMessage: "Background image could not be linked to this floor.");
}
backgroundImages.DeleteBackgroundFile(previousPath);
await activity.RecordAsync(plan.ProjectID, "Updated", "Floor Plan Background", floorPlanFloorId, floor.Name);
return result;
}
public async Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model)
{
var (_, floor) = await GetPlanFloorAsync(model.FloorPlanID, model.FloorPlanFloorID);
floor.BackgroundOpacity = Clamp(model.BackgroundOpacity, 0.05m, 1m);
floor.BackgroundScale = Clamp(model.BackgroundScale, 0.1m, 5m);
floor.BackgroundOffsetX = Clamp(model.BackgroundOffsetX, -10000m, 10000m);
floor.BackgroundOffsetY = Clamp(model.BackgroundOffsetY, -10000m, 10000m);
floor.BackgroundLocked = model.BackgroundLocked;
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
{
throw new InvalidOperationException("Background settings could not be saved.");
}
}
public async Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId)
{
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
floor.BackgroundOpacity = 0.35m;
floor.BackgroundScale = 1m;
floor.BackgroundOffsetX = 0m;
floor.BackgroundOffsetY = 0m;
floor.BackgroundLocked = true;
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
{
throw new InvalidOperationException("Background settings could not be reset.");
}
}
public async Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId)
{
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
var previousPath = floor.BackgroundImagePath;
floor.BackgroundImagePath = null;
floor.BackgroundImageOriginalFileName = null;
floor.BackgroundOpacity = 0.35m;
floor.BackgroundScale = 1m;
floor.BackgroundOffsetX = 0m;
floor.BackgroundOffsetY = 0m;
floor.BackgroundLocked = true;
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
{
throw new InvalidOperationException("Background image could not be removed.");
}
backgroundImages.DeleteBackgroundFile(previousPath);
}
private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId)
{
if (!FloorPlanBlockTypes.IsValid(model.BlockType))
{
throw new InvalidOperationException("Choose a valid block type.");
}
if (model.X < 0 || model.Y < 0 || model.WidthCells < 1 || model.HeightCells < 1)
{
throw new InvalidOperationException("Blocks must have a valid position and size.");
}
if (model.X + model.WidthCells > floor.GridWidth || model.Y + model.HeightCells > floor.GridHeight)
{
throw new InvalidOperationException("Block is outside the floor grid.");
}
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
{
throw new InvalidOperationException("Choose or create a location for this block.");
}
var location = await locations.GetAsync(model.LocationID.Value);
if (location is null || location.ProjectID != projectId)
{
throw new InvalidOperationException("Choose a location from this project.");
}
}
private static string NormalizeTransitionType(string? transitionType)
=> new((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
private static bool IsSameFloorAdjacentTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "door"
or "doubledoor"
or "frenchdoor"
or "frenchdoors"
or "slidingdoor"
or "slidingdoors"
or "window"
or "archway"
or "exteriordoor"
or "hiddendoor"
or "blockeddoor";
private static bool IsUnrestrictedTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "hiddenpassage" or "openpassage" or "secretpassage";
private static bool IsStairTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "stairsup" or "stairsdown";
private static bool IsLadderTransition(string? transitionType)
=> NormalizeTransitionType(transitionType) is "ladder" or "ladders";
private static bool IsInsideLocationTransition(string? transitionType)
=> IsStairTransition(transitionType) || IsLadderTransition(transitionType);
private static bool BlocksTouchAlongEdge(FloorPlanBlock a, FloorPlanBlock b)
{
var verticalTouch = a.X + a.WidthCells == b.X || b.X + b.WidthCells == a.X;
var verticalOverlap = a.Y < b.Y + b.HeightCells && b.Y < a.Y + a.HeightCells;
var horizontalTouch = a.Y + a.HeightCells == b.Y || b.Y + b.HeightCells == a.Y;
var horizontalOverlap = a.X < b.X + b.WidthCells && b.X < a.X + a.WidthCells;
return (verticalTouch && verticalOverlap) || (horizontalTouch && horizontalOverlap);
}
private static void ValidateTransition(
FloorPlanTransitionEditViewModel model,
FloorPlanFloor floor,
IReadOnlyList<FloorPlanBlock> blocks,
IReadOnlyList<FloorPlanTransition> transitions,
IReadOnlyList<FloorPlanFloor> floors)
{
if (!FloorPlanTransitionTypes.IsValid(model.TransitionType))
{
throw new InvalidOperationException("Choose a valid transition type.");
}
if (!model.FromLocationID.HasValue || !model.ToLocationID.HasValue)
{
throw new InvalidOperationException("Choose both transition locations.");
}
if (model.FromLocationID.Value == model.ToLocationID.Value)
{
throw new InvalidOperationException("From and To locations must be different.");
}
if (!FloorPlanTransitionPositions.IsValid(model.PositionWithinLocation))
{
throw new InvalidOperationException("Choose a valid stair position.");
}
var currentFloorBlocks = blocks
.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID)
.ToList();
var floorLocationIds = currentFloorBlocks.Select(x => x.LocationID).ToHashSet();
if (!floorLocationIds.Contains(model.FromLocationID.Value))
{
throw new InvalidOperationException("From Location must exist on the active floor.");
}
if (IsStairTransition(model.TransitionType))
{
var targetLevel = floor.SortOrder + (NormalizeTransitionType(model.TransitionType) == "stairsup" ? 1 : -1);
var targetFloorIds = floors.Where(x => x.SortOrder == targetLevel).Select(x => x.FloorPlanFloorID).ToHashSet();
var targetLocationIds = blocks
.Where(x => targetFloorIds.Contains(x.FloorPlanFloorID))
.Select(x => x.LocationID)
.ToHashSet();
if (!targetLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("Stairs must connect to a location on the adjacent floor level.");
}
return;
}
if (IsLadderTransition(model.TransitionType))
{
var targetFloorIds = floors
.Where(x => x.SortOrder > floor.SortOrder)
.Select(x => x.FloorPlanFloorID)
.ToHashSet();
var targetLocationIds = blocks
.Where(x => targetFloorIds.Contains(x.FloorPlanFloorID))
.Select(x => x.LocationID)
.ToHashSet();
if (!targetLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("Ladders must connect to a placed location on a floor above.");
}
return;
}
if (IsUnrestrictedTransition(model.TransitionType))
{
var planLocationIds = blocks.Select(x => x.LocationID).ToHashSet();
if (!planLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("To Location must exist in this floor plan.");
}
return;
}
if (!IsSameFloorAdjacentTransition(model.TransitionType))
{
throw new InvalidOperationException("Choose a valid transition type.");
}
if (!floorLocationIds.Contains(model.ToLocationID.Value))
{
throw new InvalidOperationException("To Location must exist on the active floor.");
}
var fromBlocks = currentFloorBlocks.Where(x => x.LocationID == model.FromLocationID.Value).ToList();
var toBlocks = currentFloorBlocks.Where(x => x.LocationID == model.ToLocationID.Value).ToList();
if (!fromBlocks.Any(from => toBlocks.Any(to => BlocksTouchAlongEdge(from, to))))
{
throw new InvalidOperationException("Door and window transitions must connect adjacent locations.");
}
}
private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId)
{
var plan = await floorPlans.GetAsync(floorPlanId)
?? throw new InvalidOperationException("Floor plan not found.");
var floor = await floorPlans.GetFloorAsync(floorPlanFloorId)
?? throw new InvalidOperationException("Floor not found.");
if (floor.FloorPlanID != plan.FloorPlanID)
{
throw new InvalidOperationException("Floor does not belong to this floor plan.");
}
return (plan, floor);
}
private static decimal Clamp(decimal value, decimal min, decimal max)
=> Math.Min(Math.Max(value, min), max);
private async Task<int?> ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model)
{
if (model.LocationID.HasValue && model.LocationID.Value > 0)
{
var existing = await locations.GetAsync(model.LocationID.Value);
if (existing is null || existing.ProjectID != model.ProjectID)
{
throw new InvalidOperationException("Floor plan location must belong to this project.");
}
return model.LocationID.Value;
}
return await FindOrCreateLocationAsync(model.ProjectID, null, model.Name.Trim());
}
private async Task<int?> ResolveFloorLocationAsync(int floorPlanId, string floorName, int? locationId)
{
var plan = await floorPlans.GetAsync(floorPlanId)
?? throw new InvalidOperationException("Floor plan not found.");
if (locationId.HasValue && locationId.Value > 0)
{
var existing = await locations.GetAsync(locationId.Value);
if (existing is null || existing.ProjectID != plan.ProjectID)
{
throw new InvalidOperationException("Floor location must belong to this project.");
}
return locationId.Value;
}
var planLocationId = plan.LocationID ?? await FindOrCreateLocationAsync(plan.ProjectID, null, plan.Name);
if (plan.LocationID != planLocationId)
{
await floorPlans.SavePlanAsync(new FloorPlan
{
FloorPlanID = plan.FloorPlanID,
ProjectID = plan.ProjectID,
LocationID = planLocationId,
Name = plan.Name,
Description = plan.Description
});
}
return await FindOrCreateLocationAsync(plan.ProjectID, planLocationId, floorName.Trim());
}
private async Task<int> ResolveBlockLocationAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, FloorPlan plan)
{
if (string.Equals(model.AddBlockLocationMode, "Create", StringComparison.OrdinalIgnoreCase))
{
var locationName = string.IsNullOrWhiteSpace(model.NewLocationName) ? model.LabelOverride : model.NewLocationName;
locationName = string.IsNullOrWhiteSpace(locationName) ? model.LabelOverride : locationName;
if (string.IsNullOrWhiteSpace(locationName))
{
throw new InvalidOperationException("Enter a room/location name for the new block.");
}
var floorLocationId = floor.LocationID ?? await ResolveFloorLocationAsync(floor.FloorPlanID, floor.Name, null);
return await FindOrCreateLocationAsync(plan.ProjectID, floorLocationId, locationName.Trim());
}
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
{
throw new InvalidOperationException("Choose a location for this block.");
}
return model.LocationID.Value;
}
private async Task<int> FindOrCreateLocationAsync(int projectId, int? parentLocationId, string locationName)
{
var name = locationName.Trim();
var existing = (await locations.ListByProjectAsync(projectId))
.FirstOrDefault(x => x.ParentLocationID == parentLocationId
&& string.Equals(x.LocationName.Trim(), name, StringComparison.OrdinalIgnoreCase)
&& !x.IsArchived);
if (existing is not null)
{
return existing.LocationID;
}
return await locations.SaveAsync(new LocationItem
{
ProjectID = projectId,
ParentLocationID = parentLocationId,
LocationName = name,
ShowInQuickAddBar = false
});
}
private static void ValidateFloor(FloorPlanFloorEditViewModel model)
{
if (model.SortOrder is < -10 or > 50)
{
throw new InvalidOperationException("Floor level must be between -10 and 50.");
}
if (model.GridWidth is < 8 or > 80 || model.GridHeight is < 8 or > 80)
{
throw new InvalidOperationException("Grid width and height must be between 8 and 80.");
}
}
private static (int X, int Y) FindFirstAvailablePosition(FloorPlanFloor floor, IReadOnlyList<FloorPlanBlock> existingBlocks, int widthCells, int heightCells)
{
var width = Math.Max(1, widthCells);
var height = Math.Max(1, heightCells);
if (width > floor.GridWidth || height > floor.GridHeight)
{
return (0, 0);
}
for (var y = 0; y <= floor.GridHeight - height; y++)
{
for (var x = 0; x <= floor.GridWidth - width; x++)
{
if (!existingBlocks.Any(block => BlocksOverlap(x, y, width, height, block.X, block.Y, block.WidthCells, block.HeightCells)))
{
return (x, y);
}
}
}
return (0, 0);
}
private static bool BlocksOverlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
{
return ax < bx + bw
&& ax + aw > bx
&& ay < by + bh
&& ay + ah > by;
}
private static FloorPlanFloorEditViewModel ToFloorEdit(FloorPlanFloor floor) => new()
{
FloorPlanFloorID = floor.FloorPlanFloorID,
FloorPlanID = floor.FloorPlanID,
LocationID = floor.LocationID,
LocationName = floor.LocationName,
LocationPath = floor.LocationPath,
Name = floor.Name,
Description = floor.Description,
SortOrder = floor.SortOrder,
GridWidth = floor.GridWidth,
GridHeight = floor.GridHeight,
BackgroundImagePath = floor.BackgroundImagePath,
BackgroundImageOriginalFileName = floor.BackgroundImageOriginalFileName,
BackgroundOpacity = floor.BackgroundOpacity ?? 0.35m,
BackgroundScale = floor.BackgroundScale ?? 1m,
BackgroundOffsetX = floor.BackgroundOffsetX ?? 0m,
BackgroundOffsetY = floor.BackgroundOffsetY ?? 0m,
BackgroundLocked = floor.BackgroundLocked
};
private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new()
{
FloorPlanBlockID = block.FloorPlanBlockID,
FloorPlanFloorID = block.FloorPlanFloorID,
LocationID = block.LocationID,
LocationName = block.LocationName,
LocationPath = block.LocationPath,
X = block.X,
Y = block.Y,
WidthCells = block.WidthCells,
HeightCells = block.HeightCells,
BlockType = block.BlockType,
LabelOverride = block.LabelOverride,
Notes = block.Notes
};
private static FloorPlanBlock ToBlock(FloorPlanBlockEditViewModel model) => new()
{
FloorPlanBlockID = model.FloorPlanBlockID,
FloorPlanFloorID = model.FloorPlanFloorID,
LocationID = model.LocationID!.Value,
X = model.X,
Y = model.Y,
WidthCells = model.WidthCells,
HeightCells = model.HeightCells,
BlockType = model.BlockType,
LabelOverride = model.LabelOverride,
Notes = model.Notes
};
private static FloorPlanTransitionEditViewModel ToTransitionEdit(FloorPlanTransition transition) => new()
{
FloorPlanTransitionID = transition.FloorPlanTransitionID,
FloorPlanFloorID = transition.FloorPlanFloorID,
FromLocationID = transition.FromLocationID,
FromLocationName = transition.FromLocationName,
FromLocationPath = transition.FromLocationPath,
ToLocationID = transition.ToLocationID,
ToLocationName = transition.ToLocationName,
ToLocationPath = transition.ToLocationPath,
TransitionType = transition.TransitionType,
Notes = transition.Notes,
IsLocked = transition.IsLocked,
DisplayLabel = transition.DisplayLabel,
PositionWithinLocation = string.IsNullOrWhiteSpace(transition.PositionWithinLocation)
? FloorPlanTransitionPositions.Centre
: transition.PositionWithinLocation
};
private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new()
{
FloorPlanTransitionID = model.FloorPlanTransitionID,
FloorPlanFloorID = model.FloorPlanFloorID,
FromLocationID = model.FromLocationID!.Value,
ToLocationID = model.ToLocationID!.Value,
TransitionType = model.TransitionType,
Notes = model.Notes,
IsLocked = model.IsLocked,
DisplayLabel = model.DisplayLabel,
PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation)
? FloorPlanTransitionPositions.Centre
: model.PositionWithinLocation
};
private static string BlockTypeLabel(string blockType) => blockType switch
{
FloorPlanBlockTypes.OpenSpace => "Open space",
_ => blockType
};
private static IReadOnlyList<SelectListItem> ToOptionalLocationOptions(IEnumerable<LocationItem> projectLocations, string emptyLabel)
{
var items = new List<SelectListItem> { new(emptyLabel, string.Empty) };
items.AddRange(projectLocations.Select(x => new SelectListItem(string.IsNullOrWhiteSpace(x.LocationPath) ? x.LocationName : x.LocationPath, x.LocationID.ToString())));
return items;
}
}
public sealed class CharacterService(
IProjectRepository projects,
IBookRepository books,
ICharacterRepository characters,
IProjectCollaborationRepository collaboration,
ISubscriptionService subscriptions,
IProjectActivityService activity,
ICurrentUserService currentUser,
IVisualIdentityImageService visualIdentityImages) : 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
: await PopulateCharacterEditOptionsAsync(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 await PopulateCharacterEditOptionsAsync(new CharacterEditViewModel
{
CharacterID = character.CharacterID,
ProjectID = character.ProjectID,
CharacterName = character.CharacterName,
Aliases = (await characters.ListAliasesAsync(character.CharacterID)).Select(x => x.Alias).ToList(),
SexValueID = character.SexValueID,
Sex = character.Sex,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
CharacterImportance = character.CharacterImportance,
ShowInQuickAddBar = character.ShowInQuickAddBar,
DefaultDescription = character.DefaultDescription,
ImagePath = character.ImagePath,
ThumbnailPath = character.ThumbnailPath,
AvatarSourceCharacterImageID = character.AvatarSourceCharacterImageID,
AvatarImagePath = character.AvatarImagePath,
AvatarThumbnailPath = character.AvatarThumbnailPath,
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,
Aliases = await characters.ListAliasesAsync(character.CharacterID),
ProjectCharacters = projectCharacters,
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
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.BookDisplayTitle}", x.BookID.ToString())).ToList()
};
}
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
{
var image = await characters.GetCharacterImageAsync(characterImageId);
if (image is null)
{
return null;
}
var character = await characters.GetCharacterAsync(image.CharacterID);
if (character is null)
{
return null;
}
return new CharacterAvatarCropViewModel
{
CharacterID = character.CharacterID,
CharacterImageID = image.CharacterImageID,
CharacterName = character.CharacterName,
ImagePath = image.ImagePath,
Caption = image.Caption
};
}
public async Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model)
{
if (model.CharacterID == 0)
{
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId);
}
var isNew = model.CharacterID == 0;
var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID);
var sex = await ResolveCharacterSexAsync(model.ProjectID, model.SexValueID, model.CustomSex, model.Sex);
var characterId = await characters.SaveCharacterAsync(new Character
{
CharacterID = model.CharacterID,
ProjectID = model.ProjectID,
CharacterName = model.CharacterName,
ShortName = existing?.ShortName,
SexValueID = sex.SexValueID,
Sex = sex.SexName,
BirthDate = model.BirthDate,
AgeAtSeriesStart = model.AgeAtSeriesStart,
Height = model.Height,
EyeColour = model.EyeColour,
CharacterImportance = model.CharacterImportance,
ShowInQuickAddBar = model.ShowInQuickAddBar,
DefaultDescription = model.DefaultDescription,
ImagePath = model.ImagePath ?? existing?.ImagePath,
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
});
await SyncCharacterAliasesAsync(characterId, model.Aliases);
var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID };
imageHost.CharacterID = characterId;
imageHost.ProjectID = model.ProjectID;
int? uploadedCharacterImageId = null;
if (model.ImageUpload is { Length: > 0 } upload)
{
var result = await visualIdentityImages.UploadCharacterImageAsync(imageHost, upload);
if (!result.Succeeded)
{
throw new EntityImageUploadException(result.ErrorMessage ?? "Character image could not be uploaded.", characterId);
}
uploadedCharacterImageId = result.CharacterImageID;
}
else if (model.RemoveImage && existing is not null)
{
await visualIdentityImages.RemoveCharacterImageAsync(existing);
}
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName);
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
}
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
{
var aliases = AliasInput.Clean(postedAliases);
var existing = await characters.ListAliasesAsync(characterId);
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < aliases.Count; index++)
{
var alias = aliases[index];
var sortOrder = (index + 1) * 10;
if (existingByAlias.TryGetValue(alias, out var row))
{
await characters.UpdateAliasAsync(row.CharacterAliasID, alias, sortOrder);
}
else
{
await characters.AddAliasAsync(characterId, alias, sortOrder);
}
}
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
{
await characters.DeleteAliasAsync(alias.CharacterAliasID);
}
}
private async Task<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model)
{
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);
IReadOnlyList<CharacterSexValue> sexValues = ownerUserId.HasValue
? await characters.ListSexValuesAsync(ownerUserId.Value)
: [];
if (model.SexValueID is null && !string.IsNullOrWhiteSpace(model.Sex))
{
var matching = sexValues.FirstOrDefault(value => string.Equals(value.SexName, model.Sex, StringComparison.OrdinalIgnoreCase));
if (matching is not null)
{
model.SexValueID = matching.CharacterSexValueID;
}
}
model.SexOptions = new[] { new SelectListItem("Not set", string.Empty) }
.Concat(sexValues.Select(value => new SelectListItem(value.SexName, value.CharacterSexValueID.ToString(), value.CharacterSexValueID == model.SexValueID)))
.Concat(new[] { new SelectListItem("Add new...", CharacterEditViewModel.CustomSexValue.ToString(), model.SexValueID == CharacterEditViewModel.CustomSexValue) })
.ToList();
return model;
}
private async Task<(int? SexValueID, string? SexName)> ResolveCharacterSexAsync(int projectId, int? sexValueId, string? customSex, string? fallbackSex)
{
var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
if (!ownerUserId.HasValue)
{
return (sexValueId, fallbackSex);
}
if (sexValueId == CharacterEditViewModel.CustomSexValue)
{
if (string.IsNullOrWhiteSpace(customSex))
{
throw new InvalidOperationException("Enter a custom sex value.");
}
var trimmed = customSex.Trim();
var customId = await characters.GetOrCreateSexValueAsync(ownerUserId.Value, trimmed);
return (customId, trimmed);
}
if (sexValueId.HasValue)
{
var sexValue = await characters.GetSexValueAsync(sexValueId.Value, ownerUserId.Value);
return sexValue is null
? (null, null)
: (sexValue.CharacterSexValueID, sexValue.SexName);
}
return (null, null);
}
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
{
var character = await characters.GetCharacterAsync(model.CharacterID);
if (character is null)
{
return new(false, "Character not found.");
}
return model.ImageUpload is null
? new(false, "Choose an image to upload.")
: await visualIdentityImages.UploadCharacterGalleryImageAsync(character, model.ImageUpload, model.Caption);
}
public async Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model)
{
var character = await characters.GetCharacterAsync(model.CharacterID);
var image = await characters.GetCharacterImageAsync(model.CharacterImageID);
if (character is null || image is null || image.CharacterID != character.CharacterID)
{
return new(false, "Character image not found.");
}
return await visualIdentityImages.CreateCharacterAvatarAsync(character, image, model.CropX, model.CropY, model.CropSize);
}
public async Task RemoveCharacterAvatarAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
if (character is not null)
{
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
await activity.RecordAsync(character.ProjectID, "Updated", "Character", characterId, character.CharacterName);
}
}
public async Task DeleteCharacterImageAsync(int characterImageId)
{
var image = await characters.GetCharacterImageAsync(characterImageId);
if (image is null)
{
return;
}
var character = await characters.GetCharacterAsync(image.CharacterID);
if (character is not null && string.Equals(character.ImagePath, image.ImagePath, StringComparison.OrdinalIgnoreCase))
{
await characters.UpdateCharacterImageAsync(character.CharacterID, null, null);
}
await visualIdentityImages.DeleteCharacterGalleryImageAsync(image);
if (character?.AvatarSourceCharacterImageID == image.CharacterImageID)
{
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
}
if (character is not null)
{
await activity.RecordAsync(character.ProjectID, "Updated", "Character", character.CharacterID, character.CharacterName);
}
}
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)
{
var age = CharacterAgeDisplayService.GetAgeForScene(character, sceneStartDateTime.HasValue ? "Exact DateTime" : null, sceneStartDateTime);
return age.DisplayText ?? "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();
}
}