PlotDirector/PlotLine/Services/CoreServices.cs
Nick Beckley 480b557f81 Phase 1S
2026-06-01 14:49:23 +01:00

2958 lines
135 KiB
C#

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?> GetEditAsync(int projectId);
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<int> AddSceneCharacterAsync(SceneCharacterCreateViewModel model);
Task<int> UpdateSceneCharacterAsync(SceneCharacterEditViewModel model);
Task DeleteSceneCharacterAsync(int sceneCharacterId);
Task<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model);
Task<int> AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model);
Task<int> AddRelationshipEventAsync(RelationshipEventCreateViewModel model);
string DisplayAge(Character character, DateTime? sceneStartDateTime = null);
}
public interface ITimelineService
{
Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter);
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 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 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?> GetEditAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new ProjectEditViewModel
{
ProjectID = project.ProjectID,
ProjectName = project.ProjectName,
Description = project.Description
};
}
public Task<int> SaveAsync(ProjectEditViewModel model) => projects.SaveAsync(new Project
{
ProjectID = model.ProjectID,
ProjectName = model.ProjectName,
Description = model.Description
});
public Task ArchiveAsync(int projectId) => projects.ArchiveAsync(projectId);
}
public sealed class BookService(IProjectRepository projects, IBookRepository books, IChapterRepository chapters) : IBookService
{
public async Task<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,
IBookRepository books,
ISceneService scenes,
IAssetRepository assets,
ICharacterRepository characters,
ILocationRepository locations,
IPlotRepository plots,
ILookupRepository lookups,
IWarningRepository warnings,
ISceneDependencyRepository sceneDependencies) : ITimelineService
{
private static readonly string[] DefaultGraphMetrics = ["Overall Intensity", "Tension", "Emotional Weight", "Action"];
public async Task<TimelineViewModel?> GetAsync(TimelineFilterViewModel filter)
{
var projectId = filter.ProjectID;
var bookId = filter.BookID;
var selectedSceneId = filter.SelectedSceneID;
var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId);
if (timeline.Project is null)
{
return null;
}
var allBooks = await books.ListByProjectAsync(projectId);
var lookupData = await lookups.GetAllAsync();
var assetLookupData = await assets.GetLookupsAsync();
var characterLookupData = await characters.GetLookupsAsync();
var locationLookupData = await locations.GetLookupsAsync();
var plotLines = await plots.ListPlotLinesAsync(projectId);
var plotThreads = await plots.ListPlotThreadsByProjectAsync(projectId);
var allAssets = await assets.ListAssetsAsync(projectId);
var allCharacters = await characters.ListCharactersAsync(projectId);
var allLocations = await locations.ListByProjectAsync(projectId);
var warningLookups = await warnings.GetLookupsAsync();
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 = (await warnings.GetSceneCountsAsync(projectId, bookId)).ToDictionary(x => x.SceneID, x => x.WarningCount);
var dependencyCounts = (await sceneDependencies.GetCountsAsync(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);
}
var assetTimeline = await assets.GetTimelineAsync(projectId, bookId);
var characterTimeline = await characters.GetTimelineAsync(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 BuildMatchingSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId);
var focusSceneIds = await BuildFocusSceneIdsAsync(filter, orderedScenes, timeline.ThreadEvents, assetTimeline.Events, characterTimeline.Appearances, projectId, bookId);
var focusLabel = BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations);
var selectedScene = selectedSceneId.HasValue ? await scenes.GetEditAsync(selectedSceneId.Value) : 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,
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(),
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(),
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(),
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 presets.ListAsync(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 = BuildMetricGraphs(timeline, orderedScenes),
PlotLanes = BuildPlotLanes(timeline, orderedScenes),
AssetLanes = BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes),
CharacterLanes = 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<ContinuityValidationSummary?> MoveSceneByDropAsync(TimelineSceneDropMoveViewModel model)
{
if (model.SceneID == model.AnchorSceneID)
{
return null;
}
await scenes.MoveRelativeAsync(model.SceneID, model.AnchorSceneID, model.Position == "Before" ? "Before" : "After");
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 locations.ListScenesByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
var characterLocationScenes = (await locations.ListCharactersByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
var assetLocationScenes = (await locations.ListAssetsByLocationAsync(filter.LocationID.Value)).Select(x => x.SceneID);
Intersect(locationScenes.Concat(characterLocationScenes).Concat(assetLocationScenes));
}
if (filter.WarningSeverityID.HasValue || filter.WarningTypeID.HasValue || filter.ShowWarningsOnly)
{
var warningScenes = (await warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings))
.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 locations.ListScenesByLocationAsync(filter.FocusID.Value)).Select(x => x.SceneID)
.Concat((await locations.ListCharactersByLocationAsync(filter.FocusID.Value)).Select(x => x.SceneID))
.Concat((await locations.ListAssetsByLocationAsync(filter.FocusID.Value)).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
.Where(metric => DefaultGraphMetrics.Contains(metric.MetricName))
.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
.OrderBy(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();
}
}
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 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 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,
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
});
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,
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
});
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,
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,
DefaultDescription = model.DefaultDescription
});
public Task ArchiveCharacterAsync(int characterId) => characters.ArchiveCharacterAsync(characterId);
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 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 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();
}
}