1904 lines
83 KiB
C#
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();
|
|
}
|
|
}
|