PlotDirector/PlotLine/Services/CoreServices.cs
2026-06-03 15:55:21 +01:00

3337 lines
153 KiB
C#

using System.Diagnostics;
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Mvc.Rendering;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IProjectService
{
Task<IReadOnlyList<Project>> ListAsync();
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
Task<ProjectEditViewModel> GetCreateAsync();
Task<ProjectEditViewModel?> GetEditAsync(int projectId);
Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model);
Task<int> SaveAsync(ProjectEditViewModel model);
Task ArchiveAsync(int projectId);
}
public interface IBookService
{
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
Task<BookEditViewModel?> GetCreateAsync(int projectId);
Task<BookEditViewModel?> GetEditAsync(int bookId);
Task<int> SaveAsync(BookEditViewModel model);
Task ArchiveAsync(int bookId);
}
public interface IChapterService
{
Task<ChapterDetailViewModel?> GetDetailAsync(int chapterId);
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
Task<int> SaveAsync(ChapterEditViewModel model);
Task ArchiveAsync(int chapterId);
}
public interface ISceneService
{
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
Task<int> SaveAsync(SceneEditViewModel model);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
Task<SceneMovePreviewViewModel?> GetMovePreviewAsync(SceneMoveRequestViewModel request);
Task<ContinuityValidationSummary?> MoveToChapterAsync(SceneMoveRequestViewModel request);
Task RenumberChapterAsync(int chapterId);
Task<int> AddDependencyAsync(SceneDependencyCreateViewModel model);
Task DeleteDependencyAsync(int sceneDependencyId);
}
public interface IPlotService
{
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
Task<PlotLineEditViewModel?> GetCreatePlotLineAsync(int projectId);
Task<PlotLineEditViewModel?> GetEditPlotLineAsync(int plotLineId);
Task<int> SavePlotLineAsync(PlotLineEditViewModel model);
Task ArchivePlotLineAsync(int plotLineId);
Task<PlotThreadListViewModel?> GetPlotThreadsAsync(int projectId);
Task<PlotThreadEditViewModel?> GetCreatePlotThreadAsync(int projectId, int? plotLineId);
Task<PlotThreadEditViewModel?> GetEditPlotThreadAsync(int plotThreadId);
Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model);
Task ArchivePlotThreadAsync(int plotThreadId);
Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model);
Task DeleteThreadEventAsync(int threadEventId);
}
public interface IAssetService
{
Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId);
Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId);
Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId);
Task<StoryAssetDetailViewModel?> GetAssetDetailAsync(int storyAssetId);
Task<int> SaveAssetAsync(StoryAssetEditViewModel model);
Task ArchiveAssetAsync(int storyAssetId);
Task<int> AddAssetEventAsync(AssetEventCreateViewModel model);
Task DeleteAssetEventAsync(int assetEventId);
Task<int> AddCustodyEventAsync(AssetCustodyCreateViewModel model);
Task DeleteCustodyEventAsync(int assetCustodyEventId);
Task<int> SaveDependencyAsync(AssetDependencyEditViewModel model);
Task DeleteDependencyAsync(int assetDependencyId);
Task<IReadOnlyList<AssetDependencyIssue>> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId);
}
public interface ILocationService
{
Task<LocationListViewModel?> GetLocationsAsync(int projectId);
Task<LocationEditViewModel?> GetCreateAsync(int projectId);
Task<LocationEditViewModel?> GetEditAsync(int locationId);
Task<LocationDetailViewModel?> GetDetailAsync(int locationId);
Task<int> SaveAsync(LocationEditViewModel model);
Task ArchiveAsync(int locationId);
Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model);
Task ArchiveRelationshipAsync(int locationRelationshipId);
Task<int> SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model);
Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId);
}
public interface IContinuityValidationService
{
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional, ContinuityValidationSummary? summary = null);
Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId);
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId);
Task<ContinuityValidationSummary> ValidateAssetAsync(int assetId);
Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId);
Task<IReadOnlyList<ContinuityWarning>> ListSceneWarningsAsync(int sceneId);
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
Task DismissAsync(int warningId);
Task MarkIntentionalAsync(int warningId);
Task RestoreAsync(int warningId);
}
public interface ICharacterService
{
Task<CharacterListViewModel?> GetCharactersAsync(int projectId);
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
Task<int> SaveCharacterAsync(CharacterEditViewModel model);
Task ArchiveCharacterAsync(int characterId);
Task ArchiveRelationshipAsync(int characterRelationshipId);
Task<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model);
Task<int> UpdateSceneCharacterAsync(SceneCharacterEditViewModel model);
Task DeleteSceneCharacterAsync(int sceneCharacterId);
Task<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model);
Task<int> AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model);
Task DeleteKnowledgeAsync(int characterKnowledgeId);
Task<int> AddRelationshipEventAsync(RelationshipEventCreateViewModel model);
Task DeleteRelationshipEventAsync(int relationshipEventId);
string DisplayAge(Character character, DateTime? sceneStartDateTime = null);
}
public interface ITimelineService
{
Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter);
Task<TimelineSettingsViewModel?> GetSettingsAsync(int projectId);
Task SaveSettingsAsync(TimelineSettingsViewModel model);
Task<int> SavePresetAsync(TimelinePresetSaveViewModel model);
Task<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId);
Task ArchivePresetAsync(int timelineViewPresetId);
Task<ContinuityValidationSummary?> MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model);
Task AddCharacterByDropAsync(TimelineSceneCharacterDropViewModel model);
Task AddAssetEventByDropAsync(TimelineAssetEventDropViewModel model);
Task AddAssetDependencyByDropAsync(TimelineAssetDependencyDropViewModel model);
Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model);
}
public interface ISceneMetricTypeService
{
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
Task<SceneMetricTypeEditViewModel?> GetEditAsync(int projectId, int metricTypeId);
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
public interface 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 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 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) : IProjectService
{
public Task<IReadOnlyList<Project>> ListAsync() => projects.ListAsync();
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return new ProjectDetailViewModel
{
Project = project,
Books = await books.ListByProjectAsync(projectId)
};
}
public async Task<ProjectEditViewModel> GetCreateAsync()
{
var model = new ProjectEditViewModel();
await PopulateGenrePresetOptionsAsync(model);
return model;
}
public async Task<ProjectEditViewModel?> GetEditAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new ProjectEditViewModel
{
ProjectID = project.ProjectID,
ProjectName = project.ProjectName,
Description = project.Description
};
}
public 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 Task<int> SaveAsync(ProjectEditViewModel model) => projects.SaveAsync(new Project
{
ProjectID = model.ProjectID,
ProjectName = model.ProjectName,
Description = model.Description
}, model.ProjectID == 0 ? model.GenreMetricPresetKey : null);
public Task ArchiveAsync(int projectId) => projects.ArchiveAsync(projectId);
}
public sealed class BookService(IProjectRepository projects, IBookRepository books, IChapterRepository chapters) : IBookService
{
public async Task<BookDetailViewModel?> GetDetailAsync(int bookId)
{
var book = await books.GetAsync(bookId);
if (book is null)
{
return null;
}
var project = await projects.GetAsync(book.ProjectID);
if (project is null)
{
return null;
}
return new BookDetailViewModel
{
Project = project,
Book = book,
Chapters = await chapters.ListByBookAsync(bookId)
};
}
public async Task<BookEditViewModel?> GetCreateAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var existing = await books.ListByProjectAsync(projectId);
return new BookEditViewModel
{
ProjectID = projectId,
Project = project,
BookNumber = existing.Count + 1
};
}
public async Task<BookEditViewModel?> GetEditAsync(int bookId)
{
var book = await books.GetAsync(bookId);
if (book is null)
{
return null;
}
return new BookEditViewModel
{
BookID = book.BookID,
ProjectID = book.ProjectID,
BookTitle = book.BookTitle,
BookNumber = book.BookNumber,
Description = book.Description,
Project = await projects.GetAsync(book.ProjectID)
};
}
public Task<int> SaveAsync(BookEditViewModel model) => books.SaveAsync(new Book
{
BookID = model.BookID,
ProjectID = model.ProjectID,
BookTitle = model.BookTitle,
BookNumber = model.BookNumber,
Description = model.Description
});
public Task ArchiveAsync(int bookId) => books.ArchiveAsync(bookId);
}
public sealed class ChapterService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ILookupRepository lookups) : IChapterService
{
public async Task<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 Task<int> SaveAsync(ChapterEditViewModel model) => chapters.SaveAsync(new Chapter
{
ChapterID = model.ChapterID,
BookID = model.BookID,
ChapterNumber = model.ChapterNumber,
ChapterTitle = model.ChapterTitle,
Summary = model.Summary,
RevisionStatusID = model.RevisionStatusID
});
public Task ArchiveAsync(int chapterId) => chapters.ArchiveAsync(chapterId);
internal static int DefaultRevisionStatusId(LookupData lookupData)
=> lookupData.RevisionStatuses.FirstOrDefault(x => x.StatusName == "Planned")?.RevisionStatusID
?? lookupData.RevisionStatuses.First().RevisionStatusID;
internal static IReadOnlyList<SelectListItem> ToSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
}
public sealed class SceneService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
ILookupRepository lookups,
IPlotRepository plots,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies,
IWriterWorkspaceRepository writerWorkspace) : ISceneService
{
public async Task<SceneEditViewModel?> GetCreateAsync(int chapterId)
{
var context = await GetContextAsync(chapterId);
if (context is null)
{
return null;
}
var existing = await scenes.ListByChapterAsync(chapterId);
var lookupData = await lookups.GetAllAsync();
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
{
ChapterID = chapterId,
SceneNumber = existing.Count + 1,
RevisionStatusID = ChapterService.DefaultRevisionStatusId(lookupData),
TimeModeID = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeID ?? lookupData.TimeModes.First().TimeModeID,
TimeConfidenceID = lookupData.TimeConfidences.FirstOrDefault(x => x.TimeConfidenceName == "Unknown")?.TimeConfidenceID ?? lookupData.TimeConfidences.First().TimeConfidenceID,
Chapter = context.Value.Chapter,
Book = context.Value.Book,
Project = context.Value.Project
}, lookupData));
}
public async Task<SceneEditViewModel?> GetEditAsync(int sceneId)
{
var scene = await scenes.GetAsync(sceneId);
if (scene is null)
{
return null;
}
var context = await GetContextAsync(scene.ChapterID);
if (context is null)
{
return null;
}
var lookupData = await lookups.GetAllAsync();
return await PopulateWorkspaceDataAsync(PopulateLists(new SceneEditViewModel
{
SceneID = scene.SceneID,
ChapterID = scene.ChapterID,
SceneNumber = scene.SceneNumber,
SceneTitle = scene.SceneTitle,
Summary = scene.Summary,
TimeModeID = scene.TimeModeID,
StartDateTime = scene.StartDateTime,
EndDateTime = scene.EndDateTime,
DurationAmount = scene.DurationAmount,
DurationUnitID = scene.DurationUnitID,
RelativeTimeText = scene.RelativeTimeText,
TimeConfidenceID = scene.TimeConfidenceID,
ScenePurposeNotes = scene.ScenePurposeNotes,
SceneOutcomeNotes = scene.SceneOutcomeNotes,
RevisionStatusID = scene.RevisionStatusID,
PrimaryLocationID = scene.PrimaryLocationID,
SelectedPurposeTypeIds = scene.SelectedPurposeTypeIds,
Metrics = scene.Metrics.Select(metric => new SceneMetricInputViewModel
{
MetricTypeID = metric.MetricTypeID,
MetricName = metric.MetricName,
Description = metric.Description,
MinValue = metric.MinValue,
MaxValue = metric.MaxValue,
Value = metric.Value,
Notes = metric.Notes
}).ToList(),
Chapter = context.Value.Chapter,
Book = context.Value.Book,
Project = context.Value.Project
}, lookupData));
}
public async Task<int> SaveAsync(SceneEditViewModel model)
{
var sceneId = await scenes.SaveAsync(new Scene
{
SceneID = model.SceneID,
ChapterID = model.ChapterID,
SceneNumber = model.SceneNumber,
SceneTitle = model.SceneTitle,
Summary = model.Summary,
TimeModeID = model.TimeModeID,
StartDateTime = model.StartDateTime,
EndDateTime = model.EndDateTime,
DurationAmount = model.DurationAmount,
DurationUnitID = model.DurationUnitID,
RelativeTimeText = model.RelativeTimeText,
TimeConfidenceID = model.TimeConfidenceID,
ScenePurposeNotes = model.ScenePurposeNotes,
SceneOutcomeNotes = model.SceneOutcomeNotes,
RevisionStatusID = model.RevisionStatusID,
PrimaryLocationID = model.PrimaryLocationID
});
await scenes.SavePurposesAsync(sceneId, model.SelectedPurposeTypeIds);
await scenes.SaveMetricValuesAsync(sceneId, model.Metrics.Select(metric => new SceneMetricValue
{
SceneID = sceneId,
MetricTypeID = metric.MetricTypeID,
Value = metric.Value,
Notes = metric.Notes
}));
return sceneId;
}
public Task ArchiveAsync(int sceneId) => scenes.ArchiveAsync(sceneId);
public async Task MoveAsync(int sceneId, string direction)
{
var scene = await scenes.GetAsync(sceneId);
var context = scene is null ? null : await GetContextAsync(scene.ChapterID);
await scenes.MoveAsync(sceneId, direction);
if (context is not null)
{
await warnings.ValidateBookAsync(context.Value.Book.BookID);
await warnings.FinaliseSceneDependencyWarningsAsync(context.Value.Project.ProjectID, context.Value.Book.BookID);
}
}
public Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position) =>
scenes.MoveRelativeAsync(sceneId, anchorSceneId, position);
private async Task<(Project Project, Book Book, Chapter Chapter)?> GetContextAsync(int chapterId)
{
var chapter = await chapters.GetAsync(chapterId);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
var project = book is null ? null : await projects.GetAsync(book.ProjectID);
return chapter is null || book is null || project is null ? null : (project, book, chapter);
}
private static SceneEditViewModel PopulateLists(SceneEditViewModel model, LookupData lookupData)
{
model.RevisionStatuses = ChapterService.ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName);
model.TimeModes = ChapterService.ToSelectList(lookupData.TimeModes, x => x.TimeModeID, x => x.TimeModeName);
model.DurationUnits = ChapterService.ToSelectList(lookupData.DurationUnits, x => x.DurationUnitID, x => x.DurationUnitName);
model.TimeConfidences = ChapterService.ToSelectList(lookupData.TimeConfidences, x => x.TimeConfidenceID, x => x.TimeConfidenceName);
model.ScenePurposeTypes = lookupData.ScenePurposeTypes;
return model;
}
private async Task<SceneEditViewModel> PopulateWorkspaceDataAsync(SceneEditViewModel model)
{
if (model.Project is null)
{
return model;
}
model.ReturnProjectID ??= model.Project.ProjectID;
model.ReturnBookID ??= model.Book?.BookID;
var plotLookups = await plots.GetLookupsAsync();
var plotLines = await plots.ListPlotLinesAsync(model.Project.ProjectID);
var plotThreads = await plots.ListPlotThreadsByProjectAsync(model.Project.ProjectID);
var assetLookups = await assets.GetLookupsAsync();
var projectAssets = await assets.ListAssetsAsync(model.Project.ProjectID);
var characterLookups = await characters.GetLookupsAsync();
var projectCharacters = await characters.ListCharactersAsync(model.Project.ProjectID);
var projectRelationships = await characters.ListRelationshipsByProjectAsync(model.Project.ProjectID);
var projectLocations = await locations.ListByProjectAsync(model.Project.ProjectID);
var dependencyLookups = await sceneDependencies.GetLookupsAsync();
var allSceneOptions = await plots.ListSceneOptionsAsync(model.Project.ProjectID);
var chapterOptions = await scenes.ListChapterOptionsAsync(model.Project.ProjectID);
model.ThreadEvents = model.SceneID == 0 ? [] : await plots.ListThreadEventsBySceneAsync(model.SceneID);
model.AssetEvents = model.SceneID == 0 ? [] : await assets.ListEventsBySceneAsync(model.SceneID);
model.AssetCustodyEvents = model.SceneID == 0 ? [] : await assets.ListCustodyBySceneAsync(model.SceneID);
model.SceneAssetLocations = model.SceneID == 0 ? [] : await locations.ListSceneAssetLocationsAsync(model.SceneID);
model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID);
model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID);
var sceneDependencyRows = model.SceneID == 0 ? [] : await sceneDependencies.ListBySceneAsync(model.SceneID);
model.DependenciesThisSceneNeeds = sceneDependencyRows.Where(x => x.TargetSceneID == model.SceneID).ToList();
model.DependenciesNeedingThisScene = sceneDependencyRows.Where(x => x.SourceSceneID == model.SceneID).ToList();
model.CharacterKnowledge = model.SceneID == 0 ? [] : await characters.ListKnowledgeBySceneAsync(model.SceneID);
model.RelationshipEvents = model.SceneID == 0 ? [] : await characters.ListRelationshipEventsBySceneAsync(model.SceneID);
var noteTypes = await writerWorkspace.ListNoteTypesAsync();
var attachmentTypes = await writerWorkspace.ListAttachmentTypesAsync();
model.Workflow = model.SceneID == 0 ? new SceneWorkflow() : await writerWorkspace.GetWorkflowAsync(model.SceneID);
model.Notes = model.SceneID == 0 ? [] : await writerWorkspace.ListNotesAsync(model.SceneID);
model.ChecklistItems = model.SceneID == 0 ? [] : await writerWorkspace.ListChecklistItemsAsync(model.SceneID);
model.Attachments = model.SceneID == 0 ? [] : await writerWorkspace.ListAttachmentsAsync(model.SceneID);
model.SceneNoteTypeOptions = ChapterService.ToSelectList(noteTypes, x => x.SceneNoteTypeID, x => x.TypeName);
model.SceneAttachmentTypeOptions = ChapterService.ToSelectList(attachmentTypes, x => x.SceneAttachmentTypeID, x => x.TypeName);
model.DraftStatusOptions = DraftStatuses()
.Select(x => new SelectListItem(x, x, string.Equals(x, model.Workflow.DraftStatus, StringComparison.OrdinalIgnoreCase)))
.ToList();
model.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}");
model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName);
model.AssetOptions = ToOptionalSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName);
model.AssetKindOptions = ChapterService.ToSelectList(assetLookups.AssetKinds, x => x.AssetKindID, x => x.KindName);
model.AssetStateOptions = ToOptionalSelectList(assetLookups.AssetStates, x => x.AssetStateID, x => x.StateName);
model.AssetEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName);
model.AssetCustodyEventTypeOptions = ChapterService.ToSelectList(assetLookups.AssetCustodyEventTypes, x => x.AssetCustodyEventTypeID, x => x.TypeName);
model.CustodyRoleOptions = ChapterService.ToSelectList(assetLookups.CustodyRoles, x => x.CustodyRoleID, x => x.RoleName);
model.CharacterOptions = ToOptionalSelectList(projectCharacters, x => x.CharacterID, x => x.CharacterName);
model.CharacterRoleOptions = ToOptionalSelectList(characterLookups.RoleTypes, x => x.CharacterRoleInSceneTypeID, x => x.TypeName);
model.PresenceTypeOptions = ToOptionalSelectList(characterLookups.PresenceTypes, x => x.PresenceTypeID, x => x.TypeName);
model.CharacterAttributeTypeOptions = ChapterService.ToSelectList(characterLookups.AttributeTypes, x => x.CharacterAttributeTypeID, x => x.AttributeName);
model.KnowledgeStateOptions = ChapterService.ToSelectList(characterLookups.KnowledgeStates, x => x.KnowledgeStateID, x => x.StateName);
model.RelationshipOptions = ToOptionalSelectList(projectRelationships, x => x.CharacterRelationshipID, x => $"{x.CharacterAName} -> {x.CharacterBName} ({x.RelationshipTypeName})");
model.RelationshipTypeOptions = ToOptionalSelectList(characterLookups.RelationshipTypes, x => x.RelationshipTypeID, x => x.TypeName);
model.RelationshipStateOptions = ChapterService.ToSelectList(characterLookups.RelationshipStates, x => x.RelationshipStateID, x => x.StateName);
model.LocationOptions = ToOptionalSelectList(projectLocations, x => x.LocationID, x => new string(' ', x.Depth * 2) + x.LocationName);
model.SceneOptions = ToOptionalSelectList(allSceneOptions.Where(x => x.SceneID != model.SceneID), x => x.SceneID, x => $"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}");
model.ChapterOptions = ChapterService.ToSelectList(chapterOptions, x => x.ChapterID, x => $"{x.BookTitle} / Chapter {x.ChapterNumber}: {x.ChapterTitle}");
model.SceneDependencyTypeOptions = ChapterService.ToSelectList(dependencyLookups.DependencyTypes, x => x.SceneDependencyTypeID, x => x.TypeName);
model.NewThreadEvent = new ThreadEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
EventTitle = string.Empty,
EventTypeID = plotLookups.ThreadEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.ThreadEventTypeID
?? plotLookups.ThreadEventTypes.FirstOrDefault()?.ThreadEventTypeID
?? 0
};
model.NewAssetEvent = new AssetEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetEventTypeID = assetLookups.AssetEventTypes.FirstOrDefault(x => x.TypeName == "Mentioned")?.AssetEventTypeID
?? assetLookups.AssetEventTypes.FirstOrDefault()?.AssetEventTypeID
?? 0,
NewAssetKindID = assetLookups.AssetKinds.FirstOrDefault(x => x.KindName == "Other")?.AssetKindID
};
model.NewAssetCustodyEvent = new AssetCustodyCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
AssetCustodyEventTypeID = assetLookups.AssetCustodyEventTypes.FirstOrDefault(x => x.TypeName == "Has")?.AssetCustodyEventTypeID
?? assetLookups.AssetCustodyEventTypes.FirstOrDefault()?.AssetCustodyEventTypeID
?? 0,
CustodyRoleID = assetLookups.CustodyRoles.FirstOrDefault(x => x.RoleName == "Has Custody")?.CustodyRoleID
?? assetLookups.CustodyRoles.FirstOrDefault()?.CustodyRoleID
?? 0
};
model.NewSceneAssetLocation = new SceneAssetLocationCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
UpdateCurrentLocation = true
};
model.NewSceneCharacter = new SceneCharacterCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RoleInSceneTypeID = characterLookups.RoleTypes.FirstOrDefault(x => x.TypeName == "Main Participant")?.CharacterRoleInSceneTypeID,
PresenceTypeID = characterLookups.PresenceTypes.FirstOrDefault(x => x.TypeName == "Present")?.PresenceTypeID
};
model.NewCharacterAttributeEvent = new CharacterAttributeEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
CharacterAttributeTypeID = characterLookups.AttributeTypes.FirstOrDefault()?.CharacterAttributeTypeID ?? 0
};
model.NewCharacterKnowledge = new CharacterKnowledgeCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
KnowledgeStateID = characterLookups.KnowledgeStates.FirstOrDefault(x => x.StateName == "Knows")?.KnowledgeStateID
?? characterLookups.KnowledgeStates.FirstOrDefault()?.KnowledgeStateID
?? 0
};
model.NewRelationshipEvent = new RelationshipEventCreateViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
RelationshipStateID = characterLookups.RelationshipStates.FirstOrDefault(x => x.StateName == "Unknown")?.RelationshipStateID
?? characterLookups.RelationshipStates.FirstOrDefault()?.RelationshipStateID
?? 0
};
model.NewDependency = new SceneDependencyCreateViewModel
{
ProjectID = model.Project.ProjectID,
CurrentSceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneDependencyTypeID = dependencyLookups.DependencyTypes.FirstOrDefault(x => x.TypeName == "Must Occur Before")?.SceneDependencyTypeID
?? dependencyLookups.DependencyTypes.FirstOrDefault()?.SceneDependencyTypeID
?? 0
};
model.NewNote = new SceneNoteEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneNoteTypeID = noteTypes.FirstOrDefault(x => x.TypeName == "Drafting Note")?.SceneNoteTypeID
?? noteTypes.FirstOrDefault()?.SceneNoteTypeID
?? 0
};
model.NewChecklistItem = new SceneChecklistItemEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.NewAttachment = new SceneAttachmentEditViewModel
{
SceneID = model.SceneID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID,
SceneAttachmentTypeID = attachmentTypes.FirstOrDefault(x => x.TypeName == "Link")?.SceneAttachmentTypeID
?? attachmentTypes.FirstOrDefault()?.SceneAttachmentTypeID
?? 0
};
model.MoveRequest = new SceneMoveRequestViewModel
{
SceneID = model.SceneID,
TargetChapterID = model.ChapterID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
internal static IReadOnlyList<string> DraftStatuses() =>
[
"Planned",
"Ready To Draft",
"Drafting",
"Drafted",
"Revising",
"Polishing",
"Complete",
"On Hold"
];
private static IReadOnlyList<SelectListItem> ToOptionalSelectList<T>(IEnumerable<T> rows, Func<T, int> value, Func<T, string> text)
=> rows.Select(x => new SelectListItem(text(x), value(x).ToString())).ToList();
public async Task<SceneMovePreviewViewModel?> GetMovePreviewAsync(SceneMoveRequestViewModel request)
{
var preview = await scenes.GetMovePreviewAsync(request.SceneID, request.TargetChapterID, request.Position);
if (preview is null)
{
return null;
}
return new SceneMovePreviewViewModel
{
Preview = preview,
Request = request,
CurrentWarnings = await warnings.ListBySceneAsync(request.SceneID)
};
}
public async Task<ContinuityValidationSummary?> MoveToChapterAsync(SceneMoveRequestViewModel request)
{
var preview = await scenes.GetMovePreviewAsync(request.SceneID, request.TargetChapterID, request.Position);
if (preview is null)
{
return null;
}
await scenes.MoveToChapterAsync(request.SceneID, request.TargetChapterID, request.Position, request.RenumberScenes);
var summary = await warnings.ValidateBookAsync(preview.TargetBookID);
if (request.ReturnProjectID.HasValue)
{
await warnings.FinaliseSceneDependencyWarningsAsync(request.ReturnProjectID.Value, preview.TargetBookID);
}
return summary;
}
public Task RenumberChapterAsync(int chapterId) => scenes.RenumberChapterAsync(chapterId);
public Task<int> AddDependencyAsync(SceneDependencyCreateViewModel model)
{
var sourceSceneId = model.Direction == "RelatedDependsOnCurrent"
? model.CurrentSceneID
: model.RelatedSceneID;
var targetSceneId = model.Direction == "RelatedDependsOnCurrent"
? model.RelatedSceneID
: model.CurrentSceneID;
return sceneDependencies.SaveAsync(new SceneDependency
{
ProjectID = model.ProjectID,
SourceSceneID = sourceSceneId,
TargetSceneID = targetSceneId,
SceneDependencyTypeID = model.SceneDependencyTypeID,
Description = model.Description,
IsHardDependency = model.IsHardDependency
});
}
public Task DeleteDependencyAsync(int sceneDependencyId) => sceneDependencies.ArchiveAsync(sceneDependencyId);
private static IReadOnlyList<string> BuildLocationConsistencyMessages(SceneEditViewModel model)
{
if (model.SceneID == 0)
{
return [];
}
var messages = new List<string>();
if (!model.PrimaryLocationID.HasValue)
{
messages.Add("This scene has no primary location yet.");
}
foreach (var character in model.SceneCharacters.Where(x => !x.LocationID.HasValue))
{
messages.Add($"{character.CharacterName} is in the scene, but has no scene location.");
}
var placedAssetIds = model.SceneAssetLocations.Select(x => x.StoryAssetID).ToHashSet();
var custodyAssetIds = model.AssetCustodyEvents.Select(x => x.StoryAssetID).ToHashSet();
foreach (var assetEvent in model.AssetEvents.Where(x => !placedAssetIds.Contains(x.StoryAssetID) && !custodyAssetIds.Contains(x.StoryAssetID)))
{
messages.Add($"{assetEvent.AssetName} is used in this scene, but has no scene location or custody entry.");
}
return messages;
}
}
public sealed class TimelineService(
ITimelineRepository timelineRepository,
ITimelinePresetRepository presets,
ITimelineSettingsRepository timelineSettings,
IProjectRepository projects,
IBookRepository books,
ISceneService scenes,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IPlotRepository plots,
ILookupRepository lookups,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies,
ILogger<TimelineService> logger) : ITimelineService
{
public async Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter)
{
var projectId = filter.ProjectID;
var bookId = filter.BookID;
var selectedSceneId = filter.SelectedSceneID;
var settings = await TimeTimelineLoadAsync("dbo.ProjectTimelineSettings_Get", () => timelineSettings.GetAsync(projectId), projectId, bookId);
if (settings is null)
{
return null;
}
var selectedMetricIds = (await TimeTimelineLoadAsync("dbo.ProjectTimelineMetricSettings_List", () => timelineSettings.ListMetricSettingsAsync(projectId), projectId, bookId))
.OrderBy(x => x.SortOrder)
.Select(x => x.MetricTypeID)
.ToList();
var needsPlotTimelineData = settings.ShowPlotLines || filter.PlotLineID.HasValue || filter.PlotThreadID.HasValue || filter.FocusType is "plotline" or "plotthread";
var needsMetricTimelineData = settings.ShowMetricShape && selectedMetricIds.Any();
var needsAssetTimelineData = settings.ShowStoryAssets || filter.StoryAssetID.HasValue || filter.FocusType == "asset";
var needsCharacterTimelineData = settings.ShowCharacterAppearances || filter.CharacterID.HasValue || filter.FocusType == "character";
var needsWarnings = settings.ShowWarnings || filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly;
var timeline = await TimeTimelineLoadAsync(
"dbo.Timeline_GetByProject",
() => timelineRepository.GetByProjectAsync(projectId, bookId, needsMetricTimelineData, selectedMetricIds, needsPlotTimelineData),
projectId,
bookId);
if (timeline.Project is null)
{
return null;
}
var allBooks = await TimeTimelineLoadAsync("dbo.Book_ListByProject", () => books.ListByProjectAsync(projectId), projectId, bookId);
var lookupData = await TimeTimelineLoadAsync("dbo.Lookup_All", lookups.GetAllAsync, projectId, bookId);
var assetLookupData = await TimeTimelineLoadAsync("dbo.AssetLookup_All", assets.GetLookupsAsync, projectId, bookId);
var characterLookupData = await TimeTimelineLoadAsync("dbo.CharacterLookup_All", characters.GetLookupsAsync, projectId, bookId);
var locationLookupData = await TimeTimelineLoadAsync("dbo.LocationLookup_All", locations.GetLookupsAsync, projectId, bookId);
var plotLines = await TimeTimelineLoadAsync("dbo.PlotLine_ListByProject", () => plots.ListPlotLinesAsync(projectId), projectId, bookId);
var plotThreads = await TimeTimelineLoadAsync("dbo.PlotThread_ListByProject", () => plots.ListPlotThreadsByProjectAsync(projectId), projectId, bookId);
var allAssets = await TimeTimelineLoadAsync("dbo.StoryAsset_ListByProject", () => assets.ListAssetsAsync(projectId), projectId, bookId);
var allCharacters = await TimeTimelineLoadAsync("dbo.Character_ListByProject", () => characters.ListCharactersAsync(projectId), projectId, bookId);
var allLocations = await TimeTimelineLoadAsync("dbo.Location_ListByProject", () => locations.ListByProjectAsync(projectId), projectId, bookId);
var warningLookups = await TimeTimelineLoadAsync("dbo.WarningLookup_All", warnings.GetLookupsAsync, projectId, bookId);
var chaptersByBook = timeline.Chapters.GroupBy(x => x.BookID).ToDictionary(x => x.Key, x => x.ToList());
var scenesByChapter = timeline.Scenes.GroupBy(x => x.ChapterID).ToDictionary(x => x.Key, x => x.ToList());
var purposeLabelsByScene = timeline.PurposeLabels.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.OrderBy(p => p.SortOrder).ToList());
var orderedScenes = timeline.Scenes.ToList();
var warningCounts = needsWarnings
? (await TimeTimelineLoadAsync("dbo.ContinuityWarning_SceneCounts", () => warnings.GetSceneCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount)
: new Dictionary<int, int>();
var dependencyCounts = (await TimeTimelineLoadAsync("dbo.SceneDependency_Counts", () => sceneDependencies.GetCountsAsync(projectId, bookId), projectId, bookId)).ToDictionary(x => x.SceneID, x => x.DependencyCount);
foreach (var scene in orderedScenes)
{
scene.PurposeLabels = purposeLabelsByScene.GetValueOrDefault(scene.SceneID, []);
scene.WarningCount = warningCounts.GetValueOrDefault(scene.SceneID);
scene.DependencyCount = dependencyCounts.GetValueOrDefault(scene.SceneID);
}
(IReadOnlyList<StoryAsset> Assets, IReadOnlyList<AssetEvent> Events) assetTimeline = needsAssetTimelineData
? await TimeTimelineLoadAsync("dbo.AssetTimeline_GetByProject", () => assets.GetTimelineAsync(projectId, bookId), projectId, bookId)
: ([], []);
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline = needsCharacterTimelineData
? await TimeTimelineLoadAsync("dbo.CharacterTimeline_GetByProject", () => characters.GetTimelineAsync(projectId, bookId), projectId, bookId)
: ([], []);
var scenePovLabels = characterTimeline.Appearances
.Where(x => x.RoleInSceneTypeName?.Contains("POV", StringComparison.OrdinalIgnoreCase) == true)
.GroupBy(x => x.SceneID)
.ToDictionary(x => x.Key, x => x.First().CharacterName);
var matchingSceneIds = await TimeTimelineLoadAsync("TimelineService.BuildMatchingSceneIds", () => BuildMatchingSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId), projectId, bookId);
var focusSceneIds = await TimeTimelineLoadAsync("TimelineService.BuildFocusSceneIds", () => BuildFocusSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId), projectId, bookId);
var focusLabel = BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations);
var selectedScene = selectedSceneId.HasValue
? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId)
: null;
if (selectedScene is not null)
{
selectedScene.ReturnProjectID = projectId;
selectedScene.ReturnBookID = bookId;
selectedScene.ReturnToTimeline = true;
selectedScene.NewThreadEvent.ReturnProjectID = projectId;
selectedScene.NewThreadEvent.ReturnBookID = bookId;
selectedScene.NewAssetEvent.ReturnProjectID = projectId;
selectedScene.NewAssetEvent.ReturnBookID = bookId;
selectedScene.NewAssetCustodyEvent.ReturnProjectID = projectId;
selectedScene.NewAssetCustodyEvent.ReturnBookID = bookId;
}
return new TimelineViewModel
{
Project = timeline.Project,
Settings = settings,
SelectedBookID = bookId,
SelectedSceneID = selectedScene?.SceneID,
SelectedScene = selectedScene,
Filter = filter,
BookOptions = allBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString())).ToList(),
ChapterFromOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterFromID)).ToList(),
ChapterToOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterToID)).ToList(),
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString())).ToList(),
SceneFromOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneFromID)).ToList(),
SceneToOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SceneToID)).ToList(),
PlotLineOptions = plotLines.Select(x => new SelectListItem(x.PlotLineName, x.PlotLineID.ToString(), x.PlotLineID == filter.PlotLineID)).ToList(),
PlotThreadOptions = plotThreads.Select(x => new SelectListItem(x.ThreadTitle, x.PlotThreadID.ToString(), x.PlotThreadID == filter.PlotThreadID)).ToList(),
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), x.StoryAssetID == filter.StoryAssetID)).ToList(),
QuickAddAssets = allAssets
.Where(x => x.ShowInQuickAddBar)
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.ToList(),
AssetEventTypeOptions = ChapterService.ToSelectList(assetLookupData.AssetEventTypes, x => x.AssetEventTypeID, x => x.TypeName),
AssetStateOptions = assetLookupData.AssetStates.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList(),
AssetDependencyTypeOptions = ChapterService.ToSelectList(assetLookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName),
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.CharacterID)).ToList(),
QuickAddCharacters = allCharacters
.Where(x => x.ShowInQuickAddBar)
.OrderByDescending(x => x.CharacterImportance ?? 0)
.ThenBy(x => x.CharacterName)
.ToList(),
CharacterRoleOptions = characterLookupData.RoleTypes.Select(x => new SelectListItem(x.TypeName, x.CharacterRoleInSceneTypeID.ToString())).ToList(),
PresenceTypeOptions = characterLookupData.PresenceTypes.Select(x => new SelectListItem(x.TypeName, x.PresenceTypeID.ToString())).ToList(),
PovCharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == filter.POVCharacterID)).ToList(),
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), x.LocationID == filter.LocationID)).ToList(),
QuickAddLocations = allLocations
.Where(x => x.ShowInQuickAddBar)
.OrderBy(x => x.LocationPath)
.ToList(),
LocationRelationshipTypeOptions = ChapterService.ToSelectList(locationLookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName),
WarningSeverityOptions = warningLookups.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == filter.WarningSeverityID)).ToList(),
WarningTypeOptions = warningLookups.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == filter.WarningTypeID)).ToList(),
RevisionStatusOptions = lookupData.RevisionStatuses.Select(x => new SelectListItem(x.StatusName, x.RevisionStatusID.ToString(), x.RevisionStatusID == filter.RevisionStatusID)).ToList(),
ScenePurposeOptions = lookupData.ScenePurposeTypes.Select(x => new SelectListItem(x.PurposeName, x.ScenePurposeTypeID.ToString(), x.ScenePurposeTypeID == filter.ScenePurposeTypeID)).ToList(),
ViewPresets = await TimeTimelineLoadAsync("dbo.TimelineViewPreset_List", () => presets.ListAsync(projectId, bookId), projectId, bookId),
Books = timeline.Books.Select(book => new BookTimelineViewModel
{
Book = book,
Chapters = chaptersByBook.GetValueOrDefault(book.BookID, [])
.Select(chapter => new ChapterTimelineViewModel
{
Chapter = chapter,
Scenes = scenesByChapter.GetValueOrDefault(chapter.ChapterID, [])
})
.ToList()
}).ToList(),
OrderedScenes = orderedScenes,
MetricGraphs = settings.ShowMetricShape ? BuildMetricGraphs(timeline, orderedScenes) : [],
PlotLanes = settings.ShowPlotLines ? BuildPlotLanes(timeline, orderedScenes) : [],
AssetLanes = settings.ShowStoryAssets ? BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes) : [],
CharacterLanes = settings.ShowCharacterAppearances ? BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes) : [],
MatchingSceneIDs = matchingSceneIds,
FocusSceneIDs = focusSceneIds,
ScenePovLabels = scenePovLabels,
FocusLabel = focusLabel
};
}
public Task<TimelineViewPreset?> GetPresetAsync(int timelineViewPresetId) => presets.GetAsync(timelineViewPresetId);
public Task ArchivePresetAsync(int timelineViewPresetId) => presets.ArchiveAsync(timelineViewPresetId);
public async Task<TimelineSettingsViewModel?> GetSettingsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var settings = await timelineSettings.GetAsync(projectId) ?? new ProjectTimelineSettings { ProjectID = projectId };
var metricTypes = await timelineSettings.ListMetricTypesAsync(projectId);
var selectedMetricIds = (await timelineSettings.ListMetricSettingsAsync(projectId)).Select(x => x.MetricTypeID).ToHashSet();
return new TimelineSettingsViewModel
{
ProjectID = project.ProjectID,
ProjectName = project.ProjectName,
ShowSceneCards = settings.ShowSceneCards,
ShowMetricShape = settings.ShowMetricShape,
ShowPlotLines = settings.ShowPlotLines,
ShowStoryAssets = settings.ShowStoryAssets,
ShowCharacterAppearances = settings.ShowCharacterAppearances,
ShowWarnings = settings.ShowWarnings,
SelectedMetricTypeIDs = selectedMetricIds.ToList(),
MetricOptions = metricTypes
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.MetricName)
.Select(x => new TimelineMetricSettingOptionViewModel
{
MetricTypeID = x.MetricTypeID,
MetricName = x.MetricName,
Description = x.Description,
SortOrder = x.SortOrder,
IsSelected = selectedMetricIds.Contains(x.MetricTypeID)
})
.ToList()
};
}
public async Task SaveSettingsAsync(TimelineSettingsViewModel model)
{
if (model.ShowMetricShape && !model.SelectedMetricTypeIDs.Any())
{
throw new InvalidOperationException("Select at least one metric or disable Metric Shape.");
}
await timelineSettings.SaveAsync(new ProjectTimelineSettings
{
ProjectID = model.ProjectID,
ShowSceneCards = model.ShowSceneCards,
ShowMetricShape = model.ShowMetricShape,
ShowPlotLines = model.ShowPlotLines,
ShowStoryAssets = model.ShowStoryAssets,
ShowCharacterAppearances = model.ShowCharacterAppearances,
ShowWarnings = model.ShowWarnings
}, model.SelectedMetricTypeIDs);
}
private async Task<T> TimeTimelineLoadAsync<T>(string operationName, Func<Task<T>> operation, int projectId, int? bookId)
{
var stopwatch = Stopwatch.StartNew();
try
{
return await operation();
}
finally
{
stopwatch.Stop();
logger.LogInformation(
"Timeline load operation {OperationName} completed in {ElapsedMilliseconds} ms for ProjectID {ProjectID}, BookID {BookID}.",
operationName,
stopwatch.ElapsedMilliseconds,
projectId,
bookId);
}
}
public async Task<ContinuityValidationSummary?> MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model)
{
if (model.SceneID == model.AnchorSceneID)
{
return null;
}
if (model.AnchorSceneID > 0)
{
await scenes.MoveRelativeAsync(model.SceneID, model.AnchorSceneID, model.Position == "Before" ? "Before" : "After");
}
else if (model.TargetChapterID.HasValue)
{
await scenes.MoveToChapterAsync(new SceneMoveRequestViewModel
{
SceneID = model.SceneID,
TargetChapterID = model.TargetChapterID.Value,
Position = "End",
RenumberScenes = true,
ReturnProjectID = model.ProjectID,
ReturnBookID = model.BookID
});
}
else
{
return null;
}
await warnings.FinaliseSceneDependencyWarningsAsync(model.ProjectID, model.BookID);
return model.BookID.HasValue
? await warnings.ValidateBookAsync(model.BookID.Value)
: await warnings.ValidateProjectAsync(model.ProjectID);
}
public async Task AddCharacterByDropAsync(TimelineSceneCharacterDropViewModel model)
{
var characterId = model.CharacterID.GetValueOrDefault();
if (characterId == 0)
{
throw new InvalidOperationException("Choose a character to drop onto the scene.");
}
await characters.SaveSceneCharacterAsync(new SceneCharacter
{
SceneID = model.SceneID,
CharacterID = characterId,
RoleInSceneTypeID = model.RoleInSceneTypeID,
PresenceTypeID = model.PresenceTypeID,
LocationID = model.LocationID
});
}
public async Task AddAssetEventByDropAsync(TimelineAssetEventDropViewModel model)
{
var assetId = model.StoryAssetID.GetValueOrDefault();
if (assetId == 0)
{
throw new InvalidOperationException("Choose an asset to drop onto the scene.");
}
var lookupData = await assets.GetLookupsAsync();
var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID)
?? lookupData.AssetEventTypes.First();
var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim();
var description = model.EventDescription;
if (!string.IsNullOrWhiteSpace(model.CustodyNote))
{
description = string.IsNullOrWhiteSpace(description)
? $"Custody note: {model.CustodyNote.Trim()}"
: $"{description.Trim()}\n\nCustody note: {model.CustodyNote.Trim()}";
}
await assets.SaveEventAsync(new AssetEvent
{
StoryAssetID = assetId,
SceneID = model.SceneID,
AssetEventTypeID = eventType.AssetEventTypeID,
FromStateID = model.FromStateID,
ToStateID = model.ToStateID,
EventTitle = title,
EventDescription = description
});
if (model.LocationID.HasValue)
{
await locations.SaveSceneAssetLocationAsync(new SceneAssetLocation
{
SceneID = model.SceneID,
StoryAssetID = assetId,
LocationID = model.LocationID.Value,
Description = title
}, updateCurrentLocation: true);
}
}
public async Task AddAssetDependencyByDropAsync(TimelineAssetDependencyDropViewModel model)
{
if (model.SourceAssetID == model.TargetAssetID)
{
throw new InvalidOperationException("An asset cannot depend on itself.");
}
var existing = await assets.ListDependenciesByAssetAsync(model.SourceAssetID);
if (existing.Any(x => x.SourceAssetID == model.SourceAssetID && x.TargetAssetID == model.TargetAssetID && x.AssetDependencyTypeID == model.AssetDependencyTypeID))
{
throw new InvalidOperationException("That asset dependency already exists.");
}
await assets.SaveDependencyAsync(new AssetDependency
{
SourceAssetID = model.SourceAssetID,
TargetAssetID = model.TargetAssetID,
AssetDependencyTypeID = model.AssetDependencyTypeID,
SourceRequiredStateID = model.SourceRequiredStateID,
TargetBlockedStateID = model.TargetBlockedStateID,
Description = model.Description
});
}
public async Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model)
{
if (model.FromLocationID == model.ToLocationID)
{
throw new InvalidOperationException("A location cannot be related to itself.");
}
var existing = await locations.ListRelationshipsByLocationAsync(model.FromLocationID);
if (existing.Any(x => x.FromLocationID == model.FromLocationID && x.ToLocationID == model.ToLocationID && x.LocationRelationshipTypeID == model.LocationRelationshipTypeID))
{
throw new InvalidOperationException("That location relationship already exists.");
}
await locations.SaveRelationshipAsync(new LocationRelationship
{
FromLocationID = model.FromLocationID,
ToLocationID = model.ToLocationID,
LocationRelationshipTypeID = model.LocationRelationshipTypeID,
IsBidirectional = model.IsBidirectional,
Strength = model.Strength,
CanHearNormalSpeech = model.CanHearNormalSpeech,
CanHearLoudNoise = model.CanHearLoudNoise,
CanSeeMovement = model.CanSeeMovement,
CanSeeClearly = model.CanSeeClearly,
CanTravelDirectly = model.CanTravelDirectly,
Notes = model.Notes
});
}
public Task<int> SavePresetAsync(TimelinePresetSaveViewModel model)
{
var filter = model.Filter;
filter.ProjectID = model.ProjectID;
filter.BookID = model.BookID;
return presets.SaveAsync(new TimelineViewPreset
{
ProjectID = model.ProjectID,
BookID = model.BookID,
PresetName = string.IsNullOrWhiteSpace(model.PresetName) ? "Untitled view" : model.PresetName.Trim(),
FilterJson = System.Text.Json.JsonSerializer.Serialize(filter),
VisibilityJson = string.IsNullOrWhiteSpace(model.VisibilityJson) ? "{}" : model.VisibilityJson,
FocusType = filter.FocusType,
FocusID = filter.FocusID
});
}
private async Task<IReadOnlySet<int>> BuildMatchingSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
var matches = orderedScenes.Select(x => x.SceneID).ToHashSet();
void Intersect(IEnumerable<int> sceneIds) => matches.IntersectWith(sceneIds.ToHashSet());
if (filter.ChapterFromID.HasValue)
{
var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.ChapterID == filter.ChapterFromID)?.index;
if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID));
}
if (filter.ChapterToID.HasValue)
{
var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).LastOrDefault(x => x.scene.ChapterID == filter.ChapterToID)?.index;
if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID));
}
if (filter.SceneFromID.HasValue)
{
var fromIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneFromID)?.index;
if (fromIndex.HasValue) Intersect(orderedScenes.Skip(fromIndex.Value).Select(x => x.SceneID));
}
if (filter.SceneToID.HasValue)
{
var toIndex = orderedScenes.Select((scene, index) => new { scene, index }).FirstOrDefault(x => x.scene.SceneID == filter.SceneToID)?.index;
if (toIndex.HasValue) Intersect(orderedScenes.Take(toIndex.Value + 1).Select(x => x.SceneID));
}
if (filter.RevisionStatusID.HasValue) Intersect(orderedScenes.Where(x => x.RevisionStatusID == filter.RevisionStatusID).Select(x => x.SceneID));
if (filter.ScenePurposeTypeID.HasValue) Intersect(orderedScenes.Where(x => x.PurposeLabels.Any(p => p.ScenePurposeTypeID == filter.ScenePurposeTypeID)).Select(x => x.SceneID));
if (filter.POVCharacterID.HasValue) Intersect(orderedScenes.Where(x => x.POVCharacterID == filter.POVCharacterID).Select(x => x.SceneID));
if (filter.PlotLineID.HasValue) Intersect(threadEvents.Where(x => x.PlotLineID == filter.PlotLineID).Select(x => x.SceneID));
if (filter.PlotThreadID.HasValue) Intersect(threadEvents.Where(x => x.PlotThreadID == filter.PlotThreadID).Select(x => x.SceneID));
if (filter.StoryAssetID.HasValue) Intersect(assetEvents.Where(x => x.StoryAssetID == filter.StoryAssetID).Select(x => x.SceneID));
if (filter.CharacterID.HasValue) Intersect(characterAppearances.Where(x => x.CharacterID == filter.CharacterID).Select(x => x.SceneID));
if (filter.LocationID.HasValue)
{
var locationScenes = (await TimeTimelineLoadAsync("dbo.LocationScene_ListByLocation", () => locations.ListScenesByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID);
var characterLocationScenes = (await TimeTimelineLoadAsync("dbo.LocationCharacter_ListByLocation", () => locations.ListCharactersByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID);
var assetLocationScenes = (await TimeTimelineLoadAsync("dbo.SceneAssetLocation_ListByLocation", () => locations.ListAssetsByLocationAsync(filter.LocationID.Value), projectId, bookId)).Select(x => x.SceneID);
Intersect(locationScenes.Concat(characterLocationScenes).Concat(assetLocationScenes));
}
if (filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly)
{
var warningScenes = (await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId))
.Where(x => x.SceneID.HasValue)
.Select(x => x.SceneID!.Value);
Intersect(warningScenes);
}
return matches;
}
private async Task<IReadOnlySet<int>> BuildFocusSceneIdsAsync(
TimelineFilterViewModel filter,
IReadOnlyList<Scene> orderedScenes,
IReadOnlyList<ThreadEvent> threadEvents,
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneCharacter> characterAppearances,
int projectId,
int? bookId)
{
if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue)
{
return new HashSet<int>();
}
var focusType = filter.FocusType.ToLowerInvariant();
return focusType switch
{
"character" => characterAppearances.Where(x => x.CharacterID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"asset" => assetEvents.Where(x => x.StoryAssetID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"plotline" => threadEvents.Where(x => x.PlotLineID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"plotthread" => threadEvents.Where(x => x.PlotThreadID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
"location" => (await TimeTimelineLoadAsync("dbo.LocationScene_ListByLocation", () => locations.ListScenesByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID)
.Concat((await TimeTimelineLoadAsync("dbo.LocationCharacter_ListByLocation", () => locations.ListCharactersByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID))
.Concat((await TimeTimelineLoadAsync("dbo.SceneAssetLocation_ListByLocation", () => locations.ListAssetsByLocationAsync(filter.FocusID.Value), projectId, bookId)).Select(x => x.SceneID))
.ToHashSet(),
"scene" => orderedScenes.Where(x => x.SceneID == filter.FocusID).Select(x => x.SceneID).ToHashSet(),
_ => new HashSet<int>()
};
}
private static string? BuildFocusLabel(TimelineFilterViewModel filter, IReadOnlyList<PlotLineItem> plotLines, IReadOnlyList<PlotThread> plotThreads, IReadOnlyList<StoryAsset> assets, IReadOnlyList<Character> characters, IReadOnlyList<LocationItem> locations)
{
if (string.IsNullOrWhiteSpace(filter.FocusType) || !filter.FocusID.HasValue) return null;
var name = filter.FocusType.ToLowerInvariant() switch
{
"character" => characters.FirstOrDefault(x => x.CharacterID == filter.FocusID)?.CharacterName,
"asset" => assets.FirstOrDefault(x => x.StoryAssetID == filter.FocusID)?.AssetName,
"plotline" => plotLines.FirstOrDefault(x => x.PlotLineID == filter.FocusID)?.PlotLineName,
"plotthread" => plotThreads.FirstOrDefault(x => x.PlotThreadID == filter.FocusID)?.ThreadTitle,
"location" => locations.FirstOrDefault(x => x.LocationID == filter.FocusID)?.LocationPath,
"scene" => "Selected scene",
_ => null
};
return string.IsNullOrWhiteSpace(name) ? null : $"{filter.FocusType}: {name}";
}
private static IReadOnlyList<MetricGraphViewModel> BuildMetricGraphs(TimelineData timeline, IReadOnlyList<Scene> orderedScenes)
{
var pointsByMetric = timeline.MetricPoints
.GroupBy(x => x.MetricTypeID)
.ToDictionary(x => x.Key, x => x.ToDictionary(point => point.SceneID));
return timeline.MetricTypes
.OrderBy(metric => metric.SortOrder)
.Select(metric => new MetricGraphViewModel
{
MetricTypeID = metric.MetricTypeID,
MetricName = metric.MetricName,
Points = orderedScenes.Select(scene =>
{
var value = metric.DefaultValue;
if (pointsByMetric.TryGetValue(metric.MetricTypeID, out var scenePoints)
&& scenePoints.TryGetValue(scene.SceneID, out var point))
{
value = point.Value;
}
return new MetricGraphPointViewModel
{
SceneID = scene.SceneID,
SceneNumber = scene.SceneNumber,
SceneTitle = scene.SceneTitle,
Value = value,
Percent = Math.Clamp((int)Math.Round((value - metric.MinValue) * 100.0 / (metric.MaxValue - metric.MinValue)), 0, 100)
};
}).ToList()
}).ToList();
}
private static IReadOnlyList<PlotLineLaneViewModel> BuildPlotLanes(TimelineData timeline, IReadOnlyList<Scene> orderedScenes)
{
var eventsByPlotLineAndScene = timeline.ThreadEvents
.GroupBy(x => x.PlotLineID)
.ToDictionary(
lineGroup => lineGroup.Key,
lineGroup => lineGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return timeline.PlotLines
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.PlotLineName)
.Select(plotLine =>
{
eventsByPlotLineAndScene.TryGetValue(plotLine.PlotLineID, out var sceneEvents);
return new PlotLineLaneViewModel
{
PlotLine = plotLine,
SceneSlots = orderedScenes.Select(scene => new PlotLaneSceneViewModel
{
Scene = scene,
Events = sceneEvents is not null && sceneEvents.TryGetValue(scene.SceneID, out var events)
? events
: []
}).ToList()
};
}).ToList();
}
private static IReadOnlyList<AssetLaneViewModel> BuildAssetLanes(IReadOnlyList<StoryAsset> assets, IReadOnlyList<AssetEvent> events, IReadOnlyList<Scene> orderedScenes)
{
var eventsByAssetAndScene = events
.GroupBy(x => x.StoryAssetID)
.ToDictionary(
assetGroup => assetGroup.Key,
assetGroup => assetGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return assets
.OrderByDescending(x => x.Importance)
.ThenBy(x => x.AssetName)
.Select(asset =>
{
eventsByAssetAndScene.TryGetValue(asset.StoryAssetID, out var sceneEvents);
return new AssetLaneViewModel
{
Asset = asset,
SceneSlots = orderedScenes.Select(scene => new AssetLaneSceneViewModel
{
Scene = scene,
Events = sceneEvents is not null && sceneEvents.TryGetValue(scene.SceneID, out var assetEvents)
? assetEvents
: []
}).ToList()
};
}).ToList();
}
private static IReadOnlyList<CharacterLaneViewModel> BuildCharacterLanes(IReadOnlyList<Character> characters, IReadOnlyList<SceneCharacter> appearances, IReadOnlyList<Scene> orderedScenes)
{
var appearancesByCharacterAndScene = appearances
.GroupBy(x => x.CharacterID)
.ToDictionary(
characterGroup => characterGroup.Key,
characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return characters
// Timeline grouping prefers CharacterImportance when present; imported classification
// metadata and strong scene roles remain as fallbacks for older projects.
.OrderByDescending(character => CharacterLanePriority(character, appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances) ? sceneAppearances.Values.SelectMany(x => x) : []))
.ThenBy(x => x.CharacterName)
.Select(character =>
{
appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances);
return new CharacterLaneViewModel
{
Character = character,
SceneSlots = orderedScenes.Select(scene => new CharacterLaneSceneViewModel
{
Scene = scene,
Appearances = sceneAppearances is not null && sceneAppearances.TryGetValue(scene.SceneID, out var rows)
? rows
: []
}).ToList()
};
}).ToList();
}
private static int CharacterLanePriority(Character character, IEnumerable<SceneCharacter> appearances)
{
var description = character.DefaultDescription ?? string.Empty;
var appearanceList = appearances as IReadOnlyCollection<SceneCharacter> ?? appearances.ToList();
var priority = 0;
if (character.CharacterImportance >= 8)
{
priority += 1_200;
}
else if (character.CharacterImportance >= 4)
{
priority += 500;
}
else if (character.CharacterImportance.HasValue)
{
priority -= 150;
}
else if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase))
{
priority += 1_000;
}
else if (description.Contains("Imported character classification: Supporting", StringComparison.OrdinalIgnoreCase))
{
priority += 400;
}
else if (description.Contains("Imported character classification: Minor", StringComparison.OrdinalIgnoreCase))
{
priority -= 100;
}
priority += appearanceList.Count * 3;
foreach (var appearance in appearanceList)
{
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
{
priority += 80;
}
else if (string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase))
{
priority += 35;
}
else if (string.Equals(appearance.RoleInSceneTypeName, "Supporting Participant", StringComparison.OrdinalIgnoreCase))
{
priority += 10;
}
}
return priority;
}
}
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots) : 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,
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,
Description = plotLine.Description,
ParentPlotLineID = plotLine.ParentPlotLineID,
SortOrder = plotLine.SortOrder,
Colour = plotLine.Colour,
IsMainPlot = plotLine.IsMainPlot,
IsVisibleOnTimeline = plotLine.IsVisibleOnTimeline,
Project = project
}, lookupData, plotLines.Where(x => x.PlotLineID != plotLine.PlotLineID));
}
public Task<int> SavePlotLineAsync(PlotLineEditViewModel model) => plots.SavePlotLineAsync(new PlotLineItem
{
PlotLineID = model.PlotLineID,
ProjectID = model.ProjectID,
BookID = model.BookID,
PlotLineName = model.PlotLineName,
PlotLineTypeID = model.PlotLineTypeID,
Description = model.Description,
ParentPlotLineID = model.ParentPlotLineID,
SortOrder = model.SortOrder,
Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour,
IsMainPlot = model.IsMainPlot,
IsVisibleOnTimeline = model.IsVisibleOnTimeline
});
public Task ArchivePlotLineAsync(int plotLineId) => plots.ArchivePlotLineAsync(plotLineId);
public async Task<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 Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model) => plots.SavePlotThreadAsync(new PlotThread
{
PlotThreadID = model.PlotThreadID,
PlotLineID = model.PlotLineID,
ThreadTitle = model.ThreadTitle,
ThreadTypeID = model.ThreadTypeID,
ThreadStatusID = model.ThreadStatusID,
Importance = model.Importance,
Summary = model.Summary,
IntroducedSceneID = model.IntroducedSceneID,
PlannedResolutionSceneID = model.PlannedResolutionSceneID,
ActualResolutionSceneID = model.ActualResolutionSceneID
});
public Task ArchivePlotThreadAsync(int plotThreadId) => plots.ArchivePlotThreadAsync(plotThreadId);
public async Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model)
{
var plotThreadId = model.PlotThreadID.GetValueOrDefault();
var lookupData = await plots.GetLookupsAsync();
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
?? lookupData.ThreadEventTypes.First();
var title = string.IsNullOrWhiteSpace(model.EventTitle)
? eventType.TypeName
: model.EventTitle.Trim();
if (plotThreadId == 0)
{
if (!model.PlotLineID.HasValue)
{
throw new InvalidOperationException("Choose a plot line or an existing plot thread.");
}
var plannedStatusId = lookupData.ThreadStatuses.First(x => x.StatusName == "Planned").ThreadStatusID;
var threadTypeId = model.NewThreadTypeID
?? GetDefaultThreadTypeId(lookupData, eventType.TypeName);
var threadTitle = string.IsNullOrWhiteSpace(model.NewThreadTitle)
? title
: model.NewThreadTitle.Trim();
plotThreadId = await plots.SavePlotThreadAsync(new PlotThread
{
PlotLineID = model.PlotLineID.Value,
ThreadTitle = threadTitle,
ThreadTypeID = threadTypeId,
ThreadStatusID = plannedStatusId,
Importance = 5
});
}
return await plots.SaveThreadEventAsync(new ThreadEvent
{
PlotThreadID = plotThreadId,
SceneID = model.SceneID,
EventTypeID = eventType.ThreadEventTypeID,
EventTitle = title,
EventDescription = model.EventDescription
});
}
public Task DeleteThreadEventAsync(int threadEventId) => plots.DeleteThreadEventAsync(threadEventId);
private static int GetDefaultThreadTypeId(PlotLookupData lookupData, string eventTypeName)
{
var preferredTypeName = eventTypeName switch
{
"Clue Planted" => "Clue",
"Question Raised" or "Question Answered" => "Question",
"False Lead" => "Suspicion",
"Payoff" => "Payoff Setup",
"Promise Made" => "Promise",
"Threat Introduced" => "Threat",
_ => "Question"
};
return lookupData.ThreadTypes.FirstOrDefault(x => x.TypeName == preferredTypeName)?.ThreadTypeID
?? lookupData.ThreadTypes.First().ThreadTypeID;
}
private async Task<PlotLineEditViewModel> PopulatePlotLineListsAsync(PlotLineEditViewModel model, PlotLookupData lookupData, IEnumerable<PlotLineItem> parentCandidates)
{
model.PlotLineTypes = ChapterService.ToSelectList(lookupData.PlotLineTypes, x => x.PlotLineTypeID, x => x.TypeName);
model.BookOptions = (await books.ListByProjectAsync(model.ProjectID))
.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString()))
.ToList();
model.ParentPlotLineOptions = parentCandidates
.Select(x => new SelectListItem(x.PlotLineName, x.PlotLineID.ToString()))
.ToList();
return model;
}
private async Task<PlotThreadEditViewModel> PopulatePlotThreadListsAsync(PlotThreadEditViewModel model, PlotLookupData lookupData)
{
var plotLines = await plots.ListPlotLinesAsync(model.ProjectID);
model.PlotLineOptions = ChapterService.ToSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
model.ThreadTypeOptions = ChapterService.ToSelectList(lookupData.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
model.ThreadStatusOptions = ChapterService.ToSelectList(lookupData.ThreadStatuses, x => x.ThreadStatusID, x => x.StatusName);
model.SceneOptions = (await plots.ListSceneOptionsAsync(model.ProjectID))
.Select(x => new SelectListItem($"{x.BookTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString()))
.ToList();
return model;
}
}
public sealed class ContinuityValidationService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
IAssetRepository assets,
ICharacterRepository characters,
IWarningRepository warnings,
IDynamicsRepository dynamics) : IContinuityValidationService
{
public async Task<WarningDashboardViewModel?> GetDashboardAsync(
int projectId,
int? bookId,
int? sceneId,
int? warningTypeId,
int? warningSeverityId,
string? entityType,
bool includeDismissed,
bool includeIntentional,
ContinuityValidationSummary? summary = null)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var lookupData = await warnings.GetLookupsAsync();
var rows = await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, warningSeverityId, entityType, includeDismissed, includeIntentional);
return new WarningDashboardViewModel
{
Project = project,
BookID = bookId,
SceneID = sceneId,
WarningTypeID = warningTypeId,
WarningSeverityID = warningSeverityId,
EntityType = entityType,
IncludeDismissed = includeDismissed,
IncludeIntentional = includeIntentional,
Warnings = rows,
BookOptions = (await books.ListByProjectAsync(projectId)).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
WarningTypeOptions = ChapterService.ToSelectList(lookupData.WarningTypes, x => x.WarningTypeID, x => x.TypeName),
SeverityOptions = ChapterService.ToSelectList(lookupData.Severities, x => x.WarningSeverityID, x => x.SeverityName),
EntityTypeOptions = rows.Select(x => x.EntityType).Distinct().OrderBy(x => x).Select(x => new SelectListItem(x, x, x == entityType)).ToList(),
CountsBySeverity = rows.GroupBy(x => x.SeverityName).ToDictionary(x => x.Key, x => x.Count()),
CountsByType = rows.GroupBy(x => x.WarningTypeName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()),
LastValidationSummary = summary
};
}
public async Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId)
{
var summary = await warnings.ValidateProjectAsync(projectId);
await dynamics.RunContinuityChecksAsync(projectId, null, null);
await warnings.FinaliseSceneDependencyWarningsAsync(projectId, null);
return summary;
}
public async Task<ContinuityValidationSummary> ValidateBookAsync(int bookId)
{
var summary = await warnings.ValidateBookAsync(bookId);
var book = await books.GetAsync(bookId);
if (book is not null)
{
await dynamics.RunContinuityChecksAsync(book.ProjectID, bookId, null);
await warnings.FinaliseSceneDependencyWarningsAsync(book.ProjectID, bookId);
}
return summary;
}
public async Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId)
{
var summary = await warnings.ValidateSceneAsync(sceneId);
var scene = await scenes.GetAsync(sceneId);
if (scene is not null)
{
var chapter = await chapters.GetAsync(scene.ChapterID);
if (chapter is not null)
{
var book = await books.GetAsync(chapter.BookID);
if (book is not null)
{
await dynamics.RunContinuityChecksAsync(book.ProjectID, book.BookID, sceneId);
}
}
}
return summary;
}
public async Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId)
{
var summary = bookId.HasValue ? await warnings.ValidateBookAsync(bookId.Value) : await warnings.ValidateProjectAsync(projectId);
await dynamics.RunContinuityChecksAsync(projectId, bookId, null);
await warnings.FinaliseSceneDependencyWarningsAsync(projectId, bookId);
return summary;
}
public async Task<ContinuityValidationSummary> ValidateAssetAsync(int assetId)
{
var asset = await assets.GetAssetAsync(assetId);
return asset is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(asset.ProjectID);
}
public async Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
return character is null ? new ContinuityValidationSummary() : await ValidateProjectAsync(character.ProjectID);
}
public Task<IReadOnlyList<ContinuityWarning>> ListSceneWarningsAsync(int sceneId) => warnings.ListBySceneAsync(sceneId);
public Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId) => warnings.GetSceneCountsAsync(projectId, bookId);
public Task DismissAsync(int warningId) => warnings.DismissAsync(warningId);
public Task MarkIntentionalAsync(int warningId) => warnings.MarkIntentionalAsync(warningId);
public Task RestoreAsync(int warningId) => warnings.RestoreAsync(warningId);
}
public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMetricTypeRepository metricTypes) : ISceneMetricTypeService
{
public async Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return new ProjectSceneMetricsViewModel
{
Project = project,
Metrics = await metricTypes.ListForManagementAsync(projectId),
NewMetric = new SceneMetricTypeEditViewModel { ProjectID = projectId }
};
}
public async Task<SceneMetricTypeEditViewModel?> GetEditAsync(int projectId, int metricTypeId)
{
var metric = await metricTypes.GetAsync(metricTypeId, projectId);
return metric is null ? null : new SceneMetricTypeEditViewModel
{
MetricTypeID = metric.MetricTypeID,
ProjectID = projectId,
MetricName = metric.MetricName,
Description = metric.Description,
MinValue = metric.MinValue,
MaxValue = metric.MaxValue,
DefaultValue = metric.DefaultValue,
SortOrder = metric.SortOrder,
IsActive = metric.IsActive
};
}
public Task<int> SaveAsync(SceneMetricTypeEditViewModel model) => metricTypes.SaveAsync(new SceneMetricType
{
MetricTypeID = model.MetricTypeID,
ProjectID = model.ProjectID,
MetricName = model.MetricName,
Description = model.Description,
MinValue = model.MinValue,
MaxValue = model.MaxValue,
DefaultValue = model.DefaultValue,
SortOrder = model.SortOrder,
IsActive = model.IsActive
});
public Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive) => metricTypes.SetActiveAsync(metricTypeId, projectId, isActive);
public Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
public Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId);
}
public sealed class AnalyticsService(IProjectRepository projects, IBookRepository books, IAnalyticsRepository analytics) : IAnalyticsService
{
public async Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var data = await analytics.GetStoryHealthAsync(projectId, bookId, startChapterNumber, endChapterNumber, startSceneNumber, endSceneNumber);
return new AnalyticsViewModel
{
Project = project,
BookID = bookId,
StartChapterNumber = startChapterNumber,
EndChapterNumber = endChapterNumber,
StartSceneNumber = startSceneNumber,
EndSceneNumber = endSceneNumber,
BookOptions = (await books.ListByProjectAsync(projectId)).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
MetricGraphs = data.MetricPoints
.GroupBy(x => x.MetricName)
.Select(x => new AnalyticsMetricGraphViewModel { MetricName = x.Key, Points = x.OrderBy(p => p.GlobalSceneIndex).ToList() })
.ToList(),
RevisionSummary = data.RevisionSummary,
RevisionAttentionScenes = data.RevisionAttentionScenes,
PurposeSummary = data.PurposeSummary,
SceneGaps = data.SceneGaps,
ThreadHealth = data.ThreadHealth,
AssetHealth = data.AssetHealth,
WarningSeverityCounts = data.WarningSeverityCounts,
WarningTypeCounts = data.WarningTypeCounts,
WarningHotspots = data.WarningHotspots,
PovSummary = data.PovSummary,
MultiPovChapters = data.MultiPovChapters,
CharacterAppearances = data.CharacterAppearances,
BusyScenes = data.BusyScenes,
LocationUsage = data.LocationUsage,
PacingReviewNotes = BuildPacingNotes(data.MetricPoints)
};
}
private static IReadOnlyList<AnalyticsReviewNote> BuildPacingNotes(IReadOnlyList<AnalyticsMetricPoint> points)
{
var notes = new List<AnalyticsReviewNote>();
foreach (var group in points.GroupBy(x => x.MetricName))
{
AddRuns(notes, group.OrderBy(x => x.GlobalSceneIndex).ToList(), high: true);
if (group.Key is "Overall Intensity" or "Tension" or "Emotional Weight" or "Action")
{
AddRuns(notes, group.OrderBy(x => x.GlobalSceneIndex).ToList(), high: false);
}
}
return notes.Take(12).ToList();
}
private static void AddRuns(List<AnalyticsReviewNote> notes, IReadOnlyList<AnalyticsMetricPoint> points, bool high)
{
var threshold = high ? 8 : 2;
var minRun = high ? 6 : 8;
var run = new List<AnalyticsMetricPoint>();
foreach (var point in points)
{
var qualifies = high ? point.Value >= threshold : point.Value <= threshold;
if (qualifies)
{
run.Add(point);
continue;
}
FlushRun(notes, run, high, minRun);
run.Clear();
}
FlushRun(notes, run, high, minRun);
}
private static void FlushRun(List<AnalyticsReviewNote> notes, IReadOnlyList<AnalyticsMetricPoint> run, bool high, int minRun)
{
if (run.Count < minRun)
{
return;
}
notes.Add(new AnalyticsReviewNote
{
MetricName = run[0].MetricName,
SceneID = run[^1].SceneID,
SceneRange = $"Ch {run[0].ChapterNumber} Scene {run[0].SceneNumber} to Ch {run[^1].ChapterNumber} Scene {run[^1].SceneNumber}",
ValuePattern = string.Join(", ", run.Select(x => x.Value)),
ReviewNote = high ? "Possible pacing issue: this stays intense for a long run. May need a recovery scene." : "Worth reviewing: this stays very low for a long run and may feel flat."
});
}
}
public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRepository storyBible) : IStoryBibleService
{
public async Task<StoryBibleViewModel?> GetAsync(int projectId, string? query)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return new StoryBibleViewModel
{
Project = project,
Query = query,
Characters = await storyBible.GetCharacterSummariesAsync(projectId),
PlotThreads = await storyBible.GetThreadSummariesAsync(projectId),
Assets = await storyBible.GetAssetSummariesAsync(projectId),
Relationships = await storyBible.GetRelationshipSummariesAsync(projectId),
Locations = await storyBible.GetLocationSummariesAsync(projectId),
Timeline = await storyBible.GetTimelineSummaryAsync(projectId),
OpenQuestions = await storyBible.GetOpenQuestionsAsync(projectId),
RevisionItems = await storyBible.GetRevisionProgressAsync(projectId),
SearchResults = await storyBible.SearchAsync(projectId, query)
};
}
}
public sealed class WriterWorkspaceService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
IWriterWorkspaceRepository writerWorkspace) : IWriterWorkspaceService
{
public async Task<WriterDashboardViewModel> GetDashboardAsync(int? projectId)
{
var projectRows = await projects.ListAsync();
return new WriterDashboardViewModel
{
ProjectID = projectId,
ProjectOptions = projectRows.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == projectId)).ToList(),
ContinueWriting = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "ContinueWriting"),
NeedsAttention = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "NeedsAttention"),
RevisionQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "RevisionQueue"),
PolishingQueue = await writerWorkspace.GetWriterDashboardScenesAsync(projectId, "PolishingQueue"),
StoryHealthReminders = await writerWorkspace.GetWriterDashboardRemindersAsync(projectId)
};
}
public async Task<ChapterWorkflowViewModel?> GetChapterWorkflowAsync(int chapterId)
{
var chapter = await chapters.GetAsync(chapterId);
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
var project = book is null ? null : await projects.GetAsync(book.ProjectID);
if (chapter is null || book is null || project is null)
{
return null;
}
return new ChapterWorkflowViewModel
{
Project = project,
Book = book,
Chapter = chapter,
Goal = await writerWorkspace.GetChapterGoalAsync(chapterId),
Scenes = await writerWorkspace.GetChapterWorkflowAsync(chapterId)
};
}
public Task SaveChapterGoalAsync(ChapterGoalEditViewModel model) => writerWorkspace.SaveChapterGoalAsync(new ChapterGoal
{
ChapterID = model.ChapterID,
GoalSummary = model.GoalSummary,
EmotionalGoal = model.EmotionalGoal,
ReaderTakeaway = model.ReaderTakeaway,
MustInclude = model.MustInclude,
RisksOrConcerns = model.RisksOrConcerns
});
public Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model) => writerWorkspace.SaveWorkflowAsync(new SceneWorkflow
{
SceneID = model.SceneID,
DraftStatus = model.DraftStatus,
EstimatedWordCount = model.EstimatedWordCount,
ActualWordCount = model.ActualWordCount,
DraftedDate = model.DraftedDate,
LastWorkedOn = model.LastWorkedOn,
ReadyForDraft = model.ReadyForDraft,
ReadyForRevision = model.ReadyForRevision,
ReadyForPolish = model.ReadyForPolish,
IsBlocked = model.IsBlocked,
BlockedReason = model.BlockedReason,
Priority = model.Priority
});
public Task<int> SaveNoteAsync(SceneNoteEditViewModel model) => writerWorkspace.SaveNoteAsync(new SceneNote
{
SceneNoteID = model.SceneNoteID,
SceneID = model.SceneID,
SceneNoteTypeID = model.SceneNoteTypeID,
NoteTitle = model.NoteTitle,
NoteText = model.NoteText,
SortOrder = model.SortOrder,
IsPinned = model.IsPinned,
IsResolved = model.IsResolved
});
public Task DeleteNoteAsync(int sceneNoteId) => writerWorkspace.DeleteNoteAsync(sceneNoteId);
public Task<int> SaveChecklistItemAsync(SceneChecklistItemEditViewModel model) => writerWorkspace.SaveChecklistItemAsync(new SceneChecklistItem
{
SceneChecklistItemID = model.SceneChecklistItemID,
SceneID = model.SceneID,
ItemText = model.ItemText,
IsCompleted = model.IsCompleted,
SortOrder = model.SortOrder
});
public Task DeleteChecklistItemAsync(int sceneChecklistItemId) => writerWorkspace.DeleteChecklistItemAsync(sceneChecklistItemId);
public Task<int> SaveAttachmentAsync(SceneAttachmentEditViewModel model) => writerWorkspace.SaveAttachmentAsync(new SceneAttachment
{
SceneAttachmentID = model.SceneAttachmentID,
SceneID = model.SceneID,
SceneAttachmentTypeID = model.SceneAttachmentTypeID,
Title = model.Title,
FilePath = model.FilePath,
ExternalUrl = model.ExternalUrl,
Notes = model.Notes
});
public Task DeleteAttachmentAsync(int sceneAttachmentId) => writerWorkspace.DeleteAttachmentAsync(sceneAttachmentId);
}
public sealed class ExportService(IExportRepository exports) : IExportService
{
public async Task<(string FileName, string ContentType, string Content)> ExportStoryBibleAsync(int projectId, string format)
=> Render(await exports.GetStoryBibleAsync(projectId), "story-bible", format);
public async Task<(string FileName, string ContentType, string Content)> ExportNarrativeTimelineAsync(int projectId, string format)
=> Render(await exports.GetNarrativeTimelineAsync(projectId), "narrative-timeline", format);
public async Task<(string FileName, string ContentType, string Content)> ExportCharacterReferenceAsync(int characterId, string format)
=> Render(await exports.GetCharacterReferenceAsync(characterId), "character-reference", format);
public async Task<(string FileName, string ContentType, string Content)> ExportSceneBriefAsync(int sceneId, string format)
=> Render(await exports.GetSceneBriefAsync(sceneId), "scene-brief", format);
public async Task<(string FileName, string ContentType, string Content)> ExportChapterBriefAsync(int chapterId, string format)
=> Render(await exports.GetChapterBriefAsync(chapterId), "chapter-brief", format);
public async Task<(string FileName, string ContentType, string Content)> ExportWriterSessionBriefAsync(int sceneId, string format)
=> Render(await exports.GetWriterSessionBriefAsync(sceneId), "writing-session-brief", format);
public async Task<(string FileName, string ContentType, string Content)> ExportResearchPackAsync(int projectId, string format)
=> Render(await exports.GetResearchPackAsync(projectId), "research-pack", format);
public async Task<(string FileName, string ContentType, string Content)> ExportSearchResultsAsync(int projectId, string query, string format)
=> Render(await exports.GetSearchResultsAsync(projectId, query), "search-results", format);
private static (string FileName, string ContentType, string Content) Render(ExportPacket packet, string slug, string format)
{
var normalised = string.IsNullOrWhiteSpace(format) ? "html" : format.Trim().ToLowerInvariant();
return normalised switch
{
"markdown" or "md" => ($"{slug}.md", "text/markdown; charset=utf-8", RenderMarkdown(packet)),
"text" or "txt" => ($"{slug}.txt", "text/plain; charset=utf-8", RenderText(packet)),
_ => ($"{slug}.html", "text/html; charset=utf-8", RenderHtml(packet))
};
}
private static string RenderMarkdown(ExportPacket packet)
{
var builder = new StringBuilder();
builder.AppendLine($"# {packet.Title}").AppendLine();
AppendPacket(builder, packet, markdown: true);
return builder.ToString();
}
private static string RenderText(ExportPacket packet)
{
var builder = new StringBuilder();
builder.AppendLine(packet.Title).AppendLine(new string('=', packet.Title.Length)).AppendLine();
AppendPacket(builder, packet, markdown: false);
return builder.ToString();
}
private static string RenderHtml(ExportPacket packet)
{
var body = RenderMarkdown(packet)
.Split('\n')
.Select(line =>
{
var text = WebUtility.HtmlEncode(line.TrimEnd());
if (text.StartsWith("### ")) return $"<h3>{text[4..]}</h3>";
if (text.StartsWith("## ")) return $"<h2>{text[3..]}</h2>";
if (text.StartsWith("# ")) return $"<h1>{text[2..]}</h1>";
if (text.StartsWith("- ")) return $"<li>{text[2..]}</li>";
return string.IsNullOrWhiteSpace(text) ? "" : $"<p>{text}</p>";
});
return $$"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{{WebUtility.HtmlEncode(packet.Title)}}</title>
<style>
body { font-family: Georgia, 'Times New Roman', serif; line-height: 1.55; max-width: 920px; margin: 32px auto; padding: 0 24px; color: #24201a; }
h1, h2, h3 { font-family: Arial, sans-serif; line-height: 1.2; }
h1 { border-bottom: 2px solid #d8d0c2; padding-bottom: 12px; }
h2 { margin-top: 32px; border-bottom: 1px solid #e6dfd2; padding-bottom: 6px; }
li { margin: 5px 0; }
@media print { body { margin: 0; max-width: none; } a { color: inherit; } }
</style>
</head>
<body>
{{string.Join(Environment.NewLine, body)}}
</body>
</html>
""";
}
private static void AppendPacket(StringBuilder builder, ExportPacket packet, bool markdown)
{
if (packet.SceneHeader is not null) AppendSceneHeader(builder, packet.SceneHeader, markdown);
if (packet.ChapterHeader is not null) AppendChapterHeader(builder, packet.ChapterHeader, markdown);
if (packet.Character is not null) AppendCharacter(builder, packet);
AppendTimeline(builder, packet.Timeline, markdown);
AppendCharacters(builder, packet.Characters);
AppendThreads(builder, packet.PlotThreads);
AppendAssets(builder, packet.Assets);
AppendRelationships(builder, packet.Relationships);
AppendLocations(builder, packet.Locations);
AppendItems(builder, "Brief Details", packet.Items, markdown);
AppendResearch(builder, packet.ResearchItems, markdown);
AppendSearch(builder, packet.SearchResults, markdown);
AppendAttention(builder, packet.OpenQuestions);
AppendRevision(builder, packet.RevisionItems);
}
private static void Heading(StringBuilder builder, string text, bool markdown, int level = 2)
=> builder.AppendLine(markdown ? $"{new string('#', level)} {text}" : text).AppendLine(markdown ? "" : new string('-', text.Length));
private static void AppendSceneHeader(StringBuilder builder, ExportSceneBriefHeader scene, bool markdown)
{
Heading(builder, $"{scene.BookTitle} / Chapter {scene.ChapterNumber} / Scene {scene.SceneNumber}: {scene.SceneTitle}", markdown);
builder.AppendLine($"- POV: {scene.PovCharacterName ?? "Not set"}");
builder.AppendLine($"- Location: {scene.LocationPath ?? "Not set"}");
builder.AppendLine($"- Time: {scene.TimeLabel}");
builder.AppendLine($"- Revision: {scene.RevisionStatusName}");
builder.AppendLine($"- Draft status: {scene.DraftStatus}");
if (scene.Priority.HasValue) builder.AppendLine($"- Priority: {scene.Priority}");
if (scene.IsBlocked) builder.AppendLine($"- Blocked: {scene.BlockedReason}");
AppendBlock(builder, "Summary", scene.Summary, markdown);
AppendBlock(builder, "Purpose", scene.ScenePurposeNotes, markdown);
AppendBlock(builder, "Outcome", scene.SceneOutcomeNotes, markdown);
}
private static void AppendChapterHeader(StringBuilder builder, ExportChapterBriefHeader chapter, bool markdown)
{
Heading(builder, $"{chapter.BookTitle} / Chapter {chapter.ChapterNumber}: {chapter.ChapterTitle}", markdown);
builder.AppendLine($"- Revision: {chapter.RevisionStatusName}");
AppendBlock(builder, "Summary", chapter.Summary, markdown);
AppendBlock(builder, "Goal", chapter.GoalSummary, markdown);
AppendBlock(builder, "Emotion", chapter.EmotionalGoal, markdown);
AppendBlock(builder, "Reader Takeaway", chapter.ReaderTakeaway, markdown);
AppendBlock(builder, "Must Include", chapter.MustInclude, markdown);
AppendBlock(builder, "Risks", chapter.RisksOrConcerns, markdown);
}
private static void AppendBlock(StringBuilder builder, string title, string? text, bool markdown)
{
if (string.IsNullOrWhiteSpace(text)) return;
builder.AppendLine(markdown ? $"### {title}" : title).AppendLine(text.Trim()).AppendLine();
}
private static void AppendTimeline(StringBuilder builder, IReadOnlyList<StoryBibleTimelineSummary> rows, bool markdown)
{
if (!rows.Any()) return;
Heading(builder, "Timeline Summary", markdown);
foreach (var book in rows.GroupBy(x => new { x.BookID, x.BookTitle }))
{
builder.AppendLine(markdown ? $"### {book.Key.BookTitle}" : book.Key.BookTitle);
foreach (var scene in book)
{
builder.AppendLine($"- Chapter {scene.ChapterNumber}, Scene {scene.SceneNumber}: {scene.SceneTitle}");
builder.AppendLine($" POV: {scene.PovCharacterName ?? "Not set"}; Location: {scene.LocationPath ?? "Not set"}; Status: {scene.RevisionStatusName}");
if (!string.IsNullOrWhiteSpace(scene.Summary)) builder.AppendLine($" {scene.Summary}");
}
builder.AppendLine();
}
}
private static void AppendCharacters(StringBuilder builder, IReadOnlyList<StoryBibleCharacterSummary> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Characters").AppendLine();
foreach (var c in rows) builder.AppendLine($"- {c.CharacterName}: {c.SceneCount} scenes; first {c.FirstSceneLabel ?? "not placed"}; last {c.LastSceneLabel ?? "not placed"}");
builder.AppendLine();
}
private static void AppendThreads(StringBuilder builder, IReadOnlyList<StoryBibleThreadSummary> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Plot Threads").AppendLine();
foreach (var t in rows) builder.AppendLine($"- {t.ThreadTitle} ({t.ThreadStatusName}, importance {t.Importance}): {t.EventHistory ?? "No events"}");
builder.AppendLine();
}
private static void AppendAssets(StringBuilder builder, IReadOnlyList<StoryBibleAssetSummary> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Story Assets").AppendLine();
foreach (var a in rows) builder.AppendLine($"- {a.AssetName} ({a.KindName}, {a.CurrentStateName ?? "No state"}): {a.EventHistory ?? "No events"}");
builder.AppendLine();
}
private static void AppendRelationships(StringBuilder builder, IReadOnlyList<StoryBibleRelationshipSummary> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Relationships").AppendLine();
foreach (var r in rows) builder.AppendLine($"- {r.CharacterAName} / {r.CharacterBName}: {r.RelationshipTypeName}; {r.CurrentStateName ?? "No state"}");
builder.AppendLine();
}
private static void AppendLocations(StringBuilder builder, IReadOnlyList<StoryBibleLocationSummary> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Locations").AppendLine();
foreach (var l in rows) builder.AppendLine($"- {l.LocationPath}: {l.SceneCount} scenes, {l.CharacterCount} characters, {l.AssetCount} assets");
builder.AppendLine();
}
private static void AppendCharacter(StringBuilder builder, ExportPacket packet)
{
var c = packet.Character!;
builder.AppendLine($"## {c.CharacterName}").AppendLine();
builder.AppendLine($"- Short name: {c.ShortName}");
builder.AppendLine($"- Sex: {c.Sex}");
builder.AppendLine($"- Age at series start: {c.AgeAtSeriesStart}");
builder.AppendLine($"- Height: {c.Height}");
builder.AppendLine($"- Eye colour: {c.EyeColour}");
if (!string.IsNullOrWhiteSpace(c.DefaultDescription)) builder.AppendLine().AppendLine(c.DefaultDescription).AppendLine();
foreach (var app in packet.CharacterAppearances) builder.AppendLine($"- Appearance: {app.BookTitle} / Ch {app.ChapterNumber} / Scene {app.SceneNumber}: {app.SceneTitle} ({app.RoleInSceneTypeName})");
foreach (var rel in packet.CharacterRelationships) builder.AppendLine($"- Relationship: {rel.CharacterAName} / {rel.CharacterBName}: {rel.RelationshipTypeName}");
foreach (var item in packet.CharacterKnowledge) builder.AppendLine($"- Knowledge: {item.KnowledgeStateName} - {item.AssetName ?? item.ThreadTitle}: {item.Description}");
builder.AppendLine();
}
private static void AppendItems(StringBuilder builder, string title, IReadOnlyList<ExportBriefItem> rows, bool markdown)
{
if (!rows.Any()) return;
Heading(builder, title, markdown);
foreach (var group in rows.GroupBy(x => x.ItemType))
{
builder.AppendLine(markdown ? $"### {group.Key}" : group.Key);
foreach (var item in group) builder.AppendLine($"- {item.Title}{(string.IsNullOrWhiteSpace(item.Status) ? "" : $" [{item.Status}]")}{(string.IsNullOrWhiteSpace(item.Detail) ? "" : $": {item.Detail}")}");
builder.AppendLine();
}
}
private static void AppendResearch(StringBuilder builder, IReadOnlyList<ExportResearchItem> rows, bool markdown)
{
if (!rows.Any()) return;
Heading(builder, "Research Pack", markdown);
foreach (var item in rows)
{
builder.AppendLine($"- {item.BookTitle} / Ch {item.ChapterNumber} / Scene {item.SceneNumber}: {item.Topic} - {item.Title}");
if (!string.IsNullOrWhiteSpace(item.Detail)) builder.AppendLine($" {item.Detail}");
if (!string.IsNullOrWhiteSpace(item.Reference)) builder.AppendLine($" {item.Reference}");
}
builder.AppendLine();
}
private static void AppendSearch(StringBuilder builder, IReadOnlyList<StoryBibleSearchResult> rows, bool markdown)
{
if (!rows.Any()) return;
Heading(builder, "Search Results", markdown);
foreach (var group in rows.GroupBy(x => x.ResultType))
{
builder.AppendLine(markdown ? $"### {group.Key}" : group.Key);
foreach (var result in group) builder.AppendLine($"- {result.Title}{(string.IsNullOrWhiteSpace(result.Detail) ? "" : $": {result.Detail}")}");
}
}
private static void AppendAttention(StringBuilder builder, IReadOnlyList<StoryBibleAttentionItem> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Open Questions and Warnings").AppendLine();
foreach (var item in rows) builder.AppendLine($"- {item.Category}: {item.Title}{(string.IsNullOrWhiteSpace(item.Detail) ? "" : $" - {item.Detail}")}");
builder.AppendLine();
}
private static void AppendRevision(StringBuilder builder, IReadOnlyList<StoryBibleRevisionItem> rows)
{
if (!rows.Any()) return;
builder.AppendLine("## Revision Status").AppendLine();
foreach (var item in rows) builder.AppendLine($"- {item.GroupName}: {item.ItemCount} {item.Detail}");
builder.AppendLine();
}
}
public sealed class DynamicsService(IProjectRepository projects, IDynamicsRepository dynamics) : IDynamicsService
{
public async Task<CharacterArcViewModel?> GetCharacterArcAsync(int characterId)
{
var data = await dynamics.GetCharacterArcAsync(characterId);
if (data.Character is null)
{
return null;
}
var project = await projects.GetAsync(data.Character.ProjectID);
return project is null ? null : new CharacterArcViewModel
{
Project = project,
Character = data.Character,
ArcPoints = data.ArcPoints,
RelationshipEvents = data.RelationshipEvents,
KnowledgeEvents = data.KnowledgeEvents,
Issues = data.Issues
};
}
public async Task<RelationshipEvolutionViewModel?> GetRelationshipEvolutionAsync(int characterRelationshipId)
{
var data = await dynamics.GetRelationshipEvolutionAsync(characterRelationshipId);
if (data.Relationship is null)
{
return null;
}
var project = await projects.GetAsync(data.Relationship.ProjectID);
return project is null ? null : new RelationshipEvolutionViewModel
{
Project = project,
Relationship = data.Relationship,
Events = data.Events,
Issues = data.Issues
};
}
public async Task<KnowledgeTimelineViewModel?> GetKnowledgeTimelineAsync(int characterId)
{
var data = await dynamics.GetKnowledgeTimelineAsync(characterId);
if (data.Character is null)
{
return null;
}
var project = await projects.GetAsync(data.Character.ProjectID);
return project is null ? null : new KnowledgeTimelineViewModel
{
Project = project,
Character = data.Character,
KnowledgeEvents = data.KnowledgeEvents,
Issues = data.Issues
};
}
public async Task<StoryDynamicsViewModel?> GetStoryDynamicsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var data = await dynamics.GetStoryDynamicsAsync(projectId);
return new StoryDynamicsViewModel
{
Project = project,
Pairings = data.Pairings,
Issues = data.Issues
};
}
public Task RunContinuityChecksAsync(int projectId, int? bookId, int? sceneId) =>
dynamics.RunContinuityChecksAsync(projectId, bookId, sceneId);
}
public sealed class ScenarioService(IProjectRepository projects, IBookRepository books, IScenarioRepository scenarios, IContinuityValidationService validation) : IScenarioService
{
public async Task<ScenarioListViewModel?> ListAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new ScenarioListViewModel
{
Project = project,
Scenarios = await scenarios.ListAsync(projectId)
};
}
public async Task<ScenarioCreateViewModel?> GetCreateAsync(int projectId, int? bookId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return new ScenarioCreateViewModel
{
ProjectID = projectId,
BookID = bookId,
BookOptions = (await books.ListByProjectAsync(projectId))
.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId))
.ToList()
};
}
public Task<int> CreateAsync(ScenarioCreateViewModel model) =>
scenarios.CreateAsync(model.ProjectID, model.BookID, model.ScenarioName, model.Description);
public async Task<ScenarioTimelineViewModel?> GetTimelineAsync(int scenarioId)
{
var scenario = await scenarios.GetAsync(scenarioId);
if (scenario is null)
{
return null;
}
var project = await projects.GetAsync(scenario.ProjectID);
if (project is null)
{
return null;
}
var data = await scenarios.GetTimelineAsync(scenarioId);
return new ScenarioTimelineViewModel
{
Project = project,
Scenario = scenario,
Books = data.Books,
Chapters = data.Chapters,
Scenes = data.Scenes,
Warnings = data.Warnings,
ChapterOptions = data.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString())).ToList()
};
}
public Task MoveSceneAsync(int scenarioId, int sceneId, int? anchorSceneId, int? targetChapterId, string position) =>
scenarios.MoveSceneAsync(scenarioId, sceneId, anchorSceneId, targetChapterId, position);
public Task<int> ValidateAsync(int scenarioId) => scenarios.ValidateAsync(scenarioId);
public async Task<ScenarioCompareViewModel?> CompareAsync(int scenarioId)
{
var scenario = await scenarios.GetAsync(scenarioId);
if (scenario is null)
{
return null;
}
var project = await projects.GetAsync(scenario.ProjectID);
if (project is null)
{
return null;
}
var data = await scenarios.CompareAsync(scenarioId);
return new ScenarioCompareViewModel
{
Project = project,
Scenario = scenario,
MovedScenes = data.MovedScenes,
ScenarioWarnings = data.Warnings
};
}
public async Task ApplyAsync(int scenarioId)
{
var scenario = await scenarios.GetAsync(scenarioId);
await scenarios.ApplyAsync(scenarioId);
if (scenario is not null)
{
await validation.ValidateProjectAsync(scenario.ProjectID);
}
}
public Task ArchiveAsync(int scenarioId) => scenarios.ArchiveAsync(scenarioId);
}
public sealed class ArchiveService(IArchiveRepository archives) : 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 = await archives.ListProjectFiltersAsync();
return new ArchivedItemsViewModel
{
ProjectID = projectId,
EntityType = normalisedType,
ProjectOptions = projects.Select(project => new SelectListItem(
project.IsArchived ? $"{project.ProjectName} (Archived)" : project.ProjectName,
project.ProjectID.ToString(),
project.ProjectID == projectId)).ToList(),
EntityTypeOptions = EntityTypes.Select(type => new SelectListItem(type.Label, type.Value, type.Value == normalisedType)).ToList(),
Items = await archives.ListAsync(projectId, normalisedType)
};
}
public Task ArchiveAsync(string entityType, int entityId, string? reason) =>
archives.ArchiveAsync(entityType, entityId, reason);
public Task RestoreAsync(string entityType, int entityId) =>
archives.RestoreAsync(entityType, entityId);
public Task<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText) =>
archives.DeleteForeverAsync(entityType, entityId, confirmationText);
}
public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance) : IAcceptanceService
{
public async Task<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId)
{
var projectRows = await projects.ListAsync();
var selectedProject = projectId.HasValue
? projectRows.FirstOrDefault(x => x.ProjectID == projectId.Value)
: projectRows.FirstOrDefault();
var effectiveProjectId = projectId ?? selectedProject?.ProjectID;
return new Phase1AcceptanceViewModel
{
ProjectID = effectiveProjectId,
ProjectName = selectedProject?.ProjectName,
ProjectOptions = projectRows.Select(project => new SelectListItem(
project.ProjectName,
project.ProjectID.ToString(),
project.ProjectID == effectiveProjectId)).ToList(),
Items = await acceptance.GetPhase1ChecklistAsync(effectiveProjectId)
};
}
}
public sealed class AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService
{
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
var lookupData = await assets.GetLookupsAsync();
return project is null ? null : new StoryAssetListViewModel
{
Project = project,
Assets = await assets.ListAssetsAsync(projectId),
AssetDependencyTypeOptions = ChapterService.ToSelectList(lookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName),
AssetStateOptions = OptionalStateList(lookupData.AssetStates)
};
}
public async Task<StoryAssetEditViewModel?> GetCreateAssetAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
var lookupData = await assets.GetLookupsAsync();
return await PopulateAssetListsAsync(new StoryAssetEditViewModel
{
ProjectID = projectId,
Project = project,
Importance = 5,
AssetKindID = lookupData.AssetKinds.FirstOrDefault(x => x.KindName == "Physical Object")?.AssetKindID
?? lookupData.AssetKinds.First().AssetKindID,
CurrentStateID = lookupData.AssetStates.FirstOrDefault(x => x.StateName == "Unknown")?.AssetStateID
}, lookupData);
}
public async Task<StoryAssetEditViewModel?> GetEditAssetAsync(int storyAssetId)
{
var asset = await assets.GetAssetAsync(storyAssetId);
if (asset is null)
{
return null;
}
var project = await projects.GetAsync(asset.ProjectID);
if (project is null)
{
return null;
}
var lookupData = await assets.GetLookupsAsync();
return await PopulateAssetListsAsync(new StoryAssetEditViewModel
{
StoryAssetID = asset.StoryAssetID,
ProjectID = asset.ProjectID,
AssetName = asset.AssetName,
AssetKindID = asset.AssetKindID,
Description = asset.Description,
Importance = asset.Importance,
CurrentStateID = asset.CurrentStateID,
CurrentLocationID = asset.CurrentLocationID,
IsResolved = asset.IsResolved,
ShowInQuickAddBar = asset.ShowInQuickAddBar,
Project = project
}, lookupData);
}
public async Task<StoryAssetDetailViewModel?> GetAssetDetailAsync(int storyAssetId)
{
var asset = await assets.GetAssetAsync(storyAssetId);
if (asset is null)
{
return null;
}
var project = await projects.GetAsync(asset.ProjectID);
if (project is null)
{
return null;
}
var lookupData = await assets.GetLookupsAsync();
var projectAssets = await assets.ListAssetsAsync(asset.ProjectID);
return new StoryAssetDetailViewModel
{
Project = project,
Asset = asset,
ProjectAssets = projectAssets,
Events = await assets.ListEventsByAssetAsync(storyAssetId),
Dependencies = await assets.ListDependenciesByAssetAsync(storyAssetId),
CustodyEvents = await assets.ListCustodyByAssetAsync(storyAssetId),
NewDependency = new AssetDependencyEditViewModel
{
ProjectID = asset.ProjectID,
SourceAssetID = projectAssets.FirstOrDefault(x => x.StoryAssetID != asset.StoryAssetID)?.StoryAssetID ?? asset.StoryAssetID,
TargetAssetID = asset.StoryAssetID,
AssetDependencyTypeID = lookupData.AssetDependencyTypes.FirstOrDefault(x => x.TypeName == "Requires")?.AssetDependencyTypeID
?? lookupData.AssetDependencyTypes.First().AssetDependencyTypeID
},
AssetOptions = ChapterService.ToSelectList(projectAssets, x => x.StoryAssetID, x => x.AssetName),
AssetDependencyTypeOptions = ChapterService.ToSelectList(lookupData.AssetDependencyTypes, x => x.AssetDependencyTypeID, x => x.TypeName),
AssetStateOptions = OptionalStateList(lookupData.AssetStates)
};
}
public Task<int> SaveAssetAsync(StoryAssetEditViewModel model) => assets.SaveAssetAsync(new StoryAsset
{
StoryAssetID = model.StoryAssetID,
ProjectID = model.ProjectID,
AssetName = model.AssetName,
AssetKindID = model.AssetKindID,
Description = model.Description,
Importance = model.Importance,
CurrentStateID = model.CurrentStateID,
CurrentLocationID = model.CurrentLocationID,
IsResolved = model.IsResolved,
ShowInQuickAddBar = model.ShowInQuickAddBar
});
public Task ArchiveAssetAsync(int storyAssetId) => assets.ArchiveAssetAsync(storyAssetId);
public async Task<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
});
}
var eventType = lookupData.AssetEventTypes.FirstOrDefault(x => x.AssetEventTypeID == model.AssetEventTypeID)
?? lookupData.AssetEventTypes.First();
var title = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim();
return await assets.SaveEventAsync(new AssetEvent
{
StoryAssetID = assetId,
SceneID = model.SceneID,
AssetEventTypeID = eventType.AssetEventTypeID,
FromStateID = model.FromStateID,
ToStateID = model.ToStateID,
EventTitle = title,
EventDescription = model.EventDescription
});
}
public Task DeleteAssetEventAsync(int assetEventId) => assets.DeleteEventAsync(assetEventId);
public Task<int> AddCustodyEventAsync(AssetCustodyCreateViewModel model) => assets.SaveCustodyEventAsync(
new AssetCustodyEvent
{
StoryAssetID = model.StoryAssetID,
SceneID = model.SceneID,
AssetCustodyEventTypeID = model.AssetCustodyEventTypeID,
Description = model.Description
},
model.CustodianNames,
model.CustodianCharacterIds,
model.CustodyRoleID);
public Task DeleteCustodyEventAsync(int assetCustodyEventId) => assets.DeleteCustodyEventAsync(assetCustodyEventId);
public Task<int> SaveDependencyAsync(AssetDependencyEditViewModel model) => assets.SaveDependencyAsync(new AssetDependency
{
AssetDependencyID = model.AssetDependencyID,
SourceAssetID = model.SourceAssetID,
TargetAssetID = model.TargetAssetID,
AssetDependencyTypeID = model.AssetDependencyTypeID,
SourceRequiredStateID = model.SourceRequiredStateID,
TargetBlockedStateID = model.TargetBlockedStateID,
Description = model.Description
});
public Task DeleteDependencyAsync(int assetDependencyId) => assets.DeleteDependencyAsync(assetDependencyId);
public Task<IReadOnlyList<AssetDependencyIssue>> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId)
=> assets.CheckDependencyIssuesAsync(storyAssetId, sceneId, toStateId);
private async Task<StoryAssetEditViewModel> PopulateAssetListsAsync(StoryAssetEditViewModel model, AssetLookupData lookupData)
{
model.AssetKindOptions = ChapterService.ToSelectList(lookupData.AssetKinds, x => x.AssetKindID, x => x.KindName);
model.AssetStateOptions = OptionalStateList(lookupData.AssetStates);
model.LocationOptions = ToOptionalLocationList(await locations.ListByProjectAsync(model.ProjectID));
return model;
}
private static IReadOnlyList<SelectListItem> OptionalStateList(IEnumerable<AssetState> states)
=> states.Select(x => new SelectListItem(x.StateName, x.AssetStateID.ToString())).ToList();
private static IReadOnlyList<SelectListItem> ToOptionalLocationList(IEnumerable<LocationItem> items)
=> items.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())).ToList();
}
public sealed class LocationService(IProjectRepository projects, ILocationRepository locations) : ILocationService
{
public async Task<LocationListViewModel?> GetLocationsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
var lookupData = await locations.GetLookupsAsync();
return project is null ? null : new LocationListViewModel
{
Project = project,
Locations = await locations.ListByProjectAsync(projectId),
RelationshipTypeOptions = ChapterService.ToSelectList(lookupData.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName)
};
}
public async Task<LocationEditViewModel?> GetCreateAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
if (project is null)
{
return null;
}
return await PopulateLocationListsAsync(new LocationEditViewModel { ProjectID = projectId, Project = project });
}
public async Task<LocationEditViewModel?> GetEditAsync(int locationId)
{
var location = await locations.GetAsync(locationId);
if (location is null)
{
return null;
}
return await PopulateLocationListsAsync(new LocationEditViewModel
{
LocationID = location.LocationID,
ProjectID = location.ProjectID,
ParentLocationID = location.ParentLocationID,
LocationName = location.LocationName,
LocationTypeID = location.LocationTypeID,
Description = location.Description,
ShowInQuickAddBar = location.ShowInQuickAddBar,
Project = await projects.GetAsync(location.ProjectID)
});
}
public async Task<LocationDetailViewModel?> GetDetailAsync(int locationId)
{
var location = await locations.GetAsync(locationId);
if (location is null)
{
return null;
}
var project = await projects.GetAsync(location.ProjectID);
if (project is null)
{
return null;
}
var allLocations = await locations.ListByProjectAsync(project.ProjectID);
var lookups = await locations.GetLookupsAsync();
return new LocationDetailViewModel
{
Project = project,
Location = location,
ChildLocations = allLocations.Where(x => x.ParentLocationID == location.LocationID).ToList(),
Relationships = await locations.ListRelationshipsByLocationAsync(location.LocationID),
Scenes = await locations.ListScenesByLocationAsync(location.LocationID),
Characters = await locations.ListCharactersByLocationAsync(location.LocationID),
Assets = await locations.ListAssetsByLocationAsync(location.LocationID),
NewRelationship = new LocationRelationshipEditViewModel
{
ProjectID = project.ProjectID,
FromLocationID = location.LocationID,
LocationRelationshipTypeID = lookups.RelationshipTypes.FirstOrDefault()?.LocationRelationshipTypeID ?? 0
},
LocationOptions = allLocations.Where(x => x.LocationID != location.LocationID)
.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString()))
.ToList(),
RelationshipTypeOptions = ChapterService.ToSelectList(lookups.RelationshipTypes, x => x.LocationRelationshipTypeID, x => x.TypeName)
};
}
public Task<int> SaveAsync(LocationEditViewModel model) => locations.SaveAsync(new LocationItem
{
LocationID = model.LocationID,
ProjectID = model.ProjectID,
ParentLocationID = model.ParentLocationID == model.LocationID ? null : model.ParentLocationID,
LocationName = model.LocationName,
LocationTypeID = model.LocationTypeID,
Description = model.Description,
ShowInQuickAddBar = model.ShowInQuickAddBar
});
public Task ArchiveAsync(int locationId) => locations.ArchiveAsync(locationId);
public Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model) => locations.SaveRelationshipAsync(new LocationRelationship
{
LocationRelationshipID = model.LocationRelationshipID,
FromLocationID = model.FromLocationID,
ToLocationID = model.ToLocationID,
LocationRelationshipTypeID = model.LocationRelationshipTypeID,
IsBidirectional = model.IsBidirectional,
Strength = model.Strength,
CanHearNormalSpeech = model.CanHearNormalSpeech,
CanHearLoudNoise = model.CanHearLoudNoise,
CanSeeMovement = model.CanSeeMovement,
CanSeeClearly = model.CanSeeClearly,
CanTravelDirectly = model.CanTravelDirectly,
Notes = model.Notes
});
public Task ArchiveRelationshipAsync(int locationRelationshipId) => locations.ArchiveRelationshipAsync(locationRelationshipId);
public Task<int> SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model) => locations.SaveSceneAssetLocationAsync(new SceneAssetLocation
{
SceneAssetLocationID = model.SceneAssetLocationID,
SceneID = model.SceneID,
StoryAssetID = model.StoryAssetID,
LocationID = model.LocationID,
Description = model.Description
}, model.UpdateCurrentLocation);
public Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId) => locations.DeleteSceneAssetLocationAsync(sceneAssetLocationId);
private async Task<LocationEditViewModel> PopulateLocationListsAsync(LocationEditViewModel model)
{
var lookups = await locations.GetLookupsAsync();
var allLocations = await locations.ListByProjectAsync(model.ProjectID);
model.ParentLocationOptions = allLocations
.Where(x => x.LocationID != model.LocationID)
.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString()))
.ToList();
model.LocationTypeOptions = ChapterService.ToSelectList(lookups.LocationTypes, x => x.LocationTypeID, x => x.TypeName);
return model;
}
}
public sealed class CharacterService(IProjectRepository projects, ICharacterRepository characters) : ICharacterService
{
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new CharacterListViewModel
{
Project = project,
Characters = await characters.ListCharactersAsync(projectId)
};
}
public async Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new CharacterEditViewModel { ProjectID = projectId, Project = project };
}
public async Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
if (character is null)
{
return null;
}
return new CharacterEditViewModel
{
CharacterID = character.CharacterID,
ProjectID = character.ProjectID,
CharacterName = character.CharacterName,
ShortName = character.ShortName,
Sex = character.Sex,
BirthDate = character.BirthDate,
AgeAtSeriesStart = character.AgeAtSeriesStart,
Height = character.Height,
EyeColour = character.EyeColour,
CharacterImportance = character.CharacterImportance,
ShowInQuickAddBar = character.ShowInQuickAddBar,
DefaultDescription = character.DefaultDescription,
Project = await projects.GetAsync(character.ProjectID)
};
}
public async Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
if (character is null)
{
return null;
}
var project = await projects.GetAsync(character.ProjectID);
if (project is null)
{
return null;
}
return new CharacterDetailViewModel
{
Project = project,
Character = character,
DisplayAge = DisplayAge(character),
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
Relationships = await characters.ListRelationshipsByCharacterAsync(character.CharacterID),
RelationshipEvents = await characters.ListRelationshipEventsByCharacterAsync(character.CharacterID)
};
}
public Task<int> SaveCharacterAsync(CharacterEditViewModel model) => characters.SaveCharacterAsync(new Character
{
CharacterID = model.CharacterID,
ProjectID = model.ProjectID,
CharacterName = model.CharacterName,
ShortName = model.ShortName,
Sex = model.Sex,
BirthDate = model.BirthDate,
AgeAtSeriesStart = model.AgeAtSeriesStart,
Height = model.Height,
EyeColour = model.EyeColour,
CharacterImportance = model.CharacterImportance,
ShowInQuickAddBar = model.ShowInQuickAddBar,
DefaultDescription = model.DefaultDescription
});
public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId);
public Task ArchiveRelationshipAsync(int characterRelationshipId) => characters.ArchiveRelationshipAsync(characterRelationshipId);
public async Task<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()
});
}
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
});
}
return await characters.SaveRelationshipEventAsync(new RelationshipEvent
{
CharacterRelationshipID = relationshipId,
SceneID = model.SceneID,
RelationshipStateID = model.RelationshipStateID,
Intensity = model.Intensity,
IsKnownToOtherCharacter = model.IsKnownToOtherCharacter,
Description = model.Description
});
}
public Task DeleteRelationshipEventAsync(int relationshipEventId) => characters.DeleteRelationshipEventAsync(relationshipEventId);
public string DisplayAge(Character character, DateTime? sceneStartDateTime = null)
{
if (character.BirthDate.HasValue && sceneStartDateTime.HasValue)
{
var age = sceneStartDateTime.Value.Year - character.BirthDate.Value.Year;
if (sceneStartDateTime.Value.Date < character.BirthDate.Value.Date.AddYears(age))
{
age--;
}
return age.ToString();
}
return character.AgeAtSeriesStart.HasValue
? $"About {character.AgeAtSeriesStart.Value}"
: "Unknown";
}
private async Task<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();
}
}