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