PlotDirector/PlotLine/Services/CoreServices.cs
2026-06-01 09:03:59 +01:00

1904 lines
83 KiB
C#

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<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 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(int projectId, int? bookId, int? selectedSceneId);
}
public interface IAnalyticsService
{
Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
}
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) : 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);
}
}
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);
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.MoveRequest = new SceneMoveRequestViewModel
{
SceneID = model.SceneID,
TargetChapterID = model.ChapterID,
ReturnProjectID = model.ReturnProjectID,
ReturnBookID = model.ReturnBookID
};
model.LocationConsistencyMessages = BuildLocationConsistencyMessages(model);
return model;
}
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, IBookRepository books, ISceneService scenes, IAssetRepository assets, ICharacterRepository characters, IWarningRepository warnings, ISceneDependencyRepository sceneDependencies) : ITimelineService
{
private static readonly string[] DefaultGraphMetrics = ["Overall Intensity", "Tension", "Emotional Weight", "Action"];
public async Task<TimelineViewModel?> GetAsync(int projectId, int? bookId, int? selectedSceneId)
{
var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId);
if (timeline.Project is null)
{
return null;
}
var allBooks = await books.ListByProjectAsync(projectId);
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 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,
BookOptions = allBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
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)
};
}
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,
IAssetRepository assets,
ICharacterRepository characters,
IWarningRepository warnings) : 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 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 warnings.FinaliseSceneDependencyWarningsAsync(book.ProjectID, bookId);
}
return summary;
}
public async Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId)
{
var summary = await warnings.ValidateSceneAsync(sceneId);
return summary;
}
public Task<ContinuityValidationSummary> ValidateAfterSceneMoveAsync(int projectId, int? bookId) =>
bookId.HasValue ? warnings.ValidateBookAsync(bookId.Value) : warnings.ValidateProjectAsync(projectId);
public async Task<ContinuityValidationSummary> ValidateAssetAsync(int assetId)
{
var asset = await assets.GetAssetAsync(assetId);
return asset is null ? new ContinuityValidationSummary() : await warnings.ValidateProjectAsync(asset.ProjectID);
}
public async Task<ContinuityValidationSummary> ValidateCharacterAsync(int characterId)
{
var character = await characters.GetCharacterAsync(characterId);
return character is null ? new ContinuityValidationSummary() : await warnings.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 AssetService(IProjectRepository projects, IAssetRepository assets, ILocationRepository locations) : IAssetService
{
public async Task<StoryAssetListViewModel?> GetAssetsAsync(int projectId)
{
var project = await projects.GetAsync(projectId);
return project is null ? null : new StoryAssetListViewModel
{
Project = project,
Assets = await assets.ListAssetsAsync(projectId)
};
}
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);
return project is null ? null : new LocationListViewModel
{
Project = project,
Locations = await locations.ListByProjectAsync(projectId)
};
}
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("Choose a 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 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();
}
}