9498 lines
420 KiB
C#
9498 lines
420 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
using Microsoft.Extensions.Options;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
internal static class AliasInput
|
|
{
|
|
public static IReadOnlyList<string> Clean(IEnumerable<string>? aliases) =>
|
|
(aliases ?? [])
|
|
.Select(alias => (alias ?? string.Empty).Trim())
|
|
.Where(alias => alias.Length > 0)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(50)
|
|
.ToList();
|
|
}
|
|
|
|
public interface IProjectService
|
|
{
|
|
Task<ProjectIndexViewModel> ListAsync();
|
|
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
|
|
Task<Project?> GetArchivedOwnerProjectAsync(int projectId);
|
|
Task<ProjectEditViewModel> GetCreateAsync();
|
|
Task<ProjectEditViewModel?> GetEditAsync(int projectId);
|
|
Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model);
|
|
Task<int> SaveAsync(ProjectEditViewModel model);
|
|
Task<bool> ArchiveAsync(int projectId);
|
|
Task<bool> RestoreAsync(int projectId);
|
|
Task<ProjectHardDeleteResult> HardDeleteAsync(int projectId);
|
|
}
|
|
|
|
public interface IProjectCollaborationService
|
|
{
|
|
Task<ProjectCollaboratorsViewModel?> GetCollaboratorsAsync(int projectId);
|
|
Task<ProjectCollaborationResult> AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model);
|
|
Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId);
|
|
}
|
|
|
|
public interface IProjectActivityService
|
|
{
|
|
Task<ProjectActivityViewModel?> GetActivityAsync(int projectId);
|
|
Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null);
|
|
}
|
|
|
|
public sealed class ProjectCollaborationResult
|
|
{
|
|
public bool Succeeded { get; init; }
|
|
public bool NotFound { get; init; }
|
|
public string Message { get; init; } = string.Empty;
|
|
|
|
public static ProjectCollaborationResult Success(string message) => new() { Succeeded = true, Message = message };
|
|
public static ProjectCollaborationResult Failure(string message) => new() { Message = message };
|
|
public static ProjectCollaborationResult Inaccessible() => new() { NotFound = true, Message = "Project not found." };
|
|
}
|
|
|
|
public sealed class EntityImageUploadException(string message, int entityId) : InvalidOperationException(message)
|
|
{
|
|
public int EntityId { get; } = entityId;
|
|
}
|
|
|
|
public sealed class ProjectActivityService(
|
|
IProjectRepository projects,
|
|
IProjectActivityRepository activity,
|
|
ICurrentUserService currentUser) : IProjectActivityService
|
|
{
|
|
public async Task<ProjectActivityViewModel?> GetActivityAsync(int projectId)
|
|
{
|
|
var project = currentUser.UserId.HasValue
|
|
? await projects.GetForUserAsync(projectId, currentUser.UserId.Value)
|
|
: null;
|
|
return project is null
|
|
? null
|
|
: new ProjectActivityViewModel
|
|
{
|
|
Project = project,
|
|
Activities = await activity.ListForProjectAsync(projectId)
|
|
};
|
|
}
|
|
|
|
public Task RecordAsync(int projectId, string activityType, string entityType, int? entityId, string? entityName, string? description = null)
|
|
{
|
|
if (projectId <= 0 || string.IsNullOrWhiteSpace(activityType) || string.IsNullOrWhiteSpace(entityType))
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return activity.RecordAsync(projectId, currentUser.UserId, activityType, entityType, entityId, entityName, description);
|
|
}
|
|
}
|
|
|
|
public interface IBookService
|
|
{
|
|
Task<BookDetailViewModel?> GetDetailAsync(int bookId);
|
|
Task<BookEditViewModel?> GetCreateAsync(int projectId);
|
|
Task<BookEditViewModel?> GetEditAsync(int bookId);
|
|
Task PopulateEditContextAsync(BookEditViewModel model);
|
|
Task<BookSaveResult> SaveAsync(BookEditViewModel model);
|
|
Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId);
|
|
Task ArchiveAsync(int bookId);
|
|
}
|
|
|
|
public sealed record BookSaveResult(int BookID, bool Succeeded, string? ErrorMessage = null);
|
|
|
|
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 UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
|
|
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 IStoryStateService
|
|
{
|
|
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
|
|
Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter);
|
|
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
|
|
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
|
|
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
|
|
Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter);
|
|
Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
|
|
Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
|
|
Task<bool> IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId);
|
|
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
|
|
}
|
|
|
|
public interface IPlotService
|
|
{
|
|
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
|
|
Task<PlotLineEditViewModel?> GetCreatePlotLineAsync(int projectId);
|
|
Task<PlotLineEditViewModel?> GetEditPlotLineAsync(int plotLineId);
|
|
Task<PlotLineEditViewModel?> PopulatePlotLineFormAsync(PlotLineEditViewModel model);
|
|
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 UpdateThreadEventAsync(ThreadEventEditViewModel 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 AcceptSceneAssetSuggestionAsync(int suggestionId);
|
|
Task RejectSceneAssetSuggestionAsync(int suggestionId);
|
|
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);
|
|
Task AcceptSceneLocationSuggestionAsync(int suggestionId);
|
|
Task RejectSceneLocationSuggestionAsync(int suggestionId);
|
|
}
|
|
|
|
public interface IFloorPlanService
|
|
{
|
|
Task<FloorPlanListViewModel?> ListAsync(int projectId);
|
|
Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId);
|
|
Task<int> SavePlanAsync(FloorPlanEditViewModel model);
|
|
Task DeletePlanAsync(int floorPlanId);
|
|
Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model);
|
|
Task DeleteFloorAsync(int floorPlanFloorId);
|
|
Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model);
|
|
Task<FloorPlanBlockEditViewModel?> GetBlockAsync(int floorPlanBlockId);
|
|
Task SaveLayoutAsync(FloorPlanEditorViewModel model);
|
|
Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload);
|
|
Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model);
|
|
Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId);
|
|
Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId);
|
|
Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model);
|
|
Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId);
|
|
Task DeleteBlockAsync(int floorPlanBlockId);
|
|
}
|
|
|
|
public interface IContinuityValidationService
|
|
{
|
|
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, string? category, bool includeDismissed, bool includeIntentional, bool showAcknowledgedWarnings, 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);
|
|
Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
|
|
Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
|
|
}
|
|
|
|
public interface ICharacterService
|
|
{
|
|
Task<CharacterListViewModel?> GetCharactersAsync(int projectId);
|
|
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
|
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
|
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
|
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
|
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
|
|
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
|
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
|
|
Task RemoveCharacterAvatarAsync(int characterId);
|
|
Task DeleteCharacterImageAsync(int characterImageId);
|
|
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 AcceptSceneCharacterSuggestionAsync(int suggestionId);
|
|
Task RejectSceneCharacterSuggestionAsync(int suggestionId);
|
|
Task<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model);
|
|
Task<int> UpdateAttributeEventAsync(CharacterAttributeEventCreateViewModel model);
|
|
Task<int> AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model);
|
|
Task<int> UpdateKnowledgeAsync(CharacterKnowledgeCreateViewModel model);
|
|
Task DeleteKnowledgeAsync(int characterKnowledgeId);
|
|
Task<int> AddRelationshipEventAsync(RelationshipEventCreateViewModel model);
|
|
Task<int> UpdateRelationshipEventAsync(RelationshipEventCreateViewModel model);
|
|
Task DeleteRelationshipEventAsync(int relationshipEventId);
|
|
string DisplayAge(Character character, DateTime? sceneStartDateTime = null);
|
|
}
|
|
|
|
public sealed record CharacterSaveResult(int CharacterID, int? UploadedCharacterImageID = 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 ISceneMetricValueService
|
|
{
|
|
Task<int> UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model);
|
|
}
|
|
|
|
public interface IAnalyticsService
|
|
{
|
|
Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
|
|
}
|
|
|
|
public interface IStoryBibleService
|
|
{
|
|
Task<StoryBibleViewModel?> GetAsync(int projectId, string? query);
|
|
}
|
|
|
|
public interface IRelationshipMapService
|
|
{
|
|
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
|
|
}
|
|
|
|
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 IWritingScheduleService
|
|
{
|
|
Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId);
|
|
Task<WritingPlanViewModel?> GetWritingPlanByIDAsync(int writingPlanId);
|
|
Task<int> CreateWritingPlanAsync(WritingPlanViewModel model);
|
|
Task UpdateWritingPlanAsync(WritingPlanViewModel model);
|
|
Task DeleteWritingPlanAsync(int writingPlanId);
|
|
Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive);
|
|
Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByPlanAsync(int writingPlanId);
|
|
Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate);
|
|
Task<int> CreateScheduleItemAsync(WritingScheduleItemViewModel model);
|
|
Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model);
|
|
Task DeleteScheduleItemAsync(int writingScheduleItemId);
|
|
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
|
|
Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId);
|
|
Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId);
|
|
Task DeleteScheduleAsync(int writingPlanId);
|
|
Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync();
|
|
Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId);
|
|
Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model);
|
|
Task ActivateWritingPlanAsync(int writingPlanId);
|
|
Task DeactivateWritingPlanAsync(int writingPlanId);
|
|
Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId);
|
|
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
|
|
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
|
|
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
|
|
Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync(int? projectId = null);
|
|
Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId);
|
|
Task<bool> SkipScheduleItemAsync(int writingScheduleItemId);
|
|
Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int? projectId = null);
|
|
Task<WritingScheduleRebalancePreviewViewModel> PreviewRebalanceAsync(int writingPlanId);
|
|
Task<WritingScheduleRebalancePreviewViewModel> ApplyRebalanceAsync(int writingPlanId);
|
|
}
|
|
|
|
public interface IStorageService
|
|
{
|
|
Task<StorageUsage?> GetSceneStorageUsageAsync(int sceneId);
|
|
Task<StorageLimitResult?> CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes);
|
|
Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes);
|
|
Task MarkDeletedByStoragePathAsync(string? storagePath);
|
|
}
|
|
|
|
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,
|
|
IProjectActivityRepository projectActivity,
|
|
IWarningRepository warnings,
|
|
IWritingScheduleRepository writingSchedule,
|
|
IProjectActivityService activity,
|
|
ISubscriptionService subscriptions,
|
|
IOnboardingService onboarding,
|
|
IStoryIntelligenceService storyIntelligence,
|
|
ICurrentUserService currentUser,
|
|
IHardDeleteFileCleanupService fileCleanup,
|
|
ILogger<ProjectService> logger) : IProjectService
|
|
{
|
|
public async Task<ProjectIndexViewModel> ListAsync()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new ProjectIndexViewModel();
|
|
}
|
|
|
|
var activeProjects = await projects.ListActiveForUserAsync(currentUser.UserId.Value);
|
|
var archivedProjects = await projects.ListArchivedForUserAsync(currentUser.UserId.Value);
|
|
var projectBooks = await books.ListByProjectsAsync(activeProjects.Concat(archivedProjects).Select(project => project.ProjectID));
|
|
var booksByProject = projectBooks
|
|
.GroupBy(book => book.ProjectID)
|
|
.ToDictionary(group => group.Key, group => group.ToList());
|
|
|
|
return new ProjectIndexViewModel
|
|
{
|
|
ActiveProjects = activeProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
|
ArchivedProjects = archivedProjects.Select(project => ToIndexCard(project, booksByProject)).ToList(),
|
|
Trial = TrialVisibilityViewModel.FromSubscription(await subscriptions.GetCurrentSubscriptionAsync(currentUser.UserId.Value), DateTime.UtcNow),
|
|
OnboardingNudge = await onboarding.GetDashboardNudgeAsync(),
|
|
StoryIntelligence = await storyIntelligence.GetDashboardAsync()
|
|
};
|
|
}
|
|
|
|
private static ProjectIndexCardViewModel ToIndexCard(Project project, IReadOnlyDictionary<int, List<Book>> booksByProject)
|
|
{
|
|
booksByProject.TryGetValue(project.ProjectID, out var projectBooks);
|
|
projectBooks ??= [];
|
|
|
|
return new ProjectIndexCardViewModel
|
|
{
|
|
ProjectID = project.ProjectID,
|
|
ProjectName = project.ProjectName,
|
|
Description = project.Description,
|
|
CreatedDate = project.CreatedDate,
|
|
UpdatedDate = project.UpdatedDate,
|
|
IsArchived = project.IsArchived,
|
|
ArchivedDate = project.ArchivedDate,
|
|
AccessRole = project.AccessRole,
|
|
BookCount = projectBooks.Count,
|
|
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
|
|
LastBookUpdatedDate = projectBooks.Count == 0 ? null : projectBooks.Max(book => book.UpdatedDate),
|
|
CoverThumbnailPaths = projectBooks
|
|
.Where(book => !string.IsNullOrWhiteSpace(book.CoverThumbnailPath))
|
|
.OrderBy(book => book.SortOrder)
|
|
.ThenBy(book => book.BookNumber)
|
|
.ThenBy(book => book.BookTitle)
|
|
.Select(book => book.CoverThumbnailPath!)
|
|
.Take(3)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var userId = currentUser.UserId.Value;
|
|
var project = await projects.GetForUserAsync(projectId, userId);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projectBooks = await books.ListByProjectAsync(projectId);
|
|
var metrics = await projects.GetDashboardMetricsAsync(projectId);
|
|
var recentActivity = await projectActivity.ListRecentForProjectAsync(projectId, 5);
|
|
var dashboardWarnings = await warnings.ListDashboardUnresolvedAsync(projectId, 5);
|
|
var attentionThreads = await projects.ListDashboardAttentionThreadsAsync(projectId, 5);
|
|
var writingPlans = await writingSchedule.GetWritingPlanSummariesByUserAsync(userId, projectId);
|
|
|
|
return new ProjectDetailViewModel
|
|
{
|
|
Project = project,
|
|
Summary = new ProjectDashboardSummaryViewModel
|
|
{
|
|
TotalBooks = projectBooks.Count,
|
|
PublishedBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)),
|
|
DraftingBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase)),
|
|
PlanningBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase)),
|
|
TotalWords = projectBooks.Sum(book => book.CurrentWordCount),
|
|
CharacterCount = metrics.CharacterCount,
|
|
LocationCount = metrics.LocationCount,
|
|
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
|
|
ContinuityWarningCount = metrics.ContinuityWarningCount
|
|
},
|
|
SeriesProgress = BuildSeriesProgress(projectBooks),
|
|
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
|
|
NeedsAttention = new ProjectDashboardAttentionViewModel
|
|
{
|
|
Warnings = dashboardWarnings.Select(ToDashboardWarningItem).ToList(),
|
|
Threads = attentionThreads.Select(ToDashboardThreadItem).ToList(),
|
|
OverdueBooks = projectBooks
|
|
.Where(IsOverdueDashboardBook)
|
|
.OrderBy(book => book.TargetCompletionDate)
|
|
.ThenBy(book => book.BookNumber)
|
|
.Take(5)
|
|
.Select(book => new ProjectDashboardOverdueBookViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
BookTitle = book.BookTitle,
|
|
TargetCompletionDate = book.TargetCompletionDate!.Value
|
|
})
|
|
.ToList(),
|
|
BookItems = BuildBookAttentionItems(projectBooks),
|
|
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
|
|
},
|
|
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
BookNumber = book.BookNumber,
|
|
BookTitle = book.BookTitle,
|
|
Subtitle = book.Subtitle,
|
|
Status = book.Status,
|
|
StatusCssClass = DashboardStatusCssClass(book.Status),
|
|
CurrentWordCount = book.CurrentWordCount,
|
|
TargetWordCount = book.TargetWordCount,
|
|
ProgressPercentage = book.ProgressPercentage,
|
|
ChapterCount = book.ChapterCount,
|
|
SceneCount = book.SceneCount,
|
|
UpdatedDate = book.UpdatedDate,
|
|
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
private static ProjectDashboardSeriesProgressViewModel BuildSeriesProgress(IReadOnlyList<Book> books)
|
|
{
|
|
var published = books.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase));
|
|
var drafting = books.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase));
|
|
var planning = books.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase));
|
|
var targetWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.TargetWordCount!.Value);
|
|
|
|
if (targetWordTotal > 0)
|
|
{
|
|
var currentWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.CurrentWordCount);
|
|
return new ProjectDashboardSeriesProgressViewModel
|
|
{
|
|
Percentage = Math.Min(100m, Math.Round(currentWordTotal * 100m / targetWordTotal, 1)),
|
|
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
|
|
CalculationLabel = "Based on target word counts"
|
|
};
|
|
}
|
|
|
|
// Fallback keeps projects without target word counts feeling active without inventing new story logic.
|
|
var statusProgress = books.Count == 0
|
|
? 0m
|
|
: books.Average(book => DashboardStatusProgressWeight(book.Status));
|
|
|
|
return new ProjectDashboardSeriesProgressViewModel
|
|
{
|
|
Percentage = Math.Round(statusProgress, 1),
|
|
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
|
|
CalculationLabel = "Based on book statuses"
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<ProjectDashboardBookAttentionItemViewModel> BuildBookAttentionItems(IReadOnlyList<Book> books)
|
|
{
|
|
return books
|
|
.Where(book => !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase))
|
|
.SelectMany(book => DashboardBookAttentionItems(book))
|
|
.Take(5)
|
|
.ToList();
|
|
}
|
|
|
|
private static IEnumerable<ProjectDashboardBookAttentionItemViewModel> DashboardBookAttentionItems(Book book)
|
|
{
|
|
if (book.SceneCount == 0)
|
|
{
|
|
yield return new ProjectDashboardBookAttentionItemViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
BookTitle = book.BookTitle,
|
|
Message = "No scenes have been added yet."
|
|
};
|
|
}
|
|
|
|
if (book.TargetWordCount is null or <= 0)
|
|
{
|
|
yield return new ProjectDashboardBookAttentionItemViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
BookTitle = book.BookTitle,
|
|
Message = "No target word count is set."
|
|
};
|
|
}
|
|
|
|
if (book.UpdatedDate.Date <= DateTime.Today.AddDays(-90))
|
|
{
|
|
yield return new ProjectDashboardBookAttentionItemViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
BookTitle = book.BookTitle,
|
|
Message = $"Not modified since {book.UpdatedDate:dd MMM yyyy}."
|
|
};
|
|
}
|
|
}
|
|
|
|
private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
|
|
{
|
|
return new ProjectDashboardActivityItemViewModel
|
|
{
|
|
ActivityType = item.ActivityType,
|
|
EntityType = item.EntityType,
|
|
EntityName = item.EntityName,
|
|
Description = item.Description,
|
|
UserLabel = item.UserLabel,
|
|
TimeLabel = RelativeTimeLabel(item.CreatedDateUTC),
|
|
IconLabel = DashboardIconLabel(item.EntityType, item.ActivityType)
|
|
};
|
|
}
|
|
|
|
private static ProjectDashboardWarningItemViewModel ToDashboardWarningItem(ContinuityWarning warning)
|
|
{
|
|
return new ProjectDashboardWarningItemViewModel
|
|
{
|
|
SeverityName = warning.SeverityName,
|
|
WarningTypeName = warning.WarningTypeName,
|
|
Message = warning.Message,
|
|
LocationLabel = DashboardWarningLocation(warning)
|
|
};
|
|
}
|
|
|
|
private static ProjectDashboardThreadItemViewModel ToDashboardThreadItem(ProjectDashboardAttentionThread thread)
|
|
{
|
|
return new ProjectDashboardThreadItemViewModel
|
|
{
|
|
PlotThreadID = thread.PlotThreadID,
|
|
ThreadTitle = thread.ThreadTitle,
|
|
PlotLineName = thread.PlotLineName,
|
|
ThreadStatusName = thread.ThreadStatusName,
|
|
Importance = thread.Importance,
|
|
AttentionLabel = string.Equals(thread.ThreadStatusName, "Dormant", StringComparison.OrdinalIgnoreCase)
|
|
? "Dormant"
|
|
: "High importance open"
|
|
};
|
|
}
|
|
|
|
private static bool IsOverdueDashboardBook(Book book)
|
|
{
|
|
return book.TargetCompletionDate.HasValue
|
|
&& book.TargetCompletionDate.Value.Date < DateTime.Today
|
|
&& !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static decimal DashboardStatusProgressWeight(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
BookStatuses.Published or BookStatuses.ReadyForPublication => 100m,
|
|
BookStatuses.Proofreading => 90m,
|
|
BookStatuses.Editing => 80m,
|
|
BookStatuses.Revising => 70m,
|
|
BookStatuses.Drafting => 45m,
|
|
BookStatuses.Paused => 35m,
|
|
BookStatuses.Outlining => 20m,
|
|
BookStatuses.Research => 15m,
|
|
BookStatuses.Planning => 10m,
|
|
_ => 0m
|
|
};
|
|
}
|
|
|
|
private static string DashboardStatusCssClass(string status)
|
|
{
|
|
return status switch
|
|
{
|
|
BookStatuses.Published => "dashboard-status-published",
|
|
BookStatuses.Drafting => "dashboard-status-drafting",
|
|
BookStatuses.Planning => "dashboard-status-planning",
|
|
BookStatuses.Research => "dashboard-status-research",
|
|
BookStatuses.Outlining => "dashboard-status-outlining",
|
|
BookStatuses.Revising => "dashboard-status-revising",
|
|
BookStatuses.Editing => "dashboard-status-editing",
|
|
BookStatuses.Proofreading => "dashboard-status-proofreading",
|
|
BookStatuses.ReadyForPublication => "dashboard-status-ready",
|
|
BookStatuses.Paused => "dashboard-status-paused",
|
|
BookStatuses.Archived => "dashboard-status-archived",
|
|
_ => "dashboard-status-planning"
|
|
};
|
|
}
|
|
|
|
private static string DashboardIconLabel(string entityType, string activityType)
|
|
{
|
|
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;
|
|
return string.IsNullOrWhiteSpace(source) ? "A" : source.Trim()[0].ToString().ToUpperInvariant();
|
|
}
|
|
|
|
private static string DashboardWarningLocation(ContinuityWarning warning)
|
|
{
|
|
if (warning.SceneNumber.HasValue)
|
|
{
|
|
return string.IsNullOrWhiteSpace(warning.SceneTitle)
|
|
? $"Scene {warning.SceneNumber.Value:g}"
|
|
: $"Scene {warning.SceneNumber.Value:g}: {warning.SceneTitle}";
|
|
}
|
|
|
|
if (warning.ChapterNumber.HasValue)
|
|
{
|
|
return string.IsNullOrWhiteSpace(warning.ChapterTitle)
|
|
? $"Chapter {warning.ChapterNumber.Value:g}"
|
|
: $"Chapter {warning.ChapterNumber.Value:g}: {warning.ChapterTitle}";
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(warning.BookDisplayTitle) ? warning.EntityType : warning.BookDisplayTitle;
|
|
}
|
|
|
|
private static string RelativeTimeLabel(DateTime createdDateUtc)
|
|
{
|
|
var elapsed = DateTime.UtcNow - DateTime.SpecifyKind(createdDateUtc, DateTimeKind.Utc);
|
|
if (elapsed.TotalMinutes < 1)
|
|
{
|
|
return "Just now";
|
|
}
|
|
|
|
if (elapsed.TotalMinutes < 60)
|
|
{
|
|
var minutes = Math.Max(1, (int)Math.Floor(elapsed.TotalMinutes));
|
|
return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago";
|
|
}
|
|
|
|
if (elapsed.TotalHours < 24)
|
|
{
|
|
var hours = Math.Max(1, (int)Math.Floor(elapsed.TotalHours));
|
|
return hours == 1 ? "1 hour ago" : $"{hours} hours ago";
|
|
}
|
|
|
|
if (elapsed.TotalDays < 7)
|
|
{
|
|
var days = Math.Max(1, (int)Math.Floor(elapsed.TotalDays));
|
|
return days == 1 ? "Yesterday" : $"{days} days ago";
|
|
}
|
|
|
|
return createdDateUtc.ToString("dd MMM yyyy");
|
|
}
|
|
|
|
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
|
|
{
|
|
return currentUser.UserId.HasValue
|
|
? projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value)
|
|
: Task.FromResult<Project?>(null);
|
|
}
|
|
|
|
public async Task<ProjectEditViewModel> GetCreateAsync()
|
|
{
|
|
var model = new ProjectEditViewModel();
|
|
await PopulateGenrePresetOptionsAsync(model);
|
|
return model;
|
|
}
|
|
|
|
public async Task<ProjectEditViewModel?> GetEditAsync(int projectId)
|
|
{
|
|
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
|
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 async Task<int> SaveAsync(ProjectEditViewModel model)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var isNew = model.ProjectID == 0;
|
|
var projectId = await projects.SaveAsync(new Project
|
|
{
|
|
ProjectID = model.ProjectID,
|
|
ProjectName = model.ProjectName,
|
|
Description = model.Description
|
|
}, currentUser.UserId.Value, model.ProjectID == 0 ? model.GenreMetricPresetKey : null);
|
|
|
|
await activity.RecordAsync(projectId, isNew ? "Created" : "Updated", "Project", projectId, model.ProjectName);
|
|
return projectId;
|
|
}
|
|
|
|
public async Task<bool> ArchiveAsync(int projectId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var project = await projects.GetForUserAsync(projectId, currentUser.UserId.Value);
|
|
var archived = await projects.ArchiveForOwnerAsync(projectId, currentUser.UserId.Value);
|
|
if (archived)
|
|
{
|
|
await activity.RecordAsync(projectId, "Archived", "Project", projectId, project?.ProjectName);
|
|
}
|
|
|
|
return archived;
|
|
}
|
|
|
|
public async Task<bool> RestoreAsync(int projectId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var project = await projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value);
|
|
var restored = await projects.RestoreForOwnerAsync(projectId, currentUser.UserId.Value);
|
|
if (restored)
|
|
{
|
|
await activity.RecordAsync(projectId, "Restored", "Project", projectId, project?.ProjectName);
|
|
}
|
|
|
|
return restored;
|
|
}
|
|
|
|
public async Task<ProjectHardDeleteResult> HardDeleteAsync(int projectId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
logger.LogWarning("Hard project delete requested without an authenticated user for project {ProjectID}.", projectId);
|
|
return ProjectHardDeleteResult.Inaccessible();
|
|
}
|
|
|
|
var userId = currentUser.UserId.Value;
|
|
var activeProject = await projects.GetForUserAsync(projectId, userId);
|
|
if (activeProject is not null)
|
|
{
|
|
if (!activeProject.IsOwner)
|
|
{
|
|
logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: user is not owner.", projectId, userId);
|
|
return ProjectHardDeleteResult.Inaccessible();
|
|
}
|
|
|
|
logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId);
|
|
return ProjectHardDeleteResult.ActiveProject();
|
|
}
|
|
|
|
var project = await projects.GetArchivedForOwnerAsync(projectId, userId);
|
|
if (project is null)
|
|
{
|
|
logger.LogWarning("Hard project delete denied for archived project {ProjectID} and user {UserID}: project not found or user is not owner.", projectId, userId);
|
|
return ProjectHardDeleteResult.Inaccessible();
|
|
}
|
|
|
|
IReadOnlyList<string> filePaths;
|
|
try
|
|
{
|
|
filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId);
|
|
await projects.HardDeleteForOwnerAsync(projectId, userId);
|
|
logger.LogInformation("Database hard delete completed for archived project {ProjectID} owned by user {UserID}. {FilePathCount} file paths queued for cleanup.",
|
|
projectId,
|
|
userId,
|
|
filePaths.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
|
|
projectId,
|
|
userId);
|
|
return ProjectHardDeleteResult.Failure("Project could not be permanently deleted. No files were removed.");
|
|
}
|
|
|
|
var cleanup = fileCleanup.DeleteFiles("project hard delete", projectId, filePaths);
|
|
if (cleanup.Failed > 0)
|
|
{
|
|
logger.LogWarning("Project {ProjectID} was hard deleted from the database but file cleanup had {FailedFileCount} failures, {DeletedFileCount} deleted files, and {MissingFileCount} missing files.",
|
|
projectId,
|
|
cleanup.Failed,
|
|
cleanup.Deleted,
|
|
cleanup.Missing);
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("File cleanup completed for hard-deleted project {ProjectID}: {DeletedFileCount} deleted files, {MissingFileCount} missing files.",
|
|
projectId,
|
|
cleanup.Deleted,
|
|
cleanup.Missing);
|
|
}
|
|
|
|
return ProjectHardDeleteResult.Success(cleanup.Deleted, cleanup.Missing, cleanup.Failed);
|
|
}
|
|
}
|
|
|
|
public sealed class ProjectCollaborationService(
|
|
IProjectRepository projects,
|
|
IProjectCollaborationRepository collaboration,
|
|
IUserRepository users,
|
|
IProjectAccessService access,
|
|
ISubscriptionService subscriptions,
|
|
IEmailQueueRepository emailQueue,
|
|
IProjectActivityService activity,
|
|
ICurrentUserService currentUser,
|
|
IOptions<EmailSettings> emailOptions,
|
|
ILogger<ProjectCollaborationService> logger) : IProjectCollaborationService
|
|
{
|
|
private readonly EmailSettings _emailSettings = emailOptions.Value;
|
|
|
|
public async Task<ProjectCollaboratorsViewModel?> GetCollaboratorsAsync(int projectId)
|
|
{
|
|
if (!await access.CanOwnProjectAsync(projectId))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new ProjectCollaboratorsViewModel
|
|
{
|
|
Project = project,
|
|
Collaborators = await collaboration.ListCollaboratorsAsync(projectId),
|
|
PendingInvitations = await collaboration.ListPendingInvitationsAsync(projectId),
|
|
Invite = new ProjectCollaboratorInviteViewModel { ProjectID = projectId }
|
|
};
|
|
}
|
|
|
|
public async Task<ProjectCollaborationResult> AddCollaboratorAsync(ProjectCollaboratorInviteViewModel model)
|
|
{
|
|
if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(model.ProjectID))
|
|
{
|
|
return ProjectCollaborationResult.Inaccessible();
|
|
}
|
|
|
|
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);
|
|
if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value)
|
|
{
|
|
return ProjectCollaborationResult.Inaccessible();
|
|
}
|
|
|
|
var email = NormalizeEmail(model.EmailAddress);
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
{
|
|
return ProjectCollaborationResult.Failure("Enter a valid collaborator email address.");
|
|
}
|
|
|
|
if (string.Equals(email, currentUser.Email, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return ProjectCollaborationResult.Failure("Owners already have access to their own projects.");
|
|
}
|
|
|
|
var user = await users.GetByEmailAsync(email);
|
|
if (user is not null)
|
|
{
|
|
if (user.UserID == ownerUserId.Value)
|
|
{
|
|
return ProjectCollaborationResult.Failure("Owners already have access to their own projects.");
|
|
}
|
|
|
|
var existingCollaborators = await collaboration.ListCollaboratorsAsync(model.ProjectID);
|
|
if (existingCollaborators.Any(x => x.UserID == user.UserID))
|
|
{
|
|
return ProjectCollaborationResult.Success("That user already has collaborator access.");
|
|
}
|
|
|
|
if (!await collaboration.CollaboratorCountsForOwnerAsync(ownerUserId.Value, user.UserID))
|
|
{
|
|
var limit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value);
|
|
if (!limit.Allowed)
|
|
{
|
|
return ProjectCollaborationResult.Failure(limit.Message);
|
|
}
|
|
}
|
|
|
|
await collaboration.AddCollaboratorAsync(model.ProjectID, user.UserID, currentUser.UserId.Value);
|
|
await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", user.UserID, user.DisplayName, $"Added collaborator {user.Email}.");
|
|
await QueueInvitationNotificationAsync(user, model.ProjectID);
|
|
return ProjectCollaborationResult.Success("Collaborator added.");
|
|
}
|
|
|
|
var pendingLimit = await subscriptions.CanInviteCollaboratorAsync(ownerUserId.Value);
|
|
if (!pendingLimit.Allowed)
|
|
{
|
|
return ProjectCollaborationResult.Failure(pendingLimit.Message);
|
|
}
|
|
|
|
await collaboration.CreatePendingInvitationAsync(model.ProjectID, email, currentUser.UserId.Value);
|
|
await activity.RecordAsync(model.ProjectID, "Shared", "Collaborator", null, email, "Pending invitation recorded.");
|
|
return ProjectCollaborationResult.Success("Pending invitation recorded. When that person creates an account, this invitation can be completed in the collaboration workflow.");
|
|
}
|
|
|
|
public async Task<ProjectCollaborationResult> RemoveCollaboratorAsync(int projectId, int userId)
|
|
{
|
|
if (!currentUser.UserId.HasValue || !await access.CanOwnProjectAsync(projectId))
|
|
{
|
|
return ProjectCollaborationResult.Inaccessible();
|
|
}
|
|
|
|
var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
|
|
if (!ownerUserId.HasValue || ownerUserId.Value != currentUser.UserId.Value)
|
|
{
|
|
return ProjectCollaborationResult.Inaccessible();
|
|
}
|
|
|
|
if (userId == ownerUserId.Value)
|
|
{
|
|
return ProjectCollaborationResult.Failure("Project owners cannot be removed as collaborators.");
|
|
}
|
|
|
|
var removed = await collaboration.RemoveCollaboratorAsync(projectId, userId);
|
|
if (removed)
|
|
{
|
|
await activity.RecordAsync(projectId, "CollaboratorRemoved", "Collaborator", userId, null);
|
|
}
|
|
return removed
|
|
? ProjectCollaborationResult.Success("Collaborator removed.")
|
|
: ProjectCollaborationResult.Failure("Collaborator access was not found.");
|
|
}
|
|
|
|
private async Task QueueInvitationNotificationAsync(AppUser user, int projectId)
|
|
{
|
|
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null;
|
|
var projectName = project?.ProjectName ?? "a project";
|
|
var inviterName = CleanDisplayName(currentUser.DisplayName);
|
|
var subject = $"You have been added to {projectName} in PlotDirector";
|
|
var plainText = $"""
|
|
You have been added as a collaborator.
|
|
|
|
Hello {CleanDisplayName(user.DisplayName)},
|
|
|
|
{inviterName} added you as a collaborator on "{projectName}" in PlotDirector. Sign in to PlotDirector to open the shared project.
|
|
""";
|
|
var html = $"""
|
|
<!doctype html>
|
|
<html>
|
|
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
|
|
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
|
|
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotDirector</p>
|
|
<h1 style="margin:0 0 16px;font-size:24px;">You have been added as a collaborator</h1>
|
|
<p>Hello {WebUtility.HtmlEncode(CleanDisplayName(user.DisplayName))},</p>
|
|
<p>{WebUtility.HtmlEncode(inviterName)} added you as a collaborator on <strong>{WebUtility.HtmlEncode(projectName)}</strong>.</p>
|
|
<p>Sign in to PlotDirector to open the shared project.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
""";
|
|
|
|
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
|
|
{
|
|
ToEmail = user.Email,
|
|
ToName = CleanDisplayName(user.DisplayName),
|
|
FromEmail = GetFromAddress(),
|
|
FromName = CleanDisplayName(_emailSettings.FromName),
|
|
Subject = subject,
|
|
PlainTextBody = plainText,
|
|
HtmlBody = html,
|
|
MaxAttempts = 5
|
|
});
|
|
|
|
logger.LogInformation("Queued collaborator invitation email {EmailQueueID} for user {UserID}.", emailQueueId, user.UserID);
|
|
}
|
|
|
|
private static string NormalizeEmail(string? value) => value?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
|
|
private static string CleanDisplayName(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? "PlotDirector" : value.Trim();
|
|
}
|
|
|
|
private string GetFromAddress()
|
|
{
|
|
return string.IsNullOrWhiteSpace(_emailSettings.FromAddress)
|
|
? "hello@plotdirector.com"
|
|
: _emailSettings.FromAddress.Trim();
|
|
}
|
|
}
|
|
|
|
internal static class SubscriptionLimitGuards
|
|
{
|
|
public static async Task EnsureAllowedAsync(Func<int, Task<SubscriptionLimitResult>> validateAsync, int? userId)
|
|
{
|
|
if (!userId.HasValue)
|
|
{
|
|
throw new SubscriptionLimitException("Sign in before creating new content.");
|
|
}
|
|
|
|
var result = await validateAsync(userId.Value);
|
|
if (!result.Allowed)
|
|
{
|
|
throw new SubscriptionLimitException(result.Message);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class BookService(
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
IChapterRepository chapters,
|
|
IManuscriptDocumentRepository manuscriptDocuments,
|
|
IBookCoverService bookCovers,
|
|
ISubscriptionService subscriptions,
|
|
IProjectActivityService activity,
|
|
IStoryIntelligencePipelineStateService storyIntelligencePipeline,
|
|
ICurrentUserService currentUser) : 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,
|
|
ManuscriptDocument = currentUser.UserId is int userId
|
|
? await manuscriptDocuments.GetByBookAsync(bookId, userId)
|
|
: null,
|
|
Chapters = await chapters.ListByBookAsync(bookId),
|
|
StoryIntelligencePipeline = currentUser.UserId is int pipelineUserId
|
|
? await storyIntelligencePipeline.GetForBookAsync(bookId, pipelineUserId)
|
|
: null
|
|
};
|
|
}
|
|
|
|
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,
|
|
Status = BookStatuses.Planning,
|
|
CoverThumbnailPath = BookCoverService.PlaceholderPath,
|
|
StatusOptions = BuildStatusOptions(BookStatuses.Planning)
|
|
};
|
|
}
|
|
|
|
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,
|
|
Subtitle = book.Subtitle,
|
|
Tagline = book.Tagline,
|
|
ShortDescription = book.ShortDescription,
|
|
Status = book.Status,
|
|
BookNumber = book.BookNumber,
|
|
Description = book.Description,
|
|
TargetWordCount = book.TargetWordCount,
|
|
WritingStartDate = book.WritingStartDate,
|
|
TargetCompletionDate = book.TargetCompletionDate,
|
|
PublicationDate = book.PublicationDate,
|
|
UsesExplicitScenes = book.UsesExplicitScenes,
|
|
CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath,
|
|
CoverStandardPath = book.CoverStandardPath,
|
|
Project = await projects.GetAsync(book.ProjectID),
|
|
StatusOptions = BuildStatusOptions(book.Status)
|
|
};
|
|
}
|
|
|
|
public async Task PopulateEditContextAsync(BookEditViewModel model)
|
|
{
|
|
model.Project = await projects.GetAsync(model.ProjectID);
|
|
model.StatusOptions = BuildStatusOptions(model.Status);
|
|
if (model.BookID != 0)
|
|
{
|
|
var book = await books.GetAsync(model.BookID);
|
|
model.CoverThumbnailPath = book?.CoverThumbnailPath ?? BookCoverService.PlaceholderPath;
|
|
model.CoverStandardPath = book?.CoverStandardPath;
|
|
}
|
|
}
|
|
|
|
public async Task<BookSaveResult> SaveAsync(BookEditViewModel model)
|
|
{
|
|
if (model.BookID == 0)
|
|
{
|
|
await SubscriptionLimitGuards.EnsureAllowedAsync(subscriptions.CanCreateBookAsync, currentUser.UserId);
|
|
}
|
|
|
|
if (!BookStatuses.IsValid(model.Status))
|
|
{
|
|
model.Status = BookStatuses.Planning;
|
|
}
|
|
|
|
var isNew = model.BookID == 0;
|
|
var bookId = await books.SaveAsync(new Book
|
|
{
|
|
BookID = model.BookID,
|
|
ProjectID = model.ProjectID,
|
|
BookTitle = model.BookTitle,
|
|
Subtitle = model.Subtitle,
|
|
Tagline = model.Tagline,
|
|
ShortDescription = model.ShortDescription,
|
|
Status = model.Status,
|
|
BookNumber = model.BookNumber,
|
|
Description = model.Description,
|
|
TargetWordCount = model.TargetWordCount,
|
|
WritingStartDate = model.WritingStartDate,
|
|
TargetCompletionDate = model.TargetCompletionDate,
|
|
PublicationDate = model.PublicationDate,
|
|
UsesExplicitScenes = model.UsesExplicitScenes
|
|
});
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Book", bookId, model.BookTitle);
|
|
|
|
if (model.CoverUpload is { Length: > 0 })
|
|
{
|
|
var savedBook = await books.GetAsync(bookId);
|
|
if (savedBook is null)
|
|
{
|
|
return new(bookId, false, "Book was saved, but the cover could not be linked.");
|
|
}
|
|
|
|
var coverResult = await bookCovers.UploadCoverAsync(savedBook, model.CoverUpload);
|
|
if (!coverResult.Succeeded)
|
|
{
|
|
return new(bookId, false, coverResult.ErrorMessage);
|
|
}
|
|
}
|
|
|
|
return new(bookId, true);
|
|
}
|
|
|
|
public Task<ManuscriptDocumentUnlinkResult?> UnlinkManuscriptAsync(int bookId)
|
|
{
|
|
return currentUser.UserId is int userId
|
|
? manuscriptDocuments.UnlinkBookAsync(bookId, null, userId)
|
|
: Task.FromResult<ManuscriptDocumentUnlinkResult?>(null);
|
|
}
|
|
|
|
private static IReadOnlyList<SelectListItem> BuildStatusOptions(string? selectedStatus)
|
|
=> BookStatuses.All
|
|
.Select(status => new SelectListItem(status, status, string.Equals(status, selectedStatus, StringComparison.OrdinalIgnoreCase)))
|
|
.ToList();
|
|
|
|
public async Task ArchiveAsync(int bookId)
|
|
{
|
|
var book = await books.GetAsync(bookId);
|
|
await books.ArchiveAsync(bookId);
|
|
if (book is not null)
|
|
{
|
|
await activity.RecordAsync(book.ProjectID, "Archived", "Book", bookId, book.BookTitle);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class ChapterService(
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
IChapterRepository chapters,
|
|
ISceneRepository scenes,
|
|
ICharacterRepository characters,
|
|
IAssetRepository assets,
|
|
ILocationRepository locations,
|
|
ILookupRepository lookups,
|
|
ISubscriptionService subscriptions,
|
|
IProjectActivityService activity,
|
|
ICurrentUserService currentUser) : 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;
|
|
}
|
|
|
|
var sceneRows = await scenes.ListByChapterAsync(chapterId);
|
|
if (currentUser.UserId is int userId)
|
|
{
|
|
var suggestionCounts = await characters.CountSceneCharacterSuggestionsByChapterAsync(chapterId, userId);
|
|
var countsByScene = suggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount);
|
|
var assetSuggestionCounts = await assets.CountSceneAssetSuggestionsByChapterAsync(chapterId, userId);
|
|
var assetCountsByScene = assetSuggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount);
|
|
var locationSuggestionCounts = await locations.CountSceneLocationSuggestionsByChapterAsync(chapterId, userId);
|
|
var locationCountsByScene = locationSuggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount);
|
|
foreach (var scene in sceneRows)
|
|
{
|
|
scene.PendingCharacterSuggestionCount = countsByScene.GetValueOrDefault(scene.SceneID);
|
|
scene.PendingAssetSuggestionCount = assetCountsByScene.GetValueOrDefault(scene.SceneID);
|
|
scene.PendingLocationSuggestionCount = locationCountsByScene.GetValueOrDefault(scene.SceneID);
|
|
}
|
|
}
|
|
|
|
return new ChapterDetailViewModel
|
|
{
|
|
Project = project,
|
|
Book = book,
|
|
Chapter = chapter,
|
|
Scenes = sceneRows,
|
|
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
|
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
|
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
|
};
|
|
}
|
|
|
|
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),
|
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
|
};
|
|
}
|
|
|
|
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,
|
|
ChapterPurposeID = chapter.ChapterPurposeID,
|
|
RevisionStatusID = chapter.RevisionStatusID,
|
|
IsLinkedToManuscript = chapter.IsLinkedToManuscript,
|
|
Book = book,
|
|
Project = project,
|
|
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
|
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
|
};
|
|
}
|
|
|
|
public async Task<int> SaveAsync(ChapterEditViewModel model)
|
|
{
|
|
if (model.ChapterID == 0)
|
|
{
|
|
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateChapterAsync(model.BookID, userId), currentUser.UserId);
|
|
}
|
|
|
|
var isNew = model.ChapterID == 0;
|
|
var chapterId = await chapters.SaveAsync(new Chapter
|
|
{
|
|
ChapterID = model.ChapterID,
|
|
BookID = model.BookID,
|
|
ChapterNumber = model.ChapterNumber,
|
|
ChapterTitle = model.ChapterTitle,
|
|
Summary = model.Summary,
|
|
ChapterPurposeID = model.ChapterPurposeID,
|
|
RevisionStatusID = model.RevisionStatusID
|
|
});
|
|
var book = await books.GetAsync(model.BookID);
|
|
if (book is not null)
|
|
{
|
|
await activity.RecordAsync(book.ProjectID, isNew ? "Created" : "Updated", "Chapter", chapterId, model.ChapterTitle);
|
|
}
|
|
return chapterId;
|
|
}
|
|
|
|
public async Task ArchiveAsync(int chapterId)
|
|
{
|
|
var chapter = await chapters.GetAsync(chapterId);
|
|
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
|
|
await chapters.ArchiveAsync(chapterId);
|
|
if (chapter is not null && book is not null)
|
|
{
|
|
await activity.RecordAsync(book.ProjectID, "Archived", "Chapter", chapterId, chapter.ChapterTitle);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
internal 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())).Prepend(new SelectListItem("None", string.Empty)).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,
|
|
ISubscriptionService subscriptions,
|
|
IProjectActivityService activity,
|
|
IStorageService storage,
|
|
IStoryStateService storyState,
|
|
ICurrentUserService currentUser) : 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,
|
|
TimeModeName = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeName ?? lookupData.TimeModes.First().TimeModeName,
|
|
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,
|
|
POVCharacterID = scene.POVCharacterID,
|
|
TimeModeID = scene.TimeModeID,
|
|
TimeModeName = scene.TimeModeName,
|
|
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,
|
|
IsLinkedToManuscript = scene.IsLinkedToManuscript,
|
|
PrimaryLocationID = scene.PrimaryLocationID,
|
|
FloorPlanID = scene.FloorPlanID,
|
|
InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID,
|
|
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 isNew = model.SceneID == 0;
|
|
if (isNew)
|
|
{
|
|
var chapter = await chapters.GetAsync(model.ChapterID);
|
|
if (chapter is null)
|
|
{
|
|
throw new SubscriptionLimitException("The chapter could not be found.");
|
|
}
|
|
|
|
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateSceneAsync(chapter.BookID, userId), currentUser.UserId);
|
|
}
|
|
|
|
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,
|
|
FloorPlanID = model.FloorPlanID,
|
|
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
|
|
});
|
|
|
|
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
|
|
}));
|
|
var context = await GetContextAsync(model.ChapterID);
|
|
if (context is not null)
|
|
{
|
|
await activity.RecordAsync(context.Value.Project.ProjectID, isNew ? "Created" : "Updated", "Scene", sceneId, model.SceneTitle);
|
|
}
|
|
return sceneId;
|
|
}
|
|
|
|
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) =>
|
|
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId);
|
|
|
|
public async Task ArchiveAsync(int sceneId)
|
|
{
|
|
var scene = await scenes.GetAsync(sceneId);
|
|
var context = scene is null ? null : await GetContextAsync(scene.ChapterID);
|
|
await scenes.ArchiveAsync(sceneId);
|
|
if (scene is not null && context is not null)
|
|
{
|
|
await activity.RecordAsync(context.Value.Project.ProjectID, "Archived", "Scene", sceneId, scene.SceneTitle);
|
|
}
|
|
}
|
|
|
|
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.CharacterSuggestions = model.SceneID == 0 || currentUser.UserId is not int userId ? [] : await characters.ListSceneCharacterSuggestionsAsync(model.SceneID, userId);
|
|
model.AssetSuggestions = model.SceneID == 0 || currentUser.UserId is not int assetUserId ? [] : await assets.ListSceneAssetSuggestionsAsync(model.SceneID, assetUserId);
|
|
model.LocationSuggestions = model.SceneID == 0 || currentUser.UserId is not int locationUserId ? [] : await locations.ListSceneLocationSuggestionsAsync(model.SceneID, locationUserId);
|
|
model.CharacterAttributeEvents = model.SceneID == 0 ? [] : await characters.ListAttributeEventsBySceneAsync(model.SceneID);
|
|
model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID);
|
|
model.AgeContinuityWarnings = await BuildAgeContinuityWarningsAsync(model);
|
|
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.StorageUsage = model.SceneID == 0 ? null : await storage.GetSceneStorageUsageAsync(model.SceneID);
|
|
model.StoryState = model.SceneID == 0 ? new StoryStateViewModel() : await storyState.GetStoryStateAsync(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.PlotLineOptions = ToOptionalSelectList(plotLines, x => x.PlotLineID, x => x.PlotLineName);
|
|
model.PlotThreadOptions = ToOptionalSelectList(plotThreads, x => x.PlotThreadID, x => $"{x.PlotLineName}: {x.ThreadTitle}");
|
|
model.PlotLineEventOptions = plotLines;
|
|
model.PlotThreadEventOptions = plotThreads;
|
|
model.ThreadTypeOptions = ToOptionalSelectList(plotLookups.ThreadTypes, x => x.ThreadTypeID, x => x.TypeName);
|
|
model.ThreadEventTypeOptions = ChapterService.ToSelectList(plotLookups.ThreadEventTypes, x => x.ThreadEventTypeID, x => x.TypeName);
|
|
model.PlotEventTypeOptions = ChapterService.ToSelectList(plotLookups.PlotEventTypes, x => x.PlotEventTypeID, 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 = ToRelationshipTypeSelectList(characterLookups.RelationshipTypes);
|
|
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.BookDisplayTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}");
|
|
model.ChapterOptions = ChapterService.ToSelectList(chapterOptions, x => x.ChapterID, x => $"{x.BookDisplayTitle} / 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,
|
|
PlotEventTypeID = plotLookups.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID
|
|
?? plotLookups.PlotEventTypes.FirstOrDefault()?.PlotEventTypeID
|
|
?? 0,
|
|
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;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<ContinuityWarning>> BuildAgeContinuityWarningsAsync(SceneEditViewModel model)
|
|
{
|
|
if (model.SceneID == 0 || model.Project is null || model.Book is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var chapterRows = await chapters.ListByBookAsync(model.Book.BookID);
|
|
var scenesInBook = new List<CharacterAgeContinuityScene>();
|
|
foreach (var chapter in chapterRows)
|
|
{
|
|
var chapterScenes = await scenes.ListByChapterAsync(chapter.ChapterID);
|
|
scenesInBook.AddRange(chapterScenes.Select(scene => new CharacterAgeContinuityScene
|
|
{
|
|
SceneID = scene.SceneID,
|
|
ProjectID = model.Project.ProjectID,
|
|
BookID = model.Book.BookID,
|
|
ChapterID = chapter.ChapterID,
|
|
ChapterNumber = chapter.ChapterNumber,
|
|
ChapterTitle = chapter.ChapterTitle,
|
|
SceneNumber = scene.SceneNumber,
|
|
SceneTitle = scene.SceneTitle,
|
|
ChapterSortOrder = chapter.SortOrder,
|
|
SceneSortOrder = scene.SortOrder,
|
|
TimeModeName = scene.TimeModeName,
|
|
StartDateTime = scene.StartDateTime
|
|
}));
|
|
}
|
|
|
|
var currentScene = scenesInBook.FirstOrDefault(scene => scene.SceneID == model.SceneID)
|
|
?? new CharacterAgeContinuityScene
|
|
{
|
|
SceneID = model.SceneID,
|
|
ProjectID = model.Project.ProjectID,
|
|
BookID = model.Book.BookID,
|
|
ChapterID = model.ChapterID,
|
|
ChapterNumber = model.Chapter?.ChapterNumber,
|
|
ChapterTitle = model.Chapter?.ChapterTitle,
|
|
SceneNumber = model.SceneNumber,
|
|
SceneTitle = model.SceneTitle,
|
|
ChapterSortOrder = model.Chapter?.SortOrder ?? 0,
|
|
SceneSortOrder = 0,
|
|
TimeModeName = model.TimeModeName,
|
|
StartDateTime = model.StartDateTime
|
|
};
|
|
|
|
var characterTimeline = await characters.GetTimelineAsync(model.Project.ProjectID, model.Book.BookID);
|
|
return CharacterAgeContinuityService.GetWarningsForScene(
|
|
currentScene,
|
|
scenesInBook,
|
|
characterTimeline.Appearances,
|
|
model.SceneCharacters);
|
|
}
|
|
|
|
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();
|
|
|
|
private static IReadOnlyList<SelectListItem> ToRelationshipTypeSelectList(IEnumerable<RelationshipType> relationshipTypes)
|
|
{
|
|
var groups = relationshipTypes
|
|
.GroupBy(x => string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName)
|
|
.ToDictionary(x => x.Key, x => new SelectListGroup { Name = x.Key });
|
|
|
|
return relationshipTypes
|
|
.OrderBy(x => x.CategorySortOrder == 0 ? 900 : x.CategorySortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.TypeName)
|
|
.Select(x => new SelectListItem(x.TypeName, x.RelationshipTypeID.ToString())
|
|
{
|
|
Group = groups[string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName]
|
|
})
|
|
.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), projectId, bookId);
|
|
var characterLookupData = await TimeTimelineLoadAsync("dbo.CharacterLookup_All", () => characters.GetLookupsAsync(projectId), 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 timelineWarnings = needsWarnings
|
|
? await TimeTimelineLoadAsync("dbo.ContinuityWarning_List", () => warnings.ListAsync(projectId, bookId, null, filter.WarningTypeID, filter.WarningSeverityID, null, filter.IncludeDismissedWarnings, filter.IncludeIntentionalWarnings), projectId, bookId)
|
|
: [];
|
|
var warningsByScene = await TimeTimelineLoadAsync(
|
|
"TimelineService.PrepareWarnings",
|
|
() => Task.FromResult(timelineWarnings
|
|
.Where(x => x.SceneID.HasValue)
|
|
.GroupBy(x => x.SceneID!.Value)
|
|
.ToDictionary(x => x.Key, x => (IReadOnlyList<ContinuityWarning>)x.ToList())),
|
|
projectId,
|
|
bookId);
|
|
var warningCounts = warningsByScene.ToDictionary(x => x.Key, x => x.Value.Count);
|
|
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)
|
|
: ([], []);
|
|
ApplyTimelineCharacterAvatars(characterTimeline, allCharacters);
|
|
var sceneOverviewRows = settings.ShowSceneCards
|
|
? await TimeTimelineLoadAsync("dbo.Timeline_SceneOverviews", () => timelineRepository.GetSceneOverviewsAsync(projectId, bookId), projectId, bookId)
|
|
: [];
|
|
var plotThreadDetailsById = plotThreads.ToDictionary(x => x.PlotThreadID);
|
|
foreach (var threadEvent in timeline.ThreadEvents)
|
|
{
|
|
if (plotThreadDetailsById.TryGetValue(threadEvent.PlotThreadID, out var thread))
|
|
{
|
|
threadEvent.ThreadStatusName = thread.ThreadStatusName;
|
|
threadEvent.Importance = thread.Importance;
|
|
}
|
|
}
|
|
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 = await TimeTimelineLoadAsync("TimelineService.BuildFocusLabel", () => Task.FromResult(BuildFocusLabel(filter, plotLines, plotThreads, allAssets, allCharacters, allLocations)), projectId, bookId);
|
|
var sceneOverviews = settings.ShowSceneCards
|
|
? await TimeTimelineLoadAsync("TimelineService.BuildSceneOverviews", () => Task.FromResult(BuildSceneOverviews(orderedScenes, sceneOverviewRows)), projectId, bookId)
|
|
: new Dictionary<int, TimelineSceneOverviewViewModel>();
|
|
var metricGraphs = settings.ShowMetricShape
|
|
? await TimeTimelineLoadAsync("TimelineService.BuildMetricGraphs", () => Task.FromResult(BuildMetricGraphs(timeline, orderedScenes)), projectId, bookId)
|
|
: [];
|
|
var plotLanes = settings.ShowPlotLines
|
|
? await TimeTimelineLoadAsync("TimelineService.BuildPlotLanes", () => Task.FromResult(BuildPlotLanes(timeline, orderedScenes)), projectId, bookId)
|
|
: [];
|
|
var assetLanes = settings.ShowStoryAssets
|
|
? await TimeTimelineLoadAsync("TimelineService.BuildAssetLanes", () => Task.FromResult(BuildAssetLanes(assetTimeline.Assets, assetTimeline.Events, orderedScenes)), projectId, bookId)
|
|
: [];
|
|
var characterLanes = settings.ShowCharacterAppearances
|
|
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
|
|
: [];
|
|
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.BookDisplayTitle}", 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 = metricGraphs,
|
|
PlotLanes = plotLanes,
|
|
AssetLanes = assetLanes,
|
|
CharacterLanes = characterLanes,
|
|
SceneOverviews = sceneOverviews,
|
|
WarningsByScene = warningsByScene,
|
|
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,
|
|
MinValue = metric.MinValue,
|
|
MaxValue = metric.MaxValue,
|
|
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 void ApplyTimelineCharacterAvatars(
|
|
(IReadOnlyList<Character> Characters, IReadOnlyList<SceneCharacter> Appearances) characterTimeline,
|
|
IReadOnlyList<Character> allCharacters)
|
|
{
|
|
var characterImages = allCharacters.ToDictionary(x => x.CharacterID);
|
|
foreach (var character in characterTimeline.Characters)
|
|
{
|
|
if (!characterImages.TryGetValue(character.CharacterID, out var source))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
character.ImagePath = source.ImagePath;
|
|
character.ThumbnailPath = source.ThumbnailPath;
|
|
character.AvatarImagePath = source.AvatarImagePath;
|
|
character.AvatarThumbnailPath = source.AvatarThumbnailPath;
|
|
character.AvatarSourceCharacterImageID = source.AvatarSourceCharacterImageID;
|
|
}
|
|
|
|
foreach (var appearance in characterTimeline.Appearances)
|
|
{
|
|
if (!characterImages.TryGetValue(appearance.CharacterID, out var source))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
appearance.CharacterImagePath = source.AvatarImagePath ?? source.ImagePath;
|
|
appearance.CharacterThumbnailPath = source.AvatarThumbnailPath ?? source.ThumbnailPath;
|
|
}
|
|
}
|
|
|
|
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 IReadOnlyDictionary<int, TimelineSceneOverviewViewModel> BuildSceneOverviews(
|
|
IReadOnlyList<Scene> orderedScenes,
|
|
IReadOnlyList<TimelineSceneOverviewRow> overviewRows)
|
|
{
|
|
var charactersByScene = overviewRows
|
|
.Where(x => x.CharacterID.HasValue && !string.IsNullOrWhiteSpace(x.CharacterName))
|
|
.GroupBy(x => x.SceneID)
|
|
.ToDictionary(
|
|
sceneGroup => sceneGroup.Key,
|
|
sceneGroup => sceneGroup
|
|
.GroupBy(x => x.CharacterID!.Value)
|
|
.Select(characterGroup =>
|
|
{
|
|
var first = characterGroup
|
|
.OrderByDescending(x => x.IsPov)
|
|
.ThenBy(x => x.CharacterName)
|
|
.First();
|
|
return new TimelineSceneCharacterOverviewViewModel
|
|
{
|
|
CharacterID = first.CharacterID!.Value,
|
|
CharacterName = first.CharacterName ?? string.Empty,
|
|
IsPov = first.IsPov
|
|
};
|
|
})
|
|
.OrderByDescending(x => x.IsPov)
|
|
.ThenBy(x => x.CharacterName)
|
|
.ToList());
|
|
var assetsByScene = overviewRows
|
|
.Where(x => x.StoryAssetID.HasValue && !string.IsNullOrWhiteSpace(x.AssetName))
|
|
.GroupBy(x => x.SceneID)
|
|
.ToDictionary(
|
|
sceneGroup => sceneGroup.Key,
|
|
sceneGroup => sceneGroup
|
|
.GroupBy(x => x.StoryAssetID!.Value)
|
|
.Select(assetGroup =>
|
|
{
|
|
var first = assetGroup.First();
|
|
return new TimelineSceneAssetOverviewViewModel
|
|
{
|
|
StoryAssetID = first.StoryAssetID!.Value,
|
|
AssetName = first.AssetName ?? string.Empty,
|
|
Importance = first.AssetImportance.GetValueOrDefault(),
|
|
EventTypeName = first.AssetEventTypeName
|
|
};
|
|
})
|
|
.OrderByDescending(x => x.Importance)
|
|
.ThenBy(x => x.AssetName)
|
|
.ToList());
|
|
var locationsByScene = overviewRows
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.LocationName))
|
|
.GroupBy(x => x.SceneID)
|
|
.ToDictionary(
|
|
sceneGroup => sceneGroup.Key,
|
|
sceneGroup => sceneGroup
|
|
.Select(x => x.LocationName ?? string.Empty)
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList());
|
|
|
|
return orderedScenes.ToDictionary(scene => scene.SceneID, scene =>
|
|
{
|
|
var locations = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationPath))
|
|
{
|
|
locations.Add(scene.PrimaryLocationPath);
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(scene.PrimaryLocationName))
|
|
{
|
|
locations.Add(scene.PrimaryLocationName);
|
|
}
|
|
if (locationsByScene.TryGetValue(scene.SceneID, out var characterLocations))
|
|
{
|
|
locations.AddRange(characterLocations);
|
|
}
|
|
|
|
return new TimelineSceneOverviewViewModel
|
|
{
|
|
Characters = charactersByScene.GetValueOrDefault(scene.SceneID, []),
|
|
Assets = assetsByScene.GetValueOrDefault(scene.SceneID, []),
|
|
Locations = locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(6)
|
|
.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 StoryStateService(
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
IChapterRepository chapters,
|
|
ISceneRepository scenes,
|
|
ITimelineRepository timelineRepository,
|
|
IAssetRepository assets,
|
|
ICharacterRepository characters,
|
|
ILocationRepository locations,
|
|
IContinuityWarningAcknowledgementRepository warningAcknowledgements,
|
|
ICurrentUserService currentUser) : IStoryStateService
|
|
{
|
|
public async Task<StoryStateViewModel> GetStoryStateAsync(int sceneId)
|
|
{
|
|
var currentScene = await scenes.GetAsync(sceneId);
|
|
if (currentScene is null)
|
|
{
|
|
return new StoryStateViewModel();
|
|
}
|
|
|
|
var chapter = await chapters.GetAsync(currentScene.ChapterID);
|
|
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
|
|
if (book is null)
|
|
{
|
|
return new StoryStateViewModel();
|
|
}
|
|
|
|
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, null, false, [], false);
|
|
var orderedScenes = OrderScenes(timeline).ToList();
|
|
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
|
|
if (currentSceneIndex < 0)
|
|
{
|
|
return new StoryStateViewModel();
|
|
}
|
|
|
|
var sceneIndexes = BuildSceneIndexes(orderedScenes);
|
|
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
|
|
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
|
|
var allLocations = await locations.ListByProjectAsync(book.ProjectID);
|
|
|
|
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
|
|
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
|
|
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
|
|
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
|
|
|
|
var storyState = new StoryStateViewModel
|
|
{
|
|
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
|
|
Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID))
|
|
};
|
|
storyState.Locations = BuildLocationState(allLocations, storyState);
|
|
return storyState;
|
|
}
|
|
|
|
public async Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter)
|
|
{
|
|
NormaliseFilter(filter);
|
|
var project = await projects.GetAsync(filter.ProjectID);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var timeline = await timelineRepository.GetByProjectAsync(filter.ProjectID, null, false, [], false);
|
|
var orderedScenes = OrderScenes(timeline).ToList();
|
|
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
|
|
var allCharacters = await characters.ListCharactersAsync(filter.ProjectID);
|
|
var allAssets = await assets.ListAssetsAsync(filter.ProjectID);
|
|
var allLocations = await locations.ListByProjectAsync(filter.ProjectID);
|
|
filter.SelectedSceneID ??= scopedScenes.FirstOrDefault()?.SceneID ?? orderedScenes.FirstOrDefault()?.SceneID;
|
|
|
|
var model = new ContinuityExplorerViewModel
|
|
{
|
|
Project = project,
|
|
Filter = filter,
|
|
ShowBookContext = ShouldShowBookContext(filter, scopedScenes, timeline.Chapters),
|
|
ShowChapterContext = ShouldShowChapterContext(filter, scopedScenes, timeline.Chapters),
|
|
BookOptions = timeline.Books.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString(), x.BookID == filter.BookID)).ToList(),
|
|
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterID)).ToList(),
|
|
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber:g}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SelectedSceneID)).ToList(),
|
|
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), filter.CharacterIDs.Contains(x.CharacterID))).ToList(),
|
|
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), filter.AssetIDs.Contains(x.StoryAssetID))).ToList(),
|
|
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), filter.LocationIDs.Contains(x.LocationID))).ToList()
|
|
};
|
|
|
|
if (string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase) && filter.SelectedSceneID.HasValue)
|
|
{
|
|
model.StoryState = await GetStoryStateAsync(filter.SelectedSceneID.Value);
|
|
FilterStoryState(model.StoryState, filter);
|
|
}
|
|
else if (string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var characterIds = filter.IncludeCharacters
|
|
? (filter.CharacterIDs.Any() ? filter.CharacterIDs : allCharacters.Select(x => x.CharacterID)).ToList()
|
|
: [];
|
|
var assetIds = filter.IncludeAssets
|
|
? (filter.AssetIDs.Any() ? filter.AssetIDs : allAssets.Select(x => x.StoryAssetID)).ToList()
|
|
: [];
|
|
|
|
var characterJourney = new List<CharacterJourneyViewModel>();
|
|
foreach (var characterId in characterIds)
|
|
{
|
|
characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter));
|
|
}
|
|
|
|
var assetJourney = new List<AssetJourneyViewModel>();
|
|
foreach (var assetId in assetIds)
|
|
{
|
|
assetJourney.AddRange(await GetAssetJourneyAsync(filter.ProjectID, assetId, filter));
|
|
}
|
|
|
|
model.CharacterJourney = characterJourney;
|
|
model.AssetJourney = assetJourney;
|
|
}
|
|
else if (string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var locationIds = filter.IncludeLocations
|
|
? (filter.LocationIDs.Any() ? filter.LocationIDs : allLocations.Select(x => x.LocationID)).ToList()
|
|
: [];
|
|
|
|
var activity = new List<LocationActivityViewModel>();
|
|
foreach (var locationId in locationIds)
|
|
{
|
|
activity.AddRange(await GetLocationActivityAsync(filter.ProjectID, locationId, filter));
|
|
}
|
|
|
|
model.LocationActivity = activity
|
|
.OrderBy(x => scopedScenes.FindIndex(scene => scene.SceneID == x.SceneID))
|
|
.ThenBy(x => x.LocationName)
|
|
.ToList();
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
public async Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter)
|
|
{
|
|
var context = await GetRangeContextAsync(projectId, filter);
|
|
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
|
var character = characterTimeline.Characters.FirstOrDefault(x => x.CharacterID == characterId);
|
|
if (character is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var rows = characterTimeline.Appearances
|
|
.Where(x => x.CharacterID == characterId && x.LocationID.HasValue && context.AllowedSceneIds.Contains(x.SceneID))
|
|
.GroupBy(x => x.SceneID)
|
|
.Select(x => x.OrderByDescending(row => row.UpdatedDate).First())
|
|
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ToList();
|
|
var result = new List<CharacterJourneyViewModel>();
|
|
int? previousLocationId = null;
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
if (row.LocationID == previousLocationId)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
previousLocationId = row.LocationID;
|
|
var sceneContext = context.SceneContexts.GetValueOrDefault(row.SceneID);
|
|
result.Add(new CharacterJourneyViewModel
|
|
{
|
|
CharacterID = character.CharacterID,
|
|
CharacterName = character.CharacterName,
|
|
BookID = sceneContext?.BookID ?? 0,
|
|
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
|
|
ChapterID = sceneContext?.ChapterID ?? 0,
|
|
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
|
|
SceneID = row.SceneID,
|
|
SceneTitle = sceneContext?.SceneTitle ?? string.Empty,
|
|
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
|
|
SceneDisplayName = sceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
|
|
Location = row.LocationPath ?? row.LocationName ?? "Unknown"
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter)
|
|
{
|
|
var context = await GetRangeContextAsync(projectId, filter);
|
|
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
|
var asset = assetTimeline.Assets.FirstOrDefault(x => x.StoryAssetID == assetId);
|
|
if (asset is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
|
|
var assetCustody = await assets.ListCustodyByAssetAsync(assetId);
|
|
var assignments = BuildAssetAssignments(assetTimeline.Events, assetLocations, assetCustody, context.AllowedSceneIds);
|
|
var rows = assignments
|
|
.Where(x => x.StoryAssetID == assetId)
|
|
.GroupBy(x => x.SceneID)
|
|
.Select(x => x.OrderByDescending(row => row.Priority).ThenByDescending(row => row.UpdatedDate).First())
|
|
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ToList();
|
|
var result = new List<AssetJourneyViewModel>();
|
|
string? previousLabel = null;
|
|
|
|
foreach (var row in rows)
|
|
{
|
|
if (string.Equals(previousLabel, row.Label, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
previousLabel = row.Label;
|
|
var sceneContext = context.SceneContexts.GetValueOrDefault(row.SceneID);
|
|
result.Add(new AssetJourneyViewModel
|
|
{
|
|
StoryAssetID = asset.StoryAssetID,
|
|
AssetName = asset.AssetName,
|
|
BookID = sceneContext?.BookID ?? 0,
|
|
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
|
|
ChapterID = sceneContext?.ChapterID ?? 0,
|
|
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
|
|
SceneID = row.SceneID,
|
|
SceneTitle = sceneContext?.SceneTitle ?? string.Empty,
|
|
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
|
|
SceneDisplayName = sceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
|
|
HolderOrLocation = row.Label
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter)
|
|
{
|
|
var context = await GetRangeContextAsync(projectId, filter);
|
|
var allLocations = await locations.ListByProjectAsync(projectId);
|
|
var location = allLocations.FirstOrDefault(x => x.LocationID == locationId);
|
|
if (location is null)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
|
var maxScopedSceneIndex = context.ScopedScenes
|
|
.Select(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1))
|
|
.DefaultIfEmpty(-1)
|
|
.Max();
|
|
var scenesThroughRange = context.OrderedScenes
|
|
.Where(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1) <= maxScopedSceneIndex)
|
|
.ToList();
|
|
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
|
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughRange);
|
|
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
|
|
var characterIds = filter.CharacterIDs.ToHashSet();
|
|
var assetIds = filter.AssetIDs.ToHashSet();
|
|
var characterAssignments = characterTimeline.Appearances
|
|
.Where(x => x.LocationID.HasValue)
|
|
.GroupBy(x => x.CharacterID)
|
|
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
|
|
var assetAssignments = BuildAssetLocationAssignments(assetLocations, custodyCharacters)
|
|
.GroupBy(x => x.StoryAssetID)
|
|
.ToDictionary(x => x.Key, x => x.OrderBy(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, -1)).ThenBy(row => row.UpdatedDate).ToList());
|
|
var result = new List<LocationActivityViewModel>();
|
|
|
|
foreach (var scene in context.ScopedScenes)
|
|
{
|
|
var sceneIndex = context.SceneIndexes.GetValueOrDefault(scene.SceneID, -1);
|
|
var sceneCharacters = filter.IncludeCharacters
|
|
? characterTimeline.Characters
|
|
.Where(character => !characterIds.Any() || characterIds.Contains(character.CharacterID))
|
|
.Select(character => BuildCurrentCharacterPresence(character, characterAssignments.GetValueOrDefault(character.CharacterID, []), locationId, scene.SceneID, sceneIndex, context))
|
|
.Where(x => x is not null)
|
|
.Select(x => x!)
|
|
.OrderBy(x => x.EntityName)
|
|
.ToList()
|
|
: [];
|
|
var sceneAssets = filter.IncludeAssets
|
|
? assetTimeline.Assets
|
|
.Where(asset => !assetIds.Any() || assetIds.Contains(asset.StoryAssetID))
|
|
.Select(asset => BuildCurrentAssetPresence(asset, assetAssignments.GetValueOrDefault(asset.StoryAssetID, []), characterAssignments, locationId, scene.SceneID, sceneIndex, context))
|
|
.Where(x => x is not null)
|
|
.Select(x => x!)
|
|
.OrderBy(x => x.EntityName)
|
|
.ToList()
|
|
: [];
|
|
|
|
if (!sceneCharacters.Any() && !sceneAssets.Any())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var sceneContext = context.SceneContexts.GetValueOrDefault(scene.SceneID);
|
|
result.Add(new LocationActivityViewModel
|
|
{
|
|
LocationID = location.LocationID,
|
|
LocationName = location.LocationPath,
|
|
BookID = sceneContext?.BookID ?? 0,
|
|
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
|
|
ChapterID = sceneContext?.ChapterID ?? 0,
|
|
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty,
|
|
SceneID = scene.SceneID,
|
|
SceneTitle = sceneContext?.SceneTitle ?? scene.SceneTitle,
|
|
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
|
|
SceneDisplayName = sceneContext?.SceneDisplayName ?? SceneLabel(scene.SceneNumber, scene.SceneTitle),
|
|
Characters = sceneCharacters,
|
|
Assets = sceneAssets,
|
|
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters.Select(x => x.EntityName)) : "None",
|
|
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets.Select(x => x.EntityName)) : "None"
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<List<ContinuityWarningViewModel>> GetContinuityWarningsAsync(int projectId, ContinuityFilter filter)
|
|
{
|
|
var context = await GetRangeContextAsync(projectId, filter);
|
|
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
|
|
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
|
|
var allAssetLocations = await GetAssetLocationsThroughCurrentAsync(context.OrderedScenes);
|
|
var custodyCharacters = await assets.ListCustodyCharactersByProjectAsync(projectId);
|
|
var enabledCharacterIds = filter.CharacterIDs.ToHashSet();
|
|
var enabledAssetIds = filter.AssetIDs.ToHashSet();
|
|
var warnings = new List<ContinuityWarningViewModel>();
|
|
|
|
if (filter.IncludeCharacters)
|
|
{
|
|
warnings.AddRange(BuildMultipleCharacterLocationWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
|
|
warnings.AddRange(BuildImpossibleMovementWarnings(characterTimeline.Appearances, enabledCharacterIds, context));
|
|
warnings.AddRange(BuildUnknownCharacterLocationWarnings(characterTimeline.Characters, characterTimeline.Appearances, enabledCharacterIds, context));
|
|
warnings.AddRange(await BuildKnowledgeRegressionWarningsAsync(characterTimeline.Characters, enabledCharacterIds, context));
|
|
}
|
|
|
|
if (filter.IncludeAssets)
|
|
{
|
|
var scopedAssetLocations = allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
|
|
var scopedCustody = custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).ToList();
|
|
warnings.AddRange(BuildAssetLocationConflictWarnings(assetTimeline.Assets, scopedAssetLocations, scopedCustody, enabledAssetIds, context));
|
|
warnings.AddRange(BuildUnknownAssetLocationWarnings(assetTimeline.Assets, assetTimeline.Events, allAssetLocations, custodyCharacters, enabledAssetIds, context));
|
|
}
|
|
|
|
var acknowledgements = await GetAcknowledgementsAsync(projectId);
|
|
ApplyAcknowledgements(warnings, acknowledgements);
|
|
|
|
return warnings
|
|
.Where(x => filter.ShowAcknowledgedWarnings || !x.IsAcknowledged)
|
|
.OrderBy(x => WarningSeveritySort(x.Severity))
|
|
.ThenBy(x => x.BookDisplayName)
|
|
.ThenBy(x => x.ChapterDisplayName)
|
|
.ThenBy(x => x.SceneID ?? int.MaxValue)
|
|
.ThenBy(x => x.WarningTypeDisplayName)
|
|
.ThenBy(x => x.Message)
|
|
.ToList();
|
|
}
|
|
|
|
public async Task AcknowledgeWarningAsync(ContinuityWarningAcknowledgement acknowledgement)
|
|
{
|
|
acknowledgement.WarningType = acknowledgement.WarningType.Trim();
|
|
acknowledgement.Notes = string.IsNullOrWhiteSpace(acknowledgement.Notes) ? null : acknowledgement.Notes.Trim();
|
|
acknowledgement.AcknowledgedByUserID = currentUser.UserId;
|
|
await warningAcknowledgements.SaveAsync(acknowledgement);
|
|
}
|
|
|
|
public Task RestoreWarningAsync(ContinuityWarningAcknowledgement acknowledgement)
|
|
{
|
|
return warningAcknowledgements.DeleteAsync(
|
|
acknowledgement.ProjectID,
|
|
acknowledgement.WarningType.Trim(),
|
|
acknowledgement.SceneID,
|
|
acknowledgement.CharacterID,
|
|
acknowledgement.AssetID);
|
|
}
|
|
|
|
public async Task<bool> IsAcknowledgedAsync(int projectId, string warningType, int? sceneId, int? characterId, int? assetId)
|
|
{
|
|
var acknowledgement = await warningAcknowledgements.GetAsync(projectId, warningType, sceneId, characterId, assetId);
|
|
return acknowledgement is not null;
|
|
}
|
|
|
|
public async Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId)
|
|
{
|
|
return (await warningAcknowledgements.ListByProjectAsync(projectId)).ToList();
|
|
}
|
|
|
|
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
|
|
{
|
|
NormaliseFilter(filter);
|
|
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
|
|
var orderedScenes = OrderScenes(timeline).ToList();
|
|
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
|
|
return new RangeContext(
|
|
orderedScenes,
|
|
scopedScenes,
|
|
scopedScenes.Select(x => x.SceneID).ToHashSet(),
|
|
BuildSceneIndexes(orderedScenes),
|
|
orderedScenes.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)),
|
|
BuildSceneContexts(timeline));
|
|
}
|
|
|
|
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
|
|
{
|
|
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
|
|
var bookSortOrders = timeline.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
|
|
var chapterSortOrders = timeline.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
|
|
|
|
return timeline.Scenes
|
|
.OrderBy(scene => bookSortOrders.GetValueOrDefault(chaptersById.GetValueOrDefault(scene.ChapterID)?.BookID ?? 0))
|
|
.ThenBy(scene => chapterSortOrders.GetValueOrDefault(scene.ChapterID))
|
|
.ThenBy(scene => scene.SortOrder)
|
|
.ThenBy(scene => scene.SceneNumber)
|
|
.ThenBy(scene => scene.SceneID);
|
|
}
|
|
|
|
private static IEnumerable<Scene> ApplySceneRange(IReadOnlyList<Scene> orderedScenes, IReadOnlyList<Chapter> chapters, ContinuityFilter filter)
|
|
{
|
|
if (string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase) && filter.BookID.HasValue)
|
|
{
|
|
var chapterIds = chapters.Where(x => x.BookID == filter.BookID.Value).Select(x => x.ChapterID).ToHashSet();
|
|
return orderedScenes.Where(x => chapterIds.Contains(x.ChapterID));
|
|
}
|
|
|
|
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase) && filter.ChapterID.HasValue)
|
|
{
|
|
return orderedScenes.Where(x => x.ChapterID == filter.ChapterID.Value);
|
|
}
|
|
|
|
if (string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase) && (filter.StartSceneID.HasValue || filter.EndSceneID.HasValue))
|
|
{
|
|
var startIndex = filter.StartSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.StartSceneID.Value) : 0;
|
|
var endIndex = filter.EndSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.EndSceneID.Value) : orderedScenes.Count - 1;
|
|
startIndex = startIndex < 0 ? 0 : startIndex;
|
|
endIndex = endIndex < 0 ? orderedScenes.Count - 1 : endIndex;
|
|
if (startIndex > endIndex)
|
|
{
|
|
(startIndex, endIndex) = (endIndex, startIndex);
|
|
}
|
|
|
|
return orderedScenes.Skip(startIndex).Take(endIndex - startIndex + 1);
|
|
}
|
|
|
|
return orderedScenes;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
|
|
{
|
|
var rows = new List<SceneAssetLocation>();
|
|
foreach (var scene in scenesThroughCurrent)
|
|
{
|
|
rows.AddRange(await locations.ListSceneAssetLocationsAsync(scene.SceneID));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<AssetCustodyEvent>> GetAssetCustodyThroughCurrentAsync(IReadOnlyList<StoryAsset> projectAssets)
|
|
{
|
|
var rows = new List<AssetCustodyEvent>();
|
|
foreach (var asset in projectAssets)
|
|
{
|
|
rows.AddRange(await assets.ListCustodyByAssetAsync(asset.StoryAssetID));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private static List<CharacterStoryStateViewModel> BuildCharacterState(
|
|
IReadOnlyList<Character> projectCharacters,
|
|
IReadOnlyList<SceneCharacter> appearances,
|
|
int currentSceneId,
|
|
IReadOnlySet<int> allowedSceneIds,
|
|
IReadOnlyDictionary<int, int> sceneIndexes)
|
|
{
|
|
var appearancesWithLocations = appearances
|
|
.Where(x => allowedSceneIds.Contains(x.SceneID) && x.LocationID.HasValue)
|
|
.ToList();
|
|
|
|
return projectCharacters
|
|
.OrderByDescending(x => x.CharacterImportance ?? 0)
|
|
.ThenBy(x => x.CharacterName)
|
|
.Select(character =>
|
|
{
|
|
var latest = appearancesWithLocations
|
|
.Where(x => x.CharacterID == character.CharacterID)
|
|
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ThenByDescending(x => x.UpdatedDate)
|
|
.FirstOrDefault();
|
|
|
|
if (latest is null)
|
|
{
|
|
return new CharacterStoryStateViewModel
|
|
{
|
|
CharacterID = character.CharacterID,
|
|
CharacterName = character.CharacterName
|
|
};
|
|
}
|
|
|
|
return new CharacterStoryStateViewModel
|
|
{
|
|
CharacterID = character.CharacterID,
|
|
CharacterName = character.CharacterName,
|
|
LocationID = latest.LocationID,
|
|
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
|
|
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
|
LastConfirmedSceneID = latest.SceneID,
|
|
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static List<AssetStoryStateViewModel> BuildAssetState(
|
|
IReadOnlyList<StoryAsset> projectAssets,
|
|
IReadOnlyList<AssetEvent> assetEvents,
|
|
IReadOnlyList<SceneAssetLocation> assetLocations,
|
|
IReadOnlyList<AssetCustodyEvent> assetCustody,
|
|
int currentSceneId,
|
|
IReadOnlySet<int> allowedSceneIds,
|
|
IReadOnlyDictionary<int, int> sceneIndexes,
|
|
IReadOnlyDictionary<int, Scene> scenesById)
|
|
{
|
|
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
|
|
|
|
return projectAssets
|
|
.OrderByDescending(x => x.Importance)
|
|
.ThenBy(x => x.AssetName)
|
|
.Select(asset =>
|
|
{
|
|
var latest = assignments
|
|
.Where(x => x.StoryAssetID == asset.StoryAssetID)
|
|
.OrderByDescending(x => sceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ThenByDescending(x => x.Priority)
|
|
.ThenByDescending(x => x.UpdatedDate)
|
|
.FirstOrDefault();
|
|
|
|
if (latest is null)
|
|
{
|
|
return new AssetStoryStateViewModel
|
|
{
|
|
StoryAssetID = asset.StoryAssetID,
|
|
AssetName = asset.AssetName
|
|
};
|
|
}
|
|
|
|
return new AssetStoryStateViewModel
|
|
{
|
|
StoryAssetID = asset.StoryAssetID,
|
|
AssetName = asset.AssetName,
|
|
LocationID = latest.LocationID,
|
|
CurrentHolderOrLocation = latest.Label,
|
|
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
|
LastConfirmedSceneID = latest.SceneID,
|
|
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
|
|
? SceneLabel(scene.SceneNumber, scene.SceneTitle)
|
|
: "Unknown"
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static List<AssetStateAssignment> BuildAssetAssignments(
|
|
IReadOnlyList<AssetEvent> assetEvents,
|
|
IReadOnlyList<SceneAssetLocation> assetLocations,
|
|
IReadOnlyList<AssetCustodyEvent> assetCustody,
|
|
IReadOnlySet<int> allowedSceneIds)
|
|
{
|
|
var stateAssignments = assetEvents
|
|
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
|
|
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.ToStateName!, 1, x.UpdatedDate));
|
|
var locationAssignments = assetLocations
|
|
.Where(x => allowedSceneIds.Contains(x.SceneID))
|
|
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationID, x.LocationPath, 2, x.UpdatedDate));
|
|
var custodyAssignments = assetCustody
|
|
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
|
|
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.CustodianSummary!, 3, x.UpdatedDate));
|
|
|
|
return stateAssignments.Concat(locationAssignments).Concat(custodyAssignments).ToList();
|
|
}
|
|
|
|
private static List<LocationStoryStateViewModel> BuildLocationState(IReadOnlyList<LocationItem> allLocations, StoryStateViewModel storyState)
|
|
{
|
|
return allLocations
|
|
.OrderBy(x => x.LocationPath)
|
|
.Select(location => new LocationStoryStateViewModel
|
|
{
|
|
LocationID = location.LocationID,
|
|
LocationName = location.LocationName,
|
|
LocationPath = location.LocationPath,
|
|
CharactersPresent = storyState.Characters
|
|
.Where(x => x.LocationID == location.LocationID)
|
|
.Select(x => x.CharacterName)
|
|
.OrderBy(x => x)
|
|
.ToList(),
|
|
AssetsPresent = storyState.Assets
|
|
.Where(x => x.LocationID == location.LocationID)
|
|
.Select(x => x.AssetName)
|
|
.OrderBy(x => x)
|
|
.ToList()
|
|
})
|
|
.Where(x => x.CharactersPresent.Any() || x.AssetsPresent.Any())
|
|
.ToList();
|
|
}
|
|
|
|
private static List<ContinuityWarningViewModel> BuildMultipleCharacterLocationWarnings(
|
|
IReadOnlyList<SceneCharacter> appearances,
|
|
IReadOnlySet<int> enabledCharacterIds,
|
|
RangeContext context)
|
|
{
|
|
return appearances
|
|
.Where(x => context.AllowedSceneIds.Contains(x.SceneID)
|
|
&& x.LocationID.HasValue
|
|
&& (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(x.CharacterID)))
|
|
.GroupBy(x => new { x.SceneID, x.CharacterID, x.CharacterName })
|
|
.Where(group => group.Select(x => x.LocationID).Distinct().Count() > 1)
|
|
.Select(group => BuildWarning(
|
|
"Multiple Character Locations",
|
|
"Critical",
|
|
$"{group.Key.CharacterName} appears in multiple locations during {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
|
group.Key.SceneID,
|
|
context,
|
|
characterId: group.Key.CharacterID))
|
|
.ToList();
|
|
}
|
|
|
|
private static List<ContinuityWarningViewModel> BuildImpossibleMovementWarnings(
|
|
IReadOnlyList<SceneCharacter> appearances,
|
|
IReadOnlySet<int> enabledCharacterIds,
|
|
RangeContext context)
|
|
{
|
|
// Disabled for now: consecutive-scene location changes are often ordinary story movement.
|
|
// Re-enable when this warning can use scene dates/times, overlapping scene spans,
|
|
// location distance, or author-configurable movement rules.
|
|
return [];
|
|
}
|
|
|
|
private static List<ContinuityWarningViewModel> BuildUnknownCharacterLocationWarnings(
|
|
IReadOnlyList<Character> projectCharacters,
|
|
IReadOnlyList<SceneCharacter> appearances,
|
|
IReadOnlySet<int> enabledCharacterIds,
|
|
RangeContext context)
|
|
{
|
|
var appearancesByCharacter = appearances
|
|
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
|
|
.GroupBy(x => x.CharacterID)
|
|
.ToDictionary(x => x.Key, x => x.ToList());
|
|
var locationCharacterIds = appearances
|
|
.Where(x => x.LocationID.HasValue)
|
|
.Select(x => x.CharacterID)
|
|
.ToHashSet();
|
|
|
|
return projectCharacters
|
|
.Where(character => (!enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
|
|
&& appearancesByCharacter.ContainsKey(character.CharacterID)
|
|
&& !locationCharacterIds.Contains(character.CharacterID))
|
|
.Select(character =>
|
|
{
|
|
var firstSceneId = appearancesByCharacter[character.CharacterID]
|
|
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.First()
|
|
.SceneID;
|
|
return BuildWarning(
|
|
"Characters With Unknown Locations",
|
|
"Information",
|
|
$"{character.CharacterName} has no known location.",
|
|
firstSceneId,
|
|
context,
|
|
characterId: character.CharacterID);
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static List<ContinuityWarningViewModel> BuildAssetLocationConflictWarnings(
|
|
IReadOnlyList<StoryAsset> projectAssets,
|
|
IReadOnlyList<SceneAssetLocation> sceneAssetLocations,
|
|
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
|
|
IReadOnlySet<int> enabledAssetIds,
|
|
RangeContext context)
|
|
{
|
|
var assetNames = projectAssets.ToDictionary(x => x.StoryAssetID, x => x.AssetName);
|
|
var directLocationGroups = sceneAssetLocations
|
|
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
|
|
.GroupBy(x => new { x.SceneID, x.StoryAssetID });
|
|
var custodyGroups = custodyCharacters
|
|
.Where(x => !enabledAssetIds.Any() || enabledAssetIds.Contains(x.StoryAssetID))
|
|
.GroupBy(x => new { x.SceneID, x.StoryAssetID })
|
|
.ToDictionary(x => x.Key, x => x.ToList());
|
|
var warnings = new List<ContinuityWarningViewModel>();
|
|
|
|
foreach (var group in directLocationGroups)
|
|
{
|
|
var directLocationCount = group.Select(x => x.LocationID).Distinct().Count();
|
|
if (directLocationCount <= 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
warnings.Add(BuildWarning(
|
|
"Asset Location Conflict",
|
|
"Critical",
|
|
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit locations in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
|
group.Key.SceneID,
|
|
context,
|
|
assetId: group.Key.StoryAssetID));
|
|
}
|
|
|
|
foreach (var group in custodyGroups.Where(x => !directLocationGroups.Any(locationGroup => locationGroup.Key.SceneID == x.Key.SceneID && locationGroup.Key.StoryAssetID == x.Key.StoryAssetID)))
|
|
{
|
|
var holderCount = group.Value.Select(x => x.CharacterID?.ToString() ?? x.CharacterNameText ?? string.Empty).Where(x => x.Length > 0).Distinct().Count();
|
|
if (holderCount <= 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
warnings.Add(BuildWarning(
|
|
"Asset Location Conflict",
|
|
"Critical",
|
|
$"{assetNames.GetValueOrDefault(group.Key.StoryAssetID, "An asset")} has multiple explicit holders in {context.SceneLabels.GetValueOrDefault(group.Key.SceneID, "this scene")}.",
|
|
group.Key.SceneID,
|
|
context,
|
|
assetId: group.Key.StoryAssetID));
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
private static List<ContinuityWarningViewModel> BuildUnknownAssetLocationWarnings(
|
|
IReadOnlyList<StoryAsset> projectAssets,
|
|
IReadOnlyList<AssetEvent> assetEvents,
|
|
IReadOnlyList<SceneAssetLocation> allAssetLocations,
|
|
IReadOnlyList<AssetCustodyCharacter> custodyCharacters,
|
|
IReadOnlySet<int> enabledAssetIds,
|
|
RangeContext context)
|
|
{
|
|
var appearedAssetSceneIds = assetEvents
|
|
.Where(x => context.AllowedSceneIds.Contains(x.SceneID))
|
|
.Select(x => new { x.StoryAssetID, x.SceneID })
|
|
.Concat(allAssetLocations.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
|
|
.Concat(custodyCharacters.Where(x => context.AllowedSceneIds.Contains(x.SceneID)).Select(x => new { x.StoryAssetID, x.SceneID }))
|
|
.GroupBy(x => x.StoryAssetID)
|
|
.ToDictionary(x => x.Key, x => x.Min(row => context.SceneIndexes.GetValueOrDefault(row.SceneID, int.MaxValue)));
|
|
var locatedAssetIds = allAssetLocations.Select(x => x.StoryAssetID)
|
|
.Concat(custodyCharacters.Select(x => x.StoryAssetID))
|
|
.ToHashSet();
|
|
|
|
return projectAssets
|
|
.Where(asset => (!enabledAssetIds.Any() || enabledAssetIds.Contains(asset.StoryAssetID))
|
|
&& appearedAssetSceneIds.ContainsKey(asset.StoryAssetID)
|
|
&& !locatedAssetIds.Contains(asset.StoryAssetID))
|
|
.Select(asset =>
|
|
{
|
|
var firstSceneId = context.OrderedScenes.FirstOrDefault(scene => context.SceneIndexes.GetValueOrDefault(scene.SceneID, int.MaxValue) == appearedAssetSceneIds[asset.StoryAssetID])?.SceneID;
|
|
return BuildWarning(
|
|
"Assets With Unknown Locations",
|
|
"Information",
|
|
$"{asset.AssetName} has no known location.",
|
|
firstSceneId,
|
|
context,
|
|
assetId: asset.StoryAssetID);
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<List<ContinuityWarningViewModel>> BuildKnowledgeRegressionWarningsAsync(
|
|
IReadOnlyList<Character> projectCharacters,
|
|
IReadOnlySet<int> enabledCharacterIds,
|
|
RangeContext context)
|
|
{
|
|
var warnings = new List<ContinuityWarningViewModel>();
|
|
var charactersToCheck = projectCharacters
|
|
.Where(character => !enabledCharacterIds.Any() || enabledCharacterIds.Contains(character.CharacterID))
|
|
.ToList();
|
|
|
|
foreach (var character in charactersToCheck)
|
|
{
|
|
var knowledgeEvents = (await characters.ListKnowledgeByCharacterAsync(character.CharacterID))
|
|
.Where(x => context.SceneIndexes.ContainsKey(x.SceneID))
|
|
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, int.MaxValue))
|
|
.ThenBy(x => x.CreatedDate)
|
|
.ThenBy(x => x.CharacterKnowledgeID)
|
|
.ToList();
|
|
|
|
foreach (var group in knowledgeEvents.GroupBy(KnowledgeIdentity))
|
|
{
|
|
var progressionEvents = group
|
|
.Select(x => new { Item = x, Rank = KnowledgeStateRank(x.KnowledgeStateName) })
|
|
.Where(x => x.Rank.HasValue)
|
|
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.Item.SceneID, int.MaxValue))
|
|
.ThenBy(x => x.Item.CreatedDate)
|
|
.ThenBy(x => x.Item.CharacterKnowledgeID)
|
|
.ToList();
|
|
|
|
for (var i = 1; i < progressionEvents.Count; i++)
|
|
{
|
|
var previous = progressionEvents[i - 1];
|
|
var current = progressionEvents[i];
|
|
if (current.Rank!.Value >= previous.Rank!.Value || !context.AllowedSceneIds.Contains(current.Item.SceneID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var knowledgeDescription = KnowledgeTitle(current.Item);
|
|
var warning = BuildWarning(
|
|
$"KnowledgeRegression|Knowledge:{NormaliseWarningKeyPart(knowledgeDescription)}",
|
|
"Warning",
|
|
KnowledgeRegressionMessage(character.CharacterName, knowledgeDescription, previous.Item.KnowledgeStateName, current.Item.KnowledgeStateName),
|
|
current.Item.SceneID,
|
|
context,
|
|
characterId: character.CharacterID);
|
|
|
|
warning.WarningTypeDisplayName = "Knowledge Regression";
|
|
warning.CharacterName = character.CharacterName;
|
|
warning.KnowledgeId = current.Item.CharacterKnowledgeID;
|
|
warning.KnowledgeTitle = knowledgeDescription;
|
|
warning.KnowledgeDescription = knowledgeDescription;
|
|
warning.PreviousKnowledgeState = previous.Item.KnowledgeStateName;
|
|
warning.CurrentKnowledgeState = current.Item.KnowledgeStateName;
|
|
var previousSceneContext = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID);
|
|
var currentSceneContext = context.SceneContexts.GetValueOrDefault(current.Item.SceneID);
|
|
warning.PreviousBookId = previousSceneContext?.BookID;
|
|
warning.PreviousChapterId = previousSceneContext?.ChapterID;
|
|
warning.PreviousSceneId = previous.Item.SceneID;
|
|
warning.PreviousSceneDisplayName = previousSceneContext?.SceneDisplayName
|
|
?? context.SceneLabels.GetValueOrDefault(previous.Item.SceneID, SceneLabel(previous.Item.SceneNumber, previous.Item.SceneTitle));
|
|
warning.CurrentBookId = currentSceneContext?.BookID;
|
|
warning.CurrentChapterId = currentSceneContext?.ChapterID;
|
|
warning.CurrentSceneId = current.Item.SceneID;
|
|
warning.CurrentSceneDisplayName = currentSceneContext?.SceneDisplayName
|
|
?? context.SceneLabels.GetValueOrDefault(current.Item.SceneID, SceneLabel(current.Item.SceneNumber, current.Item.SceneTitle));
|
|
warnings.Add(warning);
|
|
}
|
|
}
|
|
}
|
|
|
|
return warnings
|
|
.GroupBy(x => new { x.CharacterID, x.KnowledgeDescription, x.CurrentSceneId })
|
|
.Select(x => x.First())
|
|
.ToList();
|
|
}
|
|
|
|
private static LocationActivityEntityViewModel? BuildCurrentCharacterPresence(
|
|
Character character,
|
|
IReadOnlyList<SceneCharacter> assignments,
|
|
int locationId,
|
|
int currentSceneId,
|
|
int currentSceneIndex,
|
|
RangeContext context)
|
|
{
|
|
var latest = assignments
|
|
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
|
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ThenByDescending(x => x.UpdatedDate)
|
|
.FirstOrDefault();
|
|
|
|
if (latest?.LocationID != locationId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new LocationActivityEntityViewModel
|
|
{
|
|
EntityID = character.CharacterID,
|
|
EntityName = character.CharacterName,
|
|
EntityType = "Character",
|
|
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
|
LastConfirmedSceneID = latest.SceneID,
|
|
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, SceneLabel(latest.SceneNumber, latest.SceneTitle))
|
|
};
|
|
}
|
|
|
|
private static LocationActivityEntityViewModel? BuildCurrentAssetPresence(
|
|
StoryAsset asset,
|
|
IReadOnlyList<AssetLocationActivityAssignment> assignments,
|
|
IReadOnlyDictionary<int, List<SceneCharacter>> characterAssignments,
|
|
int locationId,
|
|
int currentSceneId,
|
|
int currentSceneIndex,
|
|
RangeContext context)
|
|
{
|
|
var latest = assignments
|
|
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
|
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ThenByDescending(x => x.Priority)
|
|
.ThenByDescending(x => x.UpdatedDate)
|
|
.FirstOrDefault();
|
|
|
|
if (latest is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var note = latest.Note;
|
|
if (latest.LocationID != locationId)
|
|
{
|
|
if (!latest.HolderCharacterIDs.Any())
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var holderInLocation = latest.HolderCharacterIDs
|
|
.Select(holderId => ResolveCharacterLocationAtScene(characterAssignments.GetValueOrDefault(holderId, []), currentSceneIndex, context))
|
|
.FirstOrDefault(x => x?.LocationID == locationId);
|
|
|
|
if (holderInLocation is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
note = string.IsNullOrWhiteSpace(holderInLocation.CharacterName)
|
|
? latest.Note
|
|
: $"Via {holderInLocation.CharacterName}";
|
|
}
|
|
|
|
return new LocationActivityEntityViewModel
|
|
{
|
|
EntityID = asset.StoryAssetID,
|
|
EntityName = asset.AssetName,
|
|
EntityType = "Asset",
|
|
StateType = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
|
|
LastConfirmedSceneID = latest.SceneID,
|
|
LastConfirmedSceneDisplayName = context.SceneLabels.GetValueOrDefault(latest.SceneID, "Unknown scene"),
|
|
Note = note
|
|
};
|
|
}
|
|
|
|
private static SceneCharacter? ResolveCharacterLocationAtScene(
|
|
IReadOnlyList<SceneCharacter> assignments,
|
|
int currentSceneIndex,
|
|
RangeContext context)
|
|
{
|
|
return assignments
|
|
.Where(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1) <= currentSceneIndex)
|
|
.OrderByDescending(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
|
|
.ThenByDescending(x => x.UpdatedDate)
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private static List<AssetLocationActivityAssignment> BuildAssetLocationAssignments(
|
|
IReadOnlyList<SceneAssetLocation> assetLocations,
|
|
IReadOnlyList<AssetCustodyCharacter> custodyCharacters)
|
|
{
|
|
var locationAssignments = assetLocations.Select(x => new AssetLocationActivityAssignment(
|
|
x.StoryAssetID,
|
|
x.SceneID,
|
|
x.LocationID,
|
|
[],
|
|
x.LocationPath,
|
|
2,
|
|
x.UpdatedDate,
|
|
null));
|
|
var custodyAssignments = custodyCharacters
|
|
.GroupBy(x => new { x.AssetCustodyEventID, x.StoryAssetID, x.SceneID })
|
|
.Select(group =>
|
|
{
|
|
var first = group.First();
|
|
var holderIds = group
|
|
.Select(x => x.CharacterID)
|
|
.Where(x => x.HasValue)
|
|
.Select(x => x!.Value)
|
|
.Distinct()
|
|
.ToList();
|
|
var holderNames = group
|
|
.Select(x => x.CharacterName ?? x.CharacterNameText)
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return new AssetLocationActivityAssignment(
|
|
group.Key.StoryAssetID,
|
|
group.Key.SceneID,
|
|
null,
|
|
holderIds,
|
|
string.Join(", ", holderNames),
|
|
3,
|
|
first.UpdatedDate,
|
|
holderIds.Any() ? null : "Named holder has no known character location");
|
|
});
|
|
|
|
return locationAssignments.Concat(custodyAssignments).ToList();
|
|
}
|
|
|
|
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
|
|
{
|
|
storyState.Characters = filter.IncludeCharacters
|
|
? storyState.Characters.Where(x => !filter.CharacterIDs.Any() || filter.CharacterIDs.Contains(x.CharacterID)).ToList()
|
|
: [];
|
|
storyState.Assets = filter.IncludeAssets
|
|
? storyState.Assets.Where(x => !filter.AssetIDs.Any() || filter.AssetIDs.Contains(x.StoryAssetID)).ToList()
|
|
: [];
|
|
storyState.Locations = filter.IncludeLocations
|
|
? storyState.Locations.Where(x => !filter.LocationIDs.Any() || filter.LocationIDs.Contains(x.LocationID)).ToList()
|
|
: [];
|
|
}
|
|
|
|
private static ContinuityWarningViewModel BuildWarning(
|
|
string warningType,
|
|
string severity,
|
|
string message,
|
|
int? sceneId,
|
|
RangeContext context,
|
|
int? characterId = null,
|
|
int? assetId = null)
|
|
{
|
|
var sceneContext = sceneId.HasValue ? context.SceneContexts.GetValueOrDefault(sceneId.Value) : null;
|
|
return new ContinuityWarningViewModel
|
|
{
|
|
WarningKey = BuildWarningKey(warningType, sceneId, characterId, assetId),
|
|
WarningType = warningType,
|
|
WarningTypeDisplayName = warningType,
|
|
Severity = severity,
|
|
Message = message,
|
|
CharacterID = characterId,
|
|
AssetID = assetId,
|
|
BookID = sceneContext?.BookID,
|
|
ChapterID = sceneContext?.ChapterID,
|
|
SceneID = sceneId,
|
|
SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty),
|
|
BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty,
|
|
ChapterDisplayName = sceneContext?.ChapterDisplayName ?? string.Empty
|
|
};
|
|
}
|
|
|
|
private static string KnowledgeIdentity(CharacterKnowledgeItem item)
|
|
{
|
|
if (item.StoryAssetID.HasValue)
|
|
{
|
|
return $"Asset:{item.StoryAssetID.Value}";
|
|
}
|
|
|
|
if (item.PlotThreadID.HasValue)
|
|
{
|
|
return $"Thread:{item.PlotThreadID.Value}";
|
|
}
|
|
|
|
return $"Description:{(item.Description ?? string.Empty).Trim().ToUpperInvariant()}";
|
|
}
|
|
|
|
private static string KnowledgeTitle(CharacterKnowledgeItem item)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(item.AssetName))
|
|
{
|
|
return item.AssetName;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(item.ThreadTitle))
|
|
{
|
|
return item.ThreadTitle;
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(item.Description) ? "Knowledge" : item.Description.Trim();
|
|
}
|
|
|
|
private static int? KnowledgeStateRank(string stateName)
|
|
{
|
|
return stateName switch
|
|
{
|
|
"Unaware" => 0,
|
|
"Suspects" => 1,
|
|
"Partially Knows" => 2,
|
|
"Knows" => 3,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static string KnowledgeRegressionMessage(string characterName, string knowledgeDescription, string previousState, string currentState)
|
|
{
|
|
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"{characterName} appears unaware of \"{knowledgeDescription}\" after previously knowing it.";
|
|
}
|
|
|
|
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(currentState, "Suspects", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"{characterName} appears to regress from knowing to suspecting \"{knowledgeDescription}\".";
|
|
}
|
|
|
|
if (string.Equals(previousState, "Knows", StringComparison.OrdinalIgnoreCase)
|
|
&& string.Equals(currentState, "Partially Knows", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"{characterName} appears to lose understanding of \"{knowledgeDescription}\".";
|
|
}
|
|
|
|
if (string.Equals(currentState, "Unaware", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return $"{characterName} appears to lose awareness of \"{knowledgeDescription}\".";
|
|
}
|
|
|
|
return $"{characterName} appears to regress from {KnowledgeStatePhrase(previousState)} to {KnowledgeStatePhrase(currentState)} \"{knowledgeDescription}\".";
|
|
}
|
|
|
|
private static string KnowledgeStatePhrase(string stateName)
|
|
{
|
|
return stateName switch
|
|
{
|
|
"Unaware" => "being unaware of",
|
|
"Suspects" => "suspecting",
|
|
"Partially Knows" => "partially knowing",
|
|
"Knows" => "knowing",
|
|
_ => stateName.ToLowerInvariant()
|
|
};
|
|
}
|
|
|
|
private static string NormaliseWarningKeyPart(string value)
|
|
{
|
|
return string.Join(" ", value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
}
|
|
|
|
private static void ApplyAcknowledgements(
|
|
IReadOnlyList<ContinuityWarningViewModel> warnings,
|
|
IReadOnlyList<ContinuityWarningAcknowledgement> acknowledgements)
|
|
{
|
|
var acknowledgementsByKey = acknowledgements.ToDictionary(
|
|
x => BuildWarningKey(x.WarningType, x.SceneID, x.CharacterID, x.AssetID),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var warning in warnings)
|
|
{
|
|
if (!acknowledgementsByKey.TryGetValue(warning.WarningKey, out var acknowledgement))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
warning.IsAcknowledged = true;
|
|
warning.AcknowledgementNotes = acknowledgement.Notes;
|
|
}
|
|
}
|
|
|
|
private static string BuildWarningKey(string warningType, int? sceneId, int? characterId, int? assetId)
|
|
{
|
|
var parts = new List<string> { warningType.Trim() };
|
|
if (sceneId.HasValue)
|
|
{
|
|
parts.Add($"Scene:{sceneId.Value}");
|
|
}
|
|
|
|
if (characterId.HasValue)
|
|
{
|
|
parts.Add($"Character:{characterId.Value}");
|
|
}
|
|
|
|
if (assetId.HasValue)
|
|
{
|
|
parts.Add($"Asset:{assetId.Value}");
|
|
}
|
|
|
|
return string.Join("|", parts);
|
|
}
|
|
|
|
private static int WarningSeveritySort(string severity)
|
|
{
|
|
return severity switch
|
|
{
|
|
"Critical" => 0,
|
|
"Warning" => 1,
|
|
_ => 2
|
|
};
|
|
}
|
|
|
|
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
|
|
{
|
|
return orderedScenes
|
|
.Select((scene, index) => new { scene.SceneID, Index = index })
|
|
.ToDictionary(x => x.SceneID, x => x.Index);
|
|
}
|
|
|
|
private static Dictionary<int, SceneContext> BuildSceneContexts(TimelineData timeline)
|
|
{
|
|
var chaptersById = timeline.Chapters.ToDictionary(x => x.ChapterID);
|
|
var booksById = timeline.Books.ToDictionary(x => x.BookID);
|
|
|
|
return timeline.Scenes.ToDictionary(
|
|
scene => scene.SceneID,
|
|
scene =>
|
|
{
|
|
chaptersById.TryGetValue(scene.ChapterID, out var chapter);
|
|
var book = chapter is null ? null : booksById.GetValueOrDefault(chapter.BookID);
|
|
|
|
return new SceneContext(
|
|
book?.BookID ?? 0,
|
|
book is null ? string.Empty : $"Book {book.BookNumber}: {book.BookDisplayTitle}",
|
|
chapter?.ChapterID ?? 0,
|
|
chapter is null ? string.Empty : $"Ch {chapter.ChapterNumber:g}: {chapter.ChapterTitle}",
|
|
scene.SceneID,
|
|
scene.SceneTitle,
|
|
SceneLabel(scene.SceneNumber, scene.SceneTitle));
|
|
});
|
|
}
|
|
|
|
private static bool ShouldShowBookContext(ContinuityFilter filter, IReadOnlyList<Scene> scopedScenes, IReadOnlyList<Chapter> chapters)
|
|
{
|
|
if (string.Equals(filter.SceneRangeMode, "EntireProject", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var chapterBookIds = chapters.ToDictionary(x => x.ChapterID, x => x.BookID);
|
|
return scopedScenes
|
|
.Select(scene => chapterBookIds.GetValueOrDefault(scene.ChapterID))
|
|
.Where(bookId => bookId > 0)
|
|
.Distinct()
|
|
.Count() > 1;
|
|
}
|
|
|
|
private static bool ShouldShowChapterContext(ContinuityFilter filter, IReadOnlyList<Scene> scopedScenes, IReadOnlyList<Chapter> chapters)
|
|
{
|
|
if (string.Equals(filter.SceneRangeMode, "EntireProject", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var showBookContext = ShouldShowBookContext(filter, scopedScenes, chapters);
|
|
return showBookContext || scopedScenes.Select(scene => scene.ChapterID).Distinct().Count() > 1;
|
|
}
|
|
|
|
private static void NormaliseFilter(ContinuityFilter filter)
|
|
{
|
|
if (filter.ProjectID <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!filter.EntityTypes.Any())
|
|
{
|
|
filter.EntityTypes = ["Characters", "Assets", "Locations"];
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(filter.SceneRangeMode))
|
|
{
|
|
filter.SceneRangeMode = "EntireProject";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(filter.DisplayMode))
|
|
{
|
|
filter.DisplayMode = "StoryState";
|
|
}
|
|
else if (!string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
filter.DisplayMode = "StoryState";
|
|
}
|
|
}
|
|
|
|
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
|
|
{
|
|
return string.IsNullOrWhiteSpace(sceneTitle)
|
|
? $"Scene {sceneNumber:g}"
|
|
: $"Scene {sceneNumber:g}: {sceneTitle}";
|
|
}
|
|
|
|
private sealed record AssetStateAssignment(
|
|
int StoryAssetID,
|
|
int SceneID,
|
|
int? LocationID,
|
|
string Label,
|
|
int Priority,
|
|
DateTime UpdatedDate);
|
|
|
|
private sealed record AssetLocationActivityAssignment(
|
|
int StoryAssetID,
|
|
int SceneID,
|
|
int? LocationID,
|
|
IReadOnlyList<int> HolderCharacterIDs,
|
|
string Label,
|
|
int Priority,
|
|
DateTime UpdatedDate,
|
|
string? Note);
|
|
|
|
private sealed record RangeContext(
|
|
IReadOnlyList<Scene> OrderedScenes,
|
|
IReadOnlyList<Scene> ScopedScenes,
|
|
IReadOnlySet<int> AllowedSceneIds,
|
|
IReadOnlyDictionary<int, int> SceneIndexes,
|
|
IReadOnlyDictionary<int, string> SceneLabels,
|
|
IReadOnlyDictionary<int, SceneContext> SceneContexts);
|
|
|
|
private sealed record SceneContext(
|
|
int BookID,
|
|
string BookDisplayName,
|
|
int ChapterID,
|
|
string ChapterDisplayName,
|
|
int SceneID,
|
|
string SceneTitle,
|
|
string SceneDisplayName);
|
|
}
|
|
|
|
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : 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,
|
|
PlotImportanceID = lookupData.PlotImportance.FirstOrDefault(x => x.ImportanceName == "Secondary")?.PlotImportanceID
|
|
?? lookupData.PlotImportance.First().PlotImportanceID,
|
|
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,
|
|
PlotImportanceID = plotLine.PlotImportanceID,
|
|
Description = plotLine.Description,
|
|
ParentPlotLineID = plotLine.ParentPlotLineID,
|
|
EmergesFromPlotLineID = plotLine.EmergesFromPlotLineID,
|
|
SortOrder = plotLine.SortOrder,
|
|
Colour = plotLine.Colour,
|
|
IsVisibleOnTimeline = plotLine.IsVisibleOnTimeline,
|
|
Project = project
|
|
}, lookupData, plotLines.Where(x => x.PlotLineID != plotLine.PlotLineID));
|
|
}
|
|
|
|
public async Task<PlotLineEditViewModel?> PopulatePlotLineFormAsync(PlotLineEditViewModel model)
|
|
{
|
|
var project = await projects.GetAsync(model.ProjectID);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var lookupData = await plots.GetLookupsAsync();
|
|
var plotLines = await plots.ListPlotLinesAsync(model.ProjectID);
|
|
|
|
model.Project = project;
|
|
if (model.PlotLineTypeID == 0)
|
|
{
|
|
model.PlotLineTypeID = lookupData.PlotLineTypes.FirstOrDefault(x => x.TypeName == "Side Plot")?.PlotLineTypeID
|
|
?? lookupData.PlotLineTypes.First().PlotLineTypeID;
|
|
}
|
|
|
|
return await PopulatePlotLineListsAsync(model, lookupData, plotLines.Where(x => x.PlotLineID != model.PlotLineID));
|
|
}
|
|
|
|
public async Task<int> SavePlotLineAsync(PlotLineEditViewModel model)
|
|
{
|
|
var isNew = model.PlotLineID == 0;
|
|
var plotLineId = await plots.SavePlotLineAsync(new PlotLineItem
|
|
{
|
|
PlotLineID = model.PlotLineID,
|
|
ProjectID = model.ProjectID,
|
|
BookID = model.BookID,
|
|
PlotLineName = model.PlotLineName,
|
|
PlotLineTypeID = model.PlotLineTypeID,
|
|
PlotImportanceID = model.PlotImportanceID,
|
|
Description = model.Description,
|
|
ParentPlotLineID = model.ParentPlotLineID,
|
|
EmergesFromPlotLineID = model.EmergesFromPlotLineID,
|
|
SortOrder = model.SortOrder,
|
|
Colour = string.IsNullOrWhiteSpace(model.Colour) ? "#2f6f63" : model.Colour,
|
|
IsVisibleOnTimeline = model.IsVisibleOnTimeline
|
|
});
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Plot Line", plotLineId, model.PlotLineName);
|
|
return plotLineId;
|
|
}
|
|
|
|
public async Task ArchivePlotLineAsync(int plotLineId)
|
|
{
|
|
var plotLine = await plots.GetPlotLineAsync(plotLineId);
|
|
await plots.ArchivePlotLineAsync(plotLineId);
|
|
if (plotLine is not null)
|
|
{
|
|
await activity.RecordAsync(plotLine.ProjectID, "Archived", "Plot Line", plotLineId, plotLine.PlotLineName);
|
|
}
|
|
}
|
|
|
|
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 async Task<int> SavePlotThreadAsync(PlotThreadEditViewModel model)
|
|
{
|
|
var isNew = model.PlotThreadID == 0;
|
|
var plotThreadId = await 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
|
|
});
|
|
var plotLine = await plots.GetPlotLineAsync(model.PlotLineID);
|
|
if (plotLine is not null)
|
|
{
|
|
await activity.RecordAsync(plotLine.ProjectID, isNew ? "Created" : "Updated", "Thread", plotThreadId, model.ThreadTitle);
|
|
}
|
|
return plotThreadId;
|
|
}
|
|
|
|
public async Task ArchivePlotThreadAsync(int plotThreadId)
|
|
{
|
|
var plotThread = await plots.GetPlotThreadAsync(plotThreadId);
|
|
await plots.ArchivePlotThreadAsync(plotThreadId);
|
|
if (plotThread is not null)
|
|
{
|
|
await activity.RecordAsync(plotThread.ProjectID, "Archived", "Thread", plotThreadId, plotThread.ThreadTitle);
|
|
}
|
|
}
|
|
|
|
public async Task<int> AddThreadEventAsync(ThreadEventCreateViewModel model)
|
|
{
|
|
var plotThreadId = model.PlotThreadID.GetValueOrDefault();
|
|
var lookupData = await plots.GetLookupsAsync();
|
|
if (model.PlotEventTypeID <= 0)
|
|
{
|
|
throw new InvalidOperationException("Choose an event type.");
|
|
}
|
|
|
|
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
|
|
?? lookupData.ThreadEventTypes.First();
|
|
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
|
|
?? throw new InvalidOperationException("Choose a valid event type.");
|
|
var title = string.IsNullOrWhiteSpace(model.EventTitle)
|
|
? eventType.TypeName
|
|
: model.EventTitle.Trim();
|
|
|
|
var sourcePlotLineId = model.PlotLineID;
|
|
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
|
|
});
|
|
}
|
|
else
|
|
{
|
|
var existingPlotThread = await plots.GetPlotThreadAsync(plotThreadId);
|
|
if (existingPlotThread is null)
|
|
{
|
|
throw new InvalidOperationException("Choose a valid plot thread.");
|
|
}
|
|
|
|
sourcePlotLineId = existingPlotThread.PlotLineID;
|
|
}
|
|
|
|
if (!sourcePlotLineId.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Choose a source plot line.");
|
|
}
|
|
|
|
var projectPlotLines = await plots.ListPlotLinesAsync(model.ReturnProjectID.GetValueOrDefault());
|
|
if (projectPlotLines.Count == 0)
|
|
{
|
|
var sourcePlotLine = await plots.GetPlotLineAsync(sourcePlotLineId.Value);
|
|
projectPlotLines = sourcePlotLine is null ? [] : await plots.ListPlotLinesAsync(sourcePlotLine.ProjectID);
|
|
}
|
|
|
|
ValidatePlotEventTargets(
|
|
plotEventType.TypeName,
|
|
sourcePlotLineId.Value,
|
|
projectPlotLines,
|
|
model.TargetPlotLineID,
|
|
model.SelectedTargetPlotLineIDs);
|
|
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
|
|
? model.TargetPlotLineID
|
|
: null;
|
|
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
|
|
? model.SelectedTargetPlotLineIDs
|
|
: [];
|
|
|
|
var threadEventId = await plots.SaveThreadEventAsync(new ThreadEvent
|
|
{
|
|
PlotThreadID = plotThreadId,
|
|
SceneID = model.SceneID,
|
|
EventTypeID = eventType.ThreadEventTypeID,
|
|
PlotEventTypeID = plotEventType.PlotEventTypeID,
|
|
TargetPlotLineID = targetPlotLineId,
|
|
EventTitle = title,
|
|
EventDescription = model.EventDescription
|
|
});
|
|
await plots.SaveThreadEventTargetsAsync(threadEventId, splitTargetPlotLineIds);
|
|
var plotThread = await plots.GetPlotThreadAsync(plotThreadId);
|
|
if (plotThread is not null)
|
|
{
|
|
await activity.RecordAsync(plotThread.ProjectID, "Created", "Thread Event", threadEventId, title);
|
|
}
|
|
return threadEventId;
|
|
}
|
|
|
|
public async Task UpdateThreadEventAsync(ThreadEventEditViewModel model)
|
|
{
|
|
var lookupData = await plots.GetLookupsAsync();
|
|
if (model.PlotEventTypeID <= 0)
|
|
{
|
|
throw new InvalidOperationException("Choose an event type.");
|
|
}
|
|
|
|
var eventType = lookupData.ThreadEventTypes.FirstOrDefault(x => x.ThreadEventTypeID == model.EventTypeID)
|
|
?? throw new InvalidOperationException("Choose a valid marker type.");
|
|
var plotEventType = lookupData.PlotEventTypes.FirstOrDefault(x => x.PlotEventTypeID == model.PlotEventTypeID)
|
|
?? throw new InvalidOperationException("Choose a valid event type.");
|
|
var plotThread = await plots.GetPlotThreadAsync(model.PlotThreadID)
|
|
?? throw new InvalidOperationException("Choose a valid plot thread.");
|
|
|
|
var projectPlotLines = await plots.ListPlotLinesAsync(plotThread.ProjectID);
|
|
ValidatePlotEventTargets(
|
|
plotEventType.TypeName,
|
|
plotThread.PlotLineID,
|
|
projectPlotLines,
|
|
model.TargetPlotLineID,
|
|
model.SelectedTargetPlotLineIDs);
|
|
|
|
var targetPlotLineId = plotEventType.TypeName is "Branch" or "Merge"
|
|
? model.TargetPlotLineID
|
|
: null;
|
|
var splitTargetPlotLineIds = plotEventType.TypeName == "Split"
|
|
? model.SelectedTargetPlotLineIDs
|
|
: [];
|
|
|
|
await plots.SaveThreadEventAsync(new ThreadEvent
|
|
{
|
|
ThreadEventID = model.ThreadEventID,
|
|
PlotThreadID = plotThread.PlotThreadID,
|
|
SceneID = model.SceneID,
|
|
EventTypeID = eventType.ThreadEventTypeID,
|
|
PlotEventTypeID = plotEventType.PlotEventTypeID,
|
|
TargetPlotLineID = targetPlotLineId,
|
|
EventTitle = string.IsNullOrWhiteSpace(model.EventTitle) ? eventType.TypeName : model.EventTitle.Trim(),
|
|
EventDescription = model.EventDescription
|
|
});
|
|
await plots.SaveThreadEventTargetsAsync(model.ThreadEventID, splitTargetPlotLineIds);
|
|
await activity.RecordAsync(plotThread.ProjectID, "Updated", "Thread Event", model.ThreadEventID, model.EventTitle);
|
|
}
|
|
|
|
private static void ValidatePlotEventTargets(
|
|
string plotEventTypeName,
|
|
int sourcePlotLineId,
|
|
IReadOnlyList<PlotLineItem> projectPlotLines,
|
|
int? targetPlotLineId,
|
|
IEnumerable<int> selectedTargetPlotLineIds)
|
|
{
|
|
var projectPlotLineIds = projectPlotLines.Select(x => x.PlotLineID).ToHashSet();
|
|
|
|
if (plotEventTypeName is "Branch" or "Merge")
|
|
{
|
|
if (!targetPlotLineId.HasValue)
|
|
{
|
|
throw new InvalidOperationException(plotEventTypeName == "Branch"
|
|
? "Choose a target plot line for the branch event."
|
|
: "Choose the plot line this event merges into.");
|
|
}
|
|
|
|
if (targetPlotLineId.Value == sourcePlotLineId)
|
|
{
|
|
throw new InvalidOperationException("Target plot line cannot be the source plot line.");
|
|
}
|
|
|
|
if (!projectPlotLineIds.Contains(targetPlotLineId.Value))
|
|
{
|
|
throw new InvalidOperationException("Target plot line must belong to this project.");
|
|
}
|
|
}
|
|
|
|
if (plotEventTypeName == "Split")
|
|
{
|
|
var targets = selectedTargetPlotLineIds.Where(id => id > 0).Distinct().ToList();
|
|
if (targets.Count < 2)
|
|
{
|
|
throw new InvalidOperationException("Choose at least two target plot lines for a split event.");
|
|
}
|
|
|
|
if (targets.Contains(sourcePlotLineId))
|
|
{
|
|
throw new InvalidOperationException("Split target plot lines cannot include the source plot line.");
|
|
}
|
|
|
|
if (targets.Any(id => !projectPlotLineIds.Contains(id)))
|
|
{
|
|
throw new InvalidOperationException("Split target plot lines must belong to this project.");
|
|
}
|
|
}
|
|
}
|
|
|
|
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.PlotImportance = ChapterService.ToSelectList(lookupData.PlotImportance, x => x.PlotImportanceID, x => x.ImportanceName);
|
|
model.BookOptions = (await books.ListByProjectAsync(model.ProjectID))
|
|
.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", 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.BookDisplayTitle} / 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,
|
|
IStoryStateService storyState) : IContinuityValidationService
|
|
{
|
|
public async Task<WarningDashboardViewModel?> GetDashboardAsync(
|
|
int projectId,
|
|
int? bookId,
|
|
int? chapterId,
|
|
int? sceneId,
|
|
int? warningTypeId,
|
|
int? warningSeverityId,
|
|
string? entityType,
|
|
string? category,
|
|
bool includeDismissed,
|
|
bool includeIntentional,
|
|
bool showAcknowledgedWarnings,
|
|
ContinuityValidationSummary? summary = null)
|
|
{
|
|
var project = await projects.GetAsync(projectId);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var lookupData = await warnings.GetLookupsAsync();
|
|
var projectBooks = (await books.ListByProjectAsync(projectId)).ToList();
|
|
var projectChapters = new List<Chapter>();
|
|
foreach (var book in projectBooks)
|
|
{
|
|
projectChapters.AddRange(await chapters.ListByBookAsync(book.BookID));
|
|
}
|
|
|
|
var severityName = warningSeverityId.HasValue
|
|
? lookupData.Severities.FirstOrDefault(x => x.WarningSeverityID == warningSeverityId.Value)?.SeverityName
|
|
: null;
|
|
var rows = (await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, null, entityType, includeDismissed, includeIntentional))
|
|
.Where(x => !chapterId.HasValue || x.ChapterID == chapterId.Value)
|
|
.ToList();
|
|
var continuityWarnings = await storyState.GetContinuityWarningsAsync(projectId, BuildWarningContinuityFilter(projectId, bookId, chapterId, sceneId));
|
|
var booksById = projectBooks.ToDictionary(x => x.BookID);
|
|
var chaptersById = projectChapters.ToDictionary(x => x.ChapterID);
|
|
var dashboardWarnings = rows.Select(x => ToProjectWarning(x, booksById, chaptersById)).Concat(continuityWarnings.Select(ToProjectWarning)).ToList();
|
|
ApplyWarningDisplayContext(dashboardWarnings, bookId, chapterId, booksById, chaptersById);
|
|
var availableWarnings = dashboardWarnings.ToList();
|
|
var hasAnyWarnings = availableWarnings.Any();
|
|
|
|
dashboardWarnings = dashboardWarnings
|
|
.Where(x => string.IsNullOrWhiteSpace(severityName) || string.Equals(x.Severity, severityName, StringComparison.OrdinalIgnoreCase))
|
|
.Where(x => string.IsNullOrWhiteSpace(entityType) || string.Equals(x.EntityType, entityType, StringComparison.OrdinalIgnoreCase))
|
|
.Where(x => string.IsNullOrWhiteSpace(category) || string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase))
|
|
.Where(x => showAcknowledgedWarnings || !x.IsAcknowledged)
|
|
.OrderBy(x => WarningSeveritySort(x.Severity))
|
|
.ThenBy(x => x.BookDisplayName)
|
|
.ThenBy(x => x.ChapterDisplayName)
|
|
.ThenBy(x => x.SceneID ?? int.MaxValue)
|
|
.ThenBy(x => x.Category)
|
|
.ThenBy(x => x.WarningTypeDisplayName)
|
|
.ThenBy(x => x.Message)
|
|
.ToList();
|
|
|
|
return new WarningDashboardViewModel
|
|
{
|
|
Project = project,
|
|
BookID = bookId,
|
|
ChapterID = chapterId,
|
|
SceneID = sceneId,
|
|
WarningTypeID = warningTypeId,
|
|
WarningSeverityID = warningSeverityId,
|
|
EntityType = entityType,
|
|
Category = category,
|
|
IncludeDismissed = includeDismissed,
|
|
IncludeIntentional = includeIntentional,
|
|
ShowAcknowledgedWarnings = showAcknowledgedWarnings,
|
|
Warnings = rows,
|
|
ProjectWarnings = dashboardWarnings,
|
|
WarningGroups = BuildWarningSeverityGroups(dashboardWarnings),
|
|
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
|
|
ChapterOptions = projectChapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == chapterId)).ToList(),
|
|
WarningTypeOptions = lookupData.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == warningTypeId)).ToList(),
|
|
SeverityOptions = lookupData.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == warningSeverityId)).ToList(),
|
|
EntityTypeOptions = availableWarnings.Select(x => x.EntityType).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, entityType, StringComparison.OrdinalIgnoreCase))).ToList(),
|
|
CategoryOptions = availableWarnings.Select(x => x.Category).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, category, StringComparison.OrdinalIgnoreCase))).ToList(),
|
|
CountsBySeverity = dashboardWarnings.GroupBy(x => x.Severity).ToDictionary(x => x.Key, x => x.Count()),
|
|
CountsByType = dashboardWarnings.GroupBy(x => x.WarningTypeDisplayName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()),
|
|
LastValidationSummary = summary,
|
|
HasAnyWarnings = hasAnyWarnings
|
|
};
|
|
}
|
|
|
|
private static ContinuityFilter BuildWarningContinuityFilter(int projectId, int? bookId, int? chapterId, int? sceneId)
|
|
{
|
|
var filter = new ContinuityFilter
|
|
{
|
|
ProjectID = projectId,
|
|
DisplayMode = "ContinuityWarnings",
|
|
ShowAcknowledgedWarnings = true
|
|
};
|
|
|
|
if (sceneId.HasValue)
|
|
{
|
|
filter.SceneRangeMode = "Custom";
|
|
filter.StartSceneID = sceneId;
|
|
filter.EndSceneID = sceneId;
|
|
}
|
|
else if (chapterId.HasValue)
|
|
{
|
|
filter.SceneRangeMode = "Chapter";
|
|
filter.ChapterID = chapterId;
|
|
}
|
|
else if (bookId.HasValue)
|
|
{
|
|
filter.SceneRangeMode = "Book";
|
|
filter.BookID = bookId;
|
|
}
|
|
|
|
return filter;
|
|
}
|
|
|
|
private static ProjectWarningViewModel ToProjectWarning(
|
|
ContinuityWarning warning,
|
|
IReadOnlyDictionary<int, Book> booksById,
|
|
IReadOnlyDictionary<int, Chapter> chaptersById)
|
|
{
|
|
var bookDisplayName = warning.BookID.HasValue && booksById.TryGetValue(warning.BookID.Value, out var book)
|
|
? $"Book {book.BookNumber:g}: {book.BookDisplayTitle}"
|
|
: warning.BookDisplayTitle;
|
|
var chapterDisplayName = warning.ChapterID.HasValue && chaptersById.TryGetValue(warning.ChapterID.Value, out var chapter)
|
|
? $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}"
|
|
: warning.ChapterNumber.HasValue
|
|
? $"Chapter {warning.ChapterNumber:g}: {warning.ChapterTitle}"
|
|
: string.Empty;
|
|
|
|
return new ProjectWarningViewModel
|
|
{
|
|
Source = "Persisted",
|
|
ContinuityWarningID = warning.ContinuityWarningID,
|
|
WarningType = warning.WarningTypeName,
|
|
WarningTypeDisplayName = warning.WarningTypeName,
|
|
Category = WarningCategoryForPersisted(warning),
|
|
Severity = warning.SeverityName,
|
|
Message = warning.Message,
|
|
Details = warning.Details,
|
|
BookID = warning.BookID,
|
|
BookDisplayName = bookDisplayName,
|
|
ChapterID = warning.ChapterID,
|
|
ChapterDisplayName = chapterDisplayName,
|
|
SceneID = warning.SceneID,
|
|
SceneDisplayName = warning.SceneID.HasValue
|
|
? SceneLabel(warning.SceneNumber ?? 0, warning.SceneTitle ?? string.Empty)
|
|
: string.Empty,
|
|
EntityType = warning.EntityType,
|
|
IsDismissed = warning.IsDismissed,
|
|
IsIntentional = warning.IsIntentional,
|
|
LastDetectedDate = warning.LastDetectedDate
|
|
};
|
|
}
|
|
|
|
private static ProjectWarningViewModel ToProjectWarning(ContinuityWarningViewModel warning)
|
|
{
|
|
return new ProjectWarningViewModel
|
|
{
|
|
Source = "Generated",
|
|
WarningType = warning.WarningType,
|
|
WarningTypeDisplayName = warning.WarningTypeDisplayName,
|
|
Category = WarningCategoryForGenerated(warning),
|
|
Severity = warning.Severity,
|
|
Message = warning.Message,
|
|
BookID = warning.BookID,
|
|
BookDisplayName = warning.BookDisplayName,
|
|
ChapterID = warning.ChapterID,
|
|
ChapterDisplayName = warning.ChapterDisplayName,
|
|
SceneID = warning.SceneID,
|
|
SceneDisplayName = warning.SceneDisplayName,
|
|
CharacterID = warning.CharacterID,
|
|
CharacterName = warning.CharacterName,
|
|
AssetID = warning.AssetID,
|
|
EntityType = GeneratedEntityType(warning),
|
|
IsAcknowledged = warning.IsAcknowledged,
|
|
AcknowledgementNotes = warning.AcknowledgementNotes,
|
|
WarningKey = warning.WarningKey,
|
|
KnowledgeId = warning.KnowledgeId,
|
|
KnowledgeTitle = warning.KnowledgeTitle,
|
|
KnowledgeDescription = warning.KnowledgeDescription,
|
|
PreviousKnowledgeState = warning.PreviousKnowledgeState,
|
|
CurrentKnowledgeState = warning.CurrentKnowledgeState,
|
|
PreviousBookId = warning.PreviousBookId,
|
|
PreviousChapterId = warning.PreviousChapterId,
|
|
PreviousSceneId = warning.PreviousSceneId,
|
|
PreviousSceneDisplayName = warning.PreviousSceneDisplayName,
|
|
PreviousContextSceneDisplayName = warning.PreviousContextSceneDisplayName,
|
|
CurrentBookId = warning.CurrentBookId,
|
|
CurrentChapterId = warning.CurrentChapterId,
|
|
CurrentSceneId = warning.CurrentSceneId,
|
|
CurrentSceneDisplayName = warning.CurrentSceneDisplayName,
|
|
CurrentContextSceneDisplayName = warning.CurrentContextSceneDisplayName,
|
|
EstablishedSceneId = warning.EstablishedSceneId,
|
|
EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName,
|
|
ReferencedSceneId = warning.ReferencedSceneId,
|
|
ReferencedSceneDisplayName = warning.ReferencedSceneDisplayName,
|
|
LastDetectedDate = DateTime.UtcNow
|
|
};
|
|
}
|
|
|
|
private static void ApplyWarningDisplayContext(
|
|
List<ProjectWarningViewModel> warnings,
|
|
int? scopedBookId,
|
|
int? scopedChapterId,
|
|
IReadOnlyDictionary<int, Book> booksById,
|
|
IReadOnlyDictionary<int, Chapter> chaptersById)
|
|
{
|
|
foreach (var warning in warnings)
|
|
{
|
|
warning.ContextSceneDisplayName = FormatWarningSceneContext(
|
|
warning.BookID,
|
|
warning.BookDisplayName,
|
|
warning.ChapterID,
|
|
warning.ChapterDisplayName,
|
|
warning.SceneDisplayName,
|
|
scopedBookId,
|
|
scopedChapterId,
|
|
booksById,
|
|
chaptersById);
|
|
warning.PreviousContextSceneDisplayName = FormatWarningSceneContext(
|
|
warning.PreviousBookId,
|
|
string.Empty,
|
|
warning.PreviousChapterId,
|
|
string.Empty,
|
|
warning.PreviousSceneDisplayName,
|
|
scopedBookId,
|
|
scopedChapterId,
|
|
booksById,
|
|
chaptersById);
|
|
warning.CurrentContextSceneDisplayName = FormatWarningSceneContext(
|
|
warning.CurrentBookId,
|
|
string.Empty,
|
|
warning.CurrentChapterId,
|
|
string.Empty,
|
|
warning.CurrentSceneDisplayName,
|
|
scopedBookId,
|
|
scopedChapterId,
|
|
booksById,
|
|
chaptersById);
|
|
warning.EstablishedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.EstablishedSceneDisplayName)
|
|
? string.Empty
|
|
: warning.EstablishedSceneDisplayName;
|
|
warning.ReferencedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.ReferencedSceneDisplayName)
|
|
? string.Empty
|
|
: warning.ReferencedSceneDisplayName;
|
|
ApplyInvestigationTarget(warning);
|
|
}
|
|
}
|
|
|
|
private static string FormatWarningSceneContext(
|
|
int? bookId,
|
|
string bookDisplayName,
|
|
int? chapterId,
|
|
string chapterDisplayName,
|
|
string sceneDisplayName,
|
|
int? scopedBookId,
|
|
int? scopedChapterId,
|
|
IReadOnlyDictionary<int, Book> booksById,
|
|
IReadOnlyDictionary<int, Chapter> chaptersById)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sceneDisplayName))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(bookDisplayName) && bookId.HasValue && booksById.TryGetValue(bookId.Value, out var book))
|
|
{
|
|
bookDisplayName = $"Book {book.BookNumber:g}: {book.BookDisplayTitle}";
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(chapterDisplayName) && chapterId.HasValue && chaptersById.TryGetValue(chapterId.Value, out var chapter))
|
|
{
|
|
chapterDisplayName = $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}";
|
|
}
|
|
|
|
if (scopedChapterId.HasValue)
|
|
{
|
|
return sceneDisplayName;
|
|
}
|
|
|
|
if (scopedBookId.HasValue || (bookId.HasValue && scopedBookId == bookId))
|
|
{
|
|
return string.IsNullOrWhiteSpace(chapterDisplayName)
|
|
? sceneDisplayName
|
|
: $"{chapterDisplayName} • {sceneDisplayName}";
|
|
}
|
|
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(bookDisplayName))
|
|
{
|
|
parts.Add(bookDisplayName);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(chapterDisplayName))
|
|
{
|
|
parts.Add(chapterDisplayName);
|
|
}
|
|
|
|
parts.Add(sceneDisplayName);
|
|
return string.Join(" • ", parts);
|
|
}
|
|
|
|
private static void ApplyInvestigationTarget(ProjectWarningViewModel warning)
|
|
{
|
|
if (warning.Source != "Generated")
|
|
{
|
|
return;
|
|
}
|
|
|
|
warning.CanInvestigate = true;
|
|
warning.InvestigationDisplayMode = warning.WarningTypeDisplayName switch
|
|
{
|
|
"Multiple Character Locations" => "LocationActivity",
|
|
_ => "Journey"
|
|
};
|
|
warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID;
|
|
}
|
|
|
|
private static IReadOnlyList<WarningSeverityGroupViewModel> BuildWarningSeverityGroups(IReadOnlyList<ProjectWarningViewModel> warnings)
|
|
{
|
|
var groups = warnings
|
|
.GroupBy(x => WarningSeverityGroupLabel(x.Severity))
|
|
.ToDictionary(x => x.Key, x => x.ToList(), StringComparer.OrdinalIgnoreCase);
|
|
|
|
return new[] { "Critical", "Warning", "Information" }
|
|
.Where(groups.ContainsKey)
|
|
.Select(severity => new WarningSeverityGroupViewModel
|
|
{
|
|
Severity = severity,
|
|
IsExpanded = severity is "Critical" or "Warning",
|
|
Warnings = groups[severity]
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static string WarningSeverityGroupLabel(string severity)
|
|
{
|
|
return severity switch
|
|
{
|
|
"Critical" or "Error" => "Critical",
|
|
"Warning" => "Warning",
|
|
_ => "Information"
|
|
};
|
|
}
|
|
|
|
private static string WarningCategoryForPersisted(ContinuityWarning warning)
|
|
{
|
|
return warning.EntityType switch
|
|
{
|
|
"Character" => "Character",
|
|
"Asset" or "StoryAsset" => "Asset",
|
|
"Scene" => "Timeline",
|
|
"Relationship" => "Character",
|
|
"PlotThread" => "Plot Thread",
|
|
_ => "General"
|
|
};
|
|
}
|
|
|
|
private static string WarningCategoryForGenerated(ContinuityWarningViewModel warning)
|
|
{
|
|
if (string.Equals(warning.WarningTypeDisplayName, "Knowledge Regression", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return "Knowledge";
|
|
}
|
|
|
|
return warning.WarningTypeDisplayName switch
|
|
{
|
|
"Multiple Character Locations" => "Character",
|
|
"Characters With Unknown Locations" => "Character",
|
|
"Asset Location Conflict" => "Asset",
|
|
"Assets With Unknown Locations" => "Asset",
|
|
_ => "Continuity"
|
|
};
|
|
}
|
|
|
|
private static string GeneratedEntityType(ContinuityWarningViewModel warning)
|
|
{
|
|
if (warning.CharacterID.HasValue)
|
|
{
|
|
return "Character";
|
|
}
|
|
|
|
if (warning.AssetID.HasValue)
|
|
{
|
|
return "Asset";
|
|
}
|
|
|
|
return "Continuity";
|
|
}
|
|
|
|
private static int WarningSeveritySort(string severity)
|
|
{
|
|
return severity switch
|
|
{
|
|
"Critical" => 0,
|
|
"Warning" => 1,
|
|
_ => 2
|
|
};
|
|
}
|
|
|
|
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
|
|
{
|
|
return string.IsNullOrWhiteSpace(sceneTitle)
|
|
? $"Scene {sceneNumber:g}"
|
|
: $"Scene {sceneNumber:g}: {sceneTitle}";
|
|
}
|
|
|
|
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 Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
|
|
storyState.AcknowledgeWarningAsync(acknowledgement);
|
|
|
|
public Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
|
|
storyState.RestoreWarningAsync(acknowledgement);
|
|
}
|
|
|
|
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 SceneMetricValueService(
|
|
ISceneRepository scenes,
|
|
IChapterRepository chapters,
|
|
IBookRepository books,
|
|
ISceneMetricTypeRepository metricTypes) : ISceneMetricValueService
|
|
{
|
|
public async Task<int> UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model)
|
|
{
|
|
var metricTypeId = model.MetricTypeID > 0 ? model.MetricTypeID : model.MetricID;
|
|
if (model.SceneID <= 0 || metricTypeId <= 0)
|
|
{
|
|
throw new InvalidOperationException("Missing scene or metric details.");
|
|
}
|
|
|
|
var scene = await scenes.GetAsync(model.SceneID);
|
|
if (scene is null)
|
|
{
|
|
throw new InvalidOperationException("Scene could not be found.");
|
|
}
|
|
|
|
var chapter = await chapters.GetAsync(scene.ChapterID);
|
|
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
|
|
if (book is null)
|
|
{
|
|
throw new InvalidOperationException("Scene project could not be found.");
|
|
}
|
|
|
|
var metric = await metricTypes.GetAsync(metricTypeId, book.ProjectID);
|
|
if (metric is null || !metric.IsActive || !metric.IsEnabledForProject)
|
|
{
|
|
throw new InvalidOperationException("Metric is not available for this project.");
|
|
}
|
|
|
|
var value = Math.Clamp(model.Value, metric.MinValue, metric.MaxValue);
|
|
await scenes.SaveMetricValueAsync(model.SceneID, metricTypeId, value);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
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.BookDisplayTitle}", 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 RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
|
|
{
|
|
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
|
|
{
|
|
if (projectId <= 0)
|
|
{
|
|
projectId = (await projects.ListAsync()).FirstOrDefault()?.ProjectID ?? 0;
|
|
}
|
|
|
|
var project = await projects.GetAsync(projectId);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var data = await characters.GetRelationshipMapAsync(projectId);
|
|
var characterImageLookup = (await characters.ListCharactersAsync(projectId)).ToDictionary(x => x.CharacterID);
|
|
var sceneOrder = BuildSceneOrder(data);
|
|
var point = ResolveStoryPoint(data, sceneOrder, bookId, chapterId, sceneId);
|
|
var eventsByRelationship = data.RelationshipEvents
|
|
.GroupBy(x => x.CharacterRelationshipID)
|
|
.ToDictionary(x => x.Key, x => x.OrderBy(e => SceneIndex(sceneOrder, e.SceneID)).ThenBy(e => e.CreatedDate).ThenBy(e => e.RelationshipEventID).ToList());
|
|
|
|
var relationships = data.Relationships
|
|
.Select(relationship => BuildRelationship(relationship, eventsByRelationship.TryGetValue(relationship.CharacterRelationshipID, out var events) ? events : [], sceneOrder, point.MaxSceneIndex))
|
|
.Where(x => x is not null)
|
|
.Select(x => x!)
|
|
.OrderBy(x => x.RelationshipCategoryName)
|
|
.ThenBy(x => x.CharacterAName)
|
|
.ThenBy(x => x.CharacterBName)
|
|
.ToList();
|
|
|
|
if (focusCharacterId.HasValue && !showFullNetwork)
|
|
{
|
|
relationships = relationships
|
|
.Where(x => x.CharacterAID == focusCharacterId || x.CharacterBID == focusCharacterId)
|
|
.ToList();
|
|
}
|
|
|
|
var activeCharacterIds = relationships
|
|
.SelectMany(x => new[] { x.CharacterAID, x.CharacterBID })
|
|
.ToHashSet();
|
|
|
|
if (focusCharacterId.HasValue)
|
|
{
|
|
activeCharacterIds.Add(focusCharacterId.Value);
|
|
}
|
|
|
|
var nodes = data.Characters
|
|
.Where(x => activeCharacterIds.Contains(x.CharacterID) || (!focusCharacterId.HasValue && !relationships.Any()))
|
|
.OrderByDescending(x => x.CharacterID == focusCharacterId)
|
|
.ThenBy(x => x.CharacterName)
|
|
.Select(x => new RelationshipMapNodeViewModel
|
|
{
|
|
CharacterID = x.CharacterID,
|
|
CharacterName = x.CharacterName,
|
|
CharacterImportance = x.CharacterImportance,
|
|
AvatarImagePath = characterImageLookup.TryGetValue(x.CharacterID, out var characterImages) ? characterImages.AvatarImagePath : x.AvatarImagePath,
|
|
AvatarThumbnailPath = characterImages?.AvatarThumbnailPath ?? x.AvatarThumbnailPath,
|
|
ImagePath = characterImages?.ImagePath ?? x.ImagePath,
|
|
ThumbnailPath = characterImages?.ThumbnailPath ?? x.ThumbnailPath,
|
|
IsFocus = x.CharacterID == focusCharacterId
|
|
})
|
|
.ToList();
|
|
|
|
return new RelationshipMapViewModel
|
|
{
|
|
Project = project,
|
|
BookID = point.BookID,
|
|
ChapterID = point.ChapterID,
|
|
SceneID = point.SceneID,
|
|
FocusCharacterID = focusCharacterId,
|
|
ShowFullNetwork = showFullNetwork,
|
|
StoryPointLabel = point.Label,
|
|
BookOptions = data.Books.OrderBy(x => x.SortOrder).ThenBy(x => x.BookNumber).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString(), x.BookID == point.BookID)).ToList(),
|
|
ChapterOptions = data.Chapters.OrderBy(x => BookSort(data, x.BookID)).ThenBy(x => x.SortOrder).Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == point.ChapterID)).ToList(),
|
|
SceneOptions = data.Scenes.OrderBy(x => SceneIndex(sceneOrder, x.SceneID)).Select(x => new SelectListItem($"Ch {ChapterNumber(data, x.ChapterID)} / Scene {x.SceneNumber}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == point.SceneID)).ToList(),
|
|
CharacterOptions = data.Characters.OrderBy(x => x.CharacterName).Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), x.CharacterID == focusCharacterId)).ToList(),
|
|
Categories = data.RelationshipCategories.OrderBy(x => x.SortOrder).Select(x => new RelationshipMapCategoryViewModel
|
|
{
|
|
RelationshipCategoryID = x.RelationshipCategoryID,
|
|
CategoryName = x.CategoryName,
|
|
CssClass = RelationshipCategoryCss(x.CategoryName)
|
|
}).ToList(),
|
|
Nodes = nodes,
|
|
Links = relationships.Select(x => new RelationshipMapLinkViewModel
|
|
{
|
|
CharacterRelationshipID = x.CharacterRelationshipID,
|
|
SourceCharacterID = x.CharacterAID,
|
|
TargetCharacterID = x.CharacterBID,
|
|
RelationshipLabel = x.RelationshipTypeName,
|
|
CategoryName = x.RelationshipCategoryName,
|
|
CssClass = x.CssClass,
|
|
Intensity = x.CurrentIntensity
|
|
}).ToList(),
|
|
Relationships = relationships
|
|
};
|
|
}
|
|
|
|
private static RelationshipMapRelationshipViewModel? BuildRelationship(CharacterRelationship relationship, IReadOnlyList<RelationshipEvent> events, IReadOnlyDictionary<int, int> sceneOrder, int maxSceneIndex)
|
|
{
|
|
var visibleEvents = maxSceneIndex < 0
|
|
? []
|
|
: events.Where(x => SceneIndex(sceneOrder, x.SceneID) <= maxSceneIndex).ToList();
|
|
|
|
var hasInitialState = relationship.IsInitialRelationship || !events.Any();
|
|
if (!hasInitialState && visibleEvents.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var latestEvent = visibleEvents
|
|
.OrderBy(x => SceneIndex(sceneOrder, x.SceneID))
|
|
.ThenBy(x => x.CreatedDate)
|
|
.ThenBy(x => x.RelationshipEventID)
|
|
.LastOrDefault();
|
|
var initialState = relationship.InitialRelationshipStateName ?? relationship.RelationshipTypeName;
|
|
var currentState = latestEvent?.RelationshipStateName ?? initialState;
|
|
|
|
var history = new List<RelationshipMapHistoryItemViewModel>
|
|
{
|
|
new()
|
|
{
|
|
Label = "Initial State",
|
|
StateName = initialState,
|
|
Intensity = relationship.InitialIntensity,
|
|
Notes = relationship.Notes
|
|
}
|
|
};
|
|
history.AddRange(visibleEvents.Select(x => new RelationshipMapHistoryItemViewModel
|
|
{
|
|
Label = $"{x.BookDisplayTitle} / Ch {x.ChapterNumber} / Scene {x.SceneNumber}: {x.SceneTitle}",
|
|
StateName = x.RelationshipStateName,
|
|
Intensity = x.Intensity,
|
|
Notes = x.Description,
|
|
SceneID = x.SceneID
|
|
}));
|
|
|
|
return new RelationshipMapRelationshipViewModel
|
|
{
|
|
CharacterRelationshipID = relationship.CharacterRelationshipID,
|
|
CharacterAID = relationship.CharacterAID,
|
|
CharacterAName = relationship.CharacterAName,
|
|
CharacterBID = relationship.CharacterBID,
|
|
CharacterBName = relationship.CharacterBName,
|
|
RelationshipTypeName = relationship.RelationshipTypeName,
|
|
RelationshipCategoryName = relationship.RelationshipCategoryName,
|
|
CssClass = RelationshipCategoryCss(relationship.RelationshipCategoryName),
|
|
InitialStateName = initialState,
|
|
CurrentStateName = currentState,
|
|
InitialIntensity = relationship.InitialIntensity,
|
|
CurrentIntensity = latestEvent?.Intensity ?? relationship.InitialIntensity,
|
|
IsReciprocal = relationship.IsReciprocal,
|
|
Notes = latestEvent?.Description ?? relationship.Notes,
|
|
History = history
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyDictionary<int, int> BuildSceneOrder(RelationshipMapData data)
|
|
{
|
|
var bookSort = data.Books.ToDictionary(x => x.BookID, x => x.SortOrder);
|
|
var chapterSort = data.Chapters.ToDictionary(x => x.ChapterID, x => x.SortOrder);
|
|
return data.Scenes
|
|
.OrderBy(x => bookSort.TryGetValue(data.Chapters.FirstOrDefault(c => c.ChapterID == x.ChapterID)?.BookID ?? 0, out var b) ? b : 0)
|
|
.ThenBy(x => chapterSort.TryGetValue(x.ChapterID, out var c) ? c : 0)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.SceneNumber)
|
|
.Select((scene, index) => new { scene.SceneID, Index = index })
|
|
.ToDictionary(x => x.SceneID, x => x.Index);
|
|
}
|
|
|
|
private static (int? BookID, int? ChapterID, int? SceneID, int MaxSceneIndex, string Label) ResolveStoryPoint(RelationshipMapData data, IReadOnlyDictionary<int, int> sceneOrder, int? bookId, int? chapterId, int? sceneId)
|
|
{
|
|
if (sceneId.HasValue && sceneOrder.ContainsKey(sceneId.Value))
|
|
{
|
|
var scene = data.Scenes.First(x => x.SceneID == sceneId.Value);
|
|
var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == scene.ChapterID);
|
|
var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID);
|
|
return (book?.BookID, chapter?.ChapterID, scene.SceneID, sceneOrder[scene.SceneID], $"{book?.BookDisplayTitle ?? "Book"} / Ch {chapter?.ChapterNumber} / Scene {scene.SceneNumber}");
|
|
}
|
|
|
|
if (chapterId.HasValue)
|
|
{
|
|
var chapterScenes = data.Scenes.Where(x => x.ChapterID == chapterId.Value && sceneOrder.ContainsKey(x.SceneID)).ToList();
|
|
if (chapterScenes.Count > 0)
|
|
{
|
|
var chapter = data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId.Value);
|
|
var book = chapter is null ? null : data.Books.FirstOrDefault(x => x.BookID == chapter.BookID);
|
|
return (book?.BookID, chapter?.ChapterID, null, chapterScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookDisplayTitle ?? "Book"} / Ch {chapter?.ChapterNumber}");
|
|
}
|
|
}
|
|
|
|
if (bookId.HasValue)
|
|
{
|
|
var chapterIds = data.Chapters.Where(x => x.BookID == bookId.Value).Select(x => x.ChapterID).ToHashSet();
|
|
var bookScenes = data.Scenes.Where(x => chapterIds.Contains(x.ChapterID) && sceneOrder.ContainsKey(x.SceneID)).ToList();
|
|
if (bookScenes.Count > 0)
|
|
{
|
|
var book = data.Books.FirstOrDefault(x => x.BookID == bookId.Value);
|
|
return (book?.BookID, null, null, bookScenes.Max(x => sceneOrder[x.SceneID]), $"End of {book?.BookDisplayTitle ?? "Book"}");
|
|
}
|
|
}
|
|
|
|
return (null, null, null, -1, "Project Start");
|
|
}
|
|
|
|
private static int SceneIndex(IReadOnlyDictionary<int, int> sceneOrder, int sceneId) => sceneOrder.TryGetValue(sceneId, out var index) ? index : int.MaxValue;
|
|
|
|
private static int BookSort(RelationshipMapData data, int bookId) => data.Books.FirstOrDefault(x => x.BookID == bookId)?.SortOrder ?? 0;
|
|
|
|
private static decimal ChapterNumber(RelationshipMapData data, int chapterId) => data.Chapters.FirstOrDefault(x => x.ChapterID == chapterId)?.ChapterNumber ?? 0;
|
|
|
|
private static string RelationshipCategoryCss(string categoryName) => categoryName switch
|
|
{
|
|
"Family" => "relationship-category-family",
|
|
"Friendship" => "relationship-category-friendship",
|
|
"Romantic / Sexual" => "relationship-category-romantic",
|
|
"Hostile / Conflict" => "relationship-category-hostile",
|
|
"Professional / Social" => "relationship-category-professional",
|
|
_ => "relationship-category-other"
|
|
};
|
|
}
|
|
|
|
public sealed class WriterWorkspaceService(
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
IChapterRepository chapters,
|
|
IWriterWorkspaceRepository writerWorkspace,
|
|
ICurrentUserService currentUser) : IWriterWorkspaceService
|
|
{
|
|
public async Task<WriterDashboardViewModel> GetDashboardAsync(int? projectId)
|
|
{
|
|
var projectRows = currentUser.UserId.HasValue
|
|
? await projects.ListForUserAsync(currentUser.UserId.Value)
|
|
: Array.Empty<Project>();
|
|
var projectIds = projectId.HasValue
|
|
? projectRows.Where(project => project.ProjectID == projectId.Value).Select(project => project.ProjectID).ToList()
|
|
: projectRows.Select(project => project.ProjectID).ToList();
|
|
|
|
return new WriterDashboardViewModel
|
|
{
|
|
ProjectID = projectId,
|
|
ProjectOptions = projectRows.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == projectId)).ToList(),
|
|
WriteNext = await GetDashboardScenesAsync(projectIds, "WriteNext"),
|
|
ContinueWriting = await GetDashboardScenesAsync(projectIds, "ContinueWriting"),
|
|
RevisionQueue = await GetDashboardScenesAsync(projectIds, "RevisionQueue"),
|
|
PolishingQueue = await GetDashboardScenesAsync(projectIds, "PolishingQueue"),
|
|
StoryHealthReminders = await GetDashboardRemindersAsync(projectIds)
|
|
};
|
|
}
|
|
|
|
private async Task<IReadOnlyList<WriterDashboardScene>> GetDashboardScenesAsync(IReadOnlyList<int> projectIds, string queue)
|
|
{
|
|
var rows = new List<WriterDashboardScene>();
|
|
foreach (var accessibleProjectId in projectIds)
|
|
{
|
|
rows.AddRange(await writerWorkspace.GetWriterDashboardScenesAsync(accessibleProjectId, queue));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
private async Task<IReadOnlyList<StoryBibleAttentionItem>> GetDashboardRemindersAsync(IReadOnlyList<int> projectIds)
|
|
{
|
|
var rows = new List<StoryBibleAttentionItem>();
|
|
foreach (var accessibleProjectId in projectIds)
|
|
{
|
|
rows.AddRange(await writerWorkspace.GetWriterDashboardRemindersAsync(accessibleProjectId));
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
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 async Task SaveSceneWorkflowAsync(SceneWorkflowEditViewModel model)
|
|
{
|
|
var existing = await writerWorkspace.GetWorkflowAsync(model.SceneID);
|
|
var priority = model.Priority.HasValue
|
|
? Math.Clamp(model.Priority.Value, 1, 10)
|
|
: (int?)null;
|
|
|
|
await writerWorkspace.SaveWorkflowAsync(new SceneWorkflow
|
|
{
|
|
SceneID = model.SceneID,
|
|
DraftStatus = existing.DraftStatus,
|
|
EstimatedWordCount = model.EstimatedWordCount,
|
|
ActualWordCount = model.ActualWordCount,
|
|
DraftedDate = model.DraftedDate,
|
|
LastWorkedOn = model.LastWorkedOn,
|
|
ReadyForDraft = existing.ReadyForDraft,
|
|
ReadyForRevision = existing.ReadyForRevision,
|
|
ReadyForPolish = existing.ReadyForPolish,
|
|
IsBlocked = model.IsBlocked,
|
|
BlockedReason = model.BlockedReason,
|
|
Priority = 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 WritingScheduleService(
|
|
IWritingScheduleRepository writingSchedule,
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
ICurrentUserService currentUser) : IWritingScheduleService
|
|
{
|
|
private static readonly IReadOnlyList<string> WritingDays =
|
|
[
|
|
"Monday",
|
|
"Tuesday",
|
|
"Wednesday",
|
|
"Thursday",
|
|
"Friday",
|
|
"Saturday",
|
|
"Sunday"
|
|
];
|
|
|
|
private static readonly IReadOnlyList<int> AllowedSessionLengths = [30, 60, 90, 120, 150, 180, 240, 300, 360, 480, 600, 720];
|
|
|
|
private sealed record WritingDayAvailability(string Day, DayOfWeek DayOfWeek, int Minutes);
|
|
|
|
private sealed record AvailableWritingDate(DateTime Date, int Minutes);
|
|
|
|
private sealed record ScheduleTaskEstimate(WritingScheduleScene Scene, string TaskType, int EstimatedMinutes, int? TargetWords);
|
|
|
|
public async Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId)
|
|
{
|
|
var plans = await writingSchedule.GetWritingPlansByUserAsync(userId);
|
|
return plans.Select(ToViewModel).ToList();
|
|
}
|
|
|
|
public async Task<WritingPlanViewModel?> GetWritingPlanByIDAsync(int writingPlanId)
|
|
{
|
|
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
|
|
return plan is null ? null : ToViewModel(plan);
|
|
}
|
|
|
|
public Task<int> CreateWritingPlanAsync(WritingPlanViewModel model)
|
|
{
|
|
ValidatePlan(model);
|
|
return writingSchedule.CreateWritingPlanAsync(ToModel(model));
|
|
}
|
|
|
|
public Task UpdateWritingPlanAsync(WritingPlanViewModel model)
|
|
{
|
|
ValidatePlan(model);
|
|
return writingSchedule.UpdateWritingPlanAsync(ToModel(model));
|
|
}
|
|
|
|
public async Task DeleteWritingPlanAsync(int writingPlanId)
|
|
{
|
|
await GetPlanForCurrentUserAsync(writingPlanId);
|
|
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
|
|
await writingSchedule.DeleteWritingPlanAsync(writingPlanId);
|
|
}
|
|
|
|
public async Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive)
|
|
{
|
|
await GetPlanForCurrentUserAsync(writingPlanId);
|
|
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByPlanAsync(int writingPlanId)
|
|
{
|
|
var items = await writingSchedule.GetScheduleItemsByPlanAsync(writingPlanId);
|
|
return items.Select(ToViewModel).ToList();
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GetScheduleItemsByDateRangeAsync(int writingPlanId, DateTime startDate, DateTime endDate)
|
|
{
|
|
var items = await writingSchedule.GetScheduleItemsByDateRangeAsync(writingPlanId, startDate, endDate);
|
|
return items.Select(ToViewModel).ToList();
|
|
}
|
|
|
|
public Task<int> CreateScheduleItemAsync(WritingScheduleItemViewModel model)
|
|
{
|
|
ValidateScheduleItem(model);
|
|
return writingSchedule.CreateScheduleItemAsync(ToModel(model));
|
|
}
|
|
|
|
public Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model)
|
|
{
|
|
ValidateScheduleItem(model);
|
|
return writingSchedule.UpdateScheduleItemAsync(ToModel(model));
|
|
}
|
|
|
|
public Task DeleteScheduleItemAsync(int writingScheduleItemId) =>
|
|
writingSchedule.DeleteScheduleItemAsync(writingScheduleItemId);
|
|
|
|
public Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) =>
|
|
writingSchedule.UpdateScheduleItemStatusAsync(writingScheduleItemId, scheduleStatus, completedDateUtc);
|
|
|
|
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId)
|
|
{
|
|
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId)
|
|
?? throw new InvalidOperationException("Writing plan not found.");
|
|
if (currentUser.UserId.HasValue && plan.UserID != currentUser.UserId.Value)
|
|
{
|
|
throw new InvalidOperationException("Writing plan not found.");
|
|
}
|
|
|
|
var planViewModel = ToViewModel(plan);
|
|
ValidatePlan(planViewModel);
|
|
|
|
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
|
|
if (availability.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Available days must include at least one valid day.");
|
|
}
|
|
|
|
var scenes = await writingSchedule.GetSchedulingScenesAsync(plan.ProjectID, plan.BookID, plan.IncludeBlockedScenes);
|
|
if (scenes.Count == 0)
|
|
{
|
|
await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, []);
|
|
return [];
|
|
}
|
|
|
|
var availableDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availability);
|
|
var items = BuildGeneratedScheduleItems(plan.WritingPlanID, scenes, availableDates);
|
|
|
|
await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items);
|
|
|
|
var savedItems = await writingSchedule.GetScheduleItemsByPlanAsync(plan.WritingPlanID);
|
|
return savedItems.Select(ToViewModel).ToList();
|
|
}
|
|
|
|
public async Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new WritingSchedulePreviewViewModel();
|
|
}
|
|
|
|
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value);
|
|
var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today);
|
|
var selectedPlan = writingPlanId.HasValue
|
|
? plans.FirstOrDefault(x => x.WritingPlanID == writingPlanId.Value)
|
|
: plans.FirstOrDefault();
|
|
var items = selectedPlan is null
|
|
? []
|
|
: await writingSchedule.GetSchedulePreviewItemsAsync(selectedPlan.WritingPlanID);
|
|
|
|
return new WritingSchedulePreviewViewModel
|
|
{
|
|
SelectedWritingPlanID = selectedPlan?.WritingPlanID,
|
|
Plans = plans,
|
|
PlansRequiringRebalance = plansRequiringRebalance,
|
|
SelectedPlan = selectedPlan,
|
|
Items = items
|
|
};
|
|
}
|
|
|
|
public async Task DeleteScheduleAsync(int writingPlanId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value);
|
|
if (plans.Any(x => x.WritingPlanID == writingPlanId))
|
|
{
|
|
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
|
|
}
|
|
}
|
|
|
|
public async Task<WritingPlansManagementViewModel> GetWritingPlansForManagementAsync()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new WritingPlansManagementViewModel();
|
|
}
|
|
|
|
var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value);
|
|
foreach (var plan in plans)
|
|
{
|
|
plan.AvailabilitySummary = FormatAvailabilitySummary(plan.AvailableDaysJson, plan.SessionLengthMinutes);
|
|
}
|
|
|
|
return new WritingPlansManagementViewModel { Plans = plans };
|
|
}
|
|
|
|
public async Task<WritingPlanSettingsEditViewModel> GetWritingPlanSettingsAsync(int writingPlanId)
|
|
{
|
|
var plan = await GetPlanForCurrentUserAsync(writingPlanId);
|
|
var project = await projects.GetAsync(plan.ProjectID);
|
|
var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null;
|
|
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
|
|
var selectedDays = availability
|
|
.Select(x => x.Day)
|
|
.ToList();
|
|
|
|
var model = new WritingPlanSettingsEditViewModel
|
|
{
|
|
WritingPlanID = plan.WritingPlanID,
|
|
PlanName = plan.PlanName,
|
|
ProjectName = project?.ProjectName ?? string.Empty,
|
|
BookName = book?.BookDisplayTitle ?? "All Books",
|
|
GoalType = plan.GoalType,
|
|
StartDate = plan.StartDate.Date,
|
|
DeadlineDate = plan.DeadlineDate.Date,
|
|
SessionLengthMinutes = plan.SessionLengthMinutes,
|
|
SelectedWritingDays = selectedDays,
|
|
WritingDayAvailability = BuildWritingDayAvailabilityInputs(availability, plan.SessionLengthMinutes).ToList(),
|
|
IncludeBlockedScenes = plan.IncludeBlockedScenes,
|
|
RebalanceMode = plan.RebalanceMode,
|
|
IsActive = plan.IsActive
|
|
};
|
|
PopulatePlanSettingsOptions(model);
|
|
return model;
|
|
}
|
|
|
|
public async Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model)
|
|
{
|
|
var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID);
|
|
ValidatePlanSettings(model);
|
|
var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
|
|
if (availability.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Select at least one writing day.");
|
|
}
|
|
|
|
plan.PlanName = model.PlanName.Trim();
|
|
plan.DeadlineDate = model.DeadlineDate.Date;
|
|
plan.AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability));
|
|
plan.SessionLengthMinutes = availability.First().Minutes;
|
|
plan.IncludeBlockedScenes = model.IncludeBlockedScenes;
|
|
plan.RebalanceMode = model.RebalanceMode;
|
|
plan.IsActive = model.IsActive;
|
|
|
|
await writingSchedule.UpdateWritingPlanAsync(plan);
|
|
}
|
|
|
|
public async Task ActivateWritingPlanAsync(int writingPlanId)
|
|
{
|
|
await GetPlanForCurrentUserAsync(writingPlanId);
|
|
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, true);
|
|
}
|
|
|
|
public async Task DeactivateWritingPlanAsync(int writingPlanId)
|
|
{
|
|
await GetPlanForCurrentUserAsync(writingPlanId);
|
|
await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, false);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WritingScheduleItemViewModel>> RegenerateScheduleAsync(int writingPlanId)
|
|
{
|
|
await GetPlanForCurrentUserAsync(writingPlanId);
|
|
await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId);
|
|
return await GenerateSchedule(writingPlanId);
|
|
}
|
|
|
|
private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model)
|
|
{
|
|
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
|
|
model.SessionLengthOptions = SessionLengthOptionList();
|
|
if (model.WritingDayAvailability.Count == 0)
|
|
{
|
|
model.WritingDayAvailability = BuildWritingDayAvailabilityInputs(
|
|
model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
|
|
model.SessionLengthMinutes).ToList();
|
|
}
|
|
}
|
|
|
|
public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId)
|
|
{
|
|
var projectRows = currentUser.UserId.HasValue
|
|
? await projects.ListForUserAsync(currentUser.UserId.Value)
|
|
: [];
|
|
var selectedProjectId = projectId.HasValue && projectRows.Any(x => x.ProjectID == projectId.Value)
|
|
? projectId
|
|
: projectRows.FirstOrDefault()?.ProjectID;
|
|
|
|
var model = new WritingScheduleSetupViewModel
|
|
{
|
|
ProjectID = selectedProjectId,
|
|
StartDate = DateTime.Today,
|
|
DeadlineDate = DateTime.Today.AddMonths(1)
|
|
};
|
|
|
|
await PopulateScheduleSetupOptionsAsync(model);
|
|
return model;
|
|
}
|
|
|
|
public async Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model)
|
|
{
|
|
var projectRows = currentUser.UserId.HasValue
|
|
? await projects.ListForUserAsync(currentUser.UserId.Value)
|
|
: [];
|
|
var selectedProject = model.ProjectID.HasValue
|
|
? projectRows.FirstOrDefault(x => x.ProjectID == model.ProjectID.Value)
|
|
: null;
|
|
var bookRows = selectedProject is null
|
|
? []
|
|
: await books.ListByProjectAsync(selectedProject.ProjectID);
|
|
|
|
model.ProjectOptions = projectRows
|
|
.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == model.ProjectID))
|
|
.ToList();
|
|
model.BookOptions = bookRows
|
|
.Select(x => new SelectListItem(x.BookDisplayTitle, x.BookID.ToString(), x.BookID == model.BookID))
|
|
.ToList();
|
|
model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType);
|
|
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
|
|
model.SessionLengthOptions = SessionLengthOptionList();
|
|
if (model.WritingDayAvailability.Count == 0)
|
|
{
|
|
model.WritingDayAvailability = BuildWritingDayAvailabilityInputs(
|
|
model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
|
|
model.SessionLengthMinutes).ToList();
|
|
}
|
|
}
|
|
|
|
public async Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Sign in before creating a writing schedule.");
|
|
}
|
|
|
|
ValidateSetup(model);
|
|
var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
|
|
if (availability.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Select at least one writing day.");
|
|
}
|
|
|
|
var projectRows = await projects.ListForUserAsync(currentUser.UserId.Value);
|
|
if (!model.ProjectID.HasValue || !projectRows.Any(x => x.ProjectID == model.ProjectID.Value))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid project.");
|
|
}
|
|
|
|
if (model.BookID.HasValue)
|
|
{
|
|
var bookRows = await books.ListByProjectAsync(model.ProjectID.Value);
|
|
if (!bookRows.Any(x => x.BookID == model.BookID.Value))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid book.");
|
|
}
|
|
}
|
|
|
|
var planId = await CreateWritingPlanAsync(new WritingPlanViewModel
|
|
{
|
|
UserID = currentUser.UserId.Value,
|
|
ProjectID = model.ProjectID!.Value,
|
|
BookID = model.BookID,
|
|
PlanName = model.PlanName.Trim(),
|
|
GoalType = model.GoalType,
|
|
StartDate = model.StartDate.Date,
|
|
DeadlineDate = model.DeadlineDate.Date,
|
|
AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability)),
|
|
SessionLengthMinutes = availability.First().Minutes,
|
|
IncludeBlockedScenes = model.IncludeBlockedScenes,
|
|
RebalanceMode = model.RebalanceMode,
|
|
IsActive = true
|
|
});
|
|
|
|
try
|
|
{
|
|
var generatedItems = await GenerateSchedule(planId);
|
|
return new WritingScheduleSetupResult
|
|
{
|
|
WritingPlanID = planId,
|
|
GeneratedItemCount = generatedItems.Count
|
|
};
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return new WritingScheduleSetupResult
|
|
{
|
|
WritingPlanID = planId,
|
|
ValidationMessage = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync(int? projectId = null)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new WritingScheduleDashboardViewModel();
|
|
}
|
|
|
|
var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value, projectId);
|
|
var activePlanCount = plans.Count(x => x.IsActive);
|
|
var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today, projectId);
|
|
var items = await writingSchedule.GetActiveScheduleDashboardItemsAsync(currentUser.UserId.Value, projectId);
|
|
var today = DateTime.Today;
|
|
var plannedItems = items
|
|
.Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
return new WritingScheduleDashboardViewModel
|
|
{
|
|
ActivePlanCount = activePlanCount,
|
|
RebalancePlanCount = plansRequiringRebalance.Count,
|
|
TodayTasks = plannedItems
|
|
.Where(x => x.ScheduledDate.Date == today)
|
|
.OrderByDescending(x => x.Priority ?? 0)
|
|
.ThenBy(x => x.ScheduledDate)
|
|
.ThenBy(x => x.ChapterSortOrder)
|
|
.ThenBy(x => x.SceneSortOrder)
|
|
.ThenBy(x => x.WritingScheduleItemID)
|
|
.ToList(),
|
|
UpcomingTasks = plannedItems
|
|
.Where(x => x.ScheduledDate.Date > today)
|
|
.OrderBy(x => x.ScheduledDate)
|
|
.ThenBy(x => x.WritingScheduleItemID)
|
|
.Take(5)
|
|
.ToList(),
|
|
TotalPlannedTasks = items.Count,
|
|
CompletedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Done", StringComparison.OrdinalIgnoreCase)),
|
|
SkippedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Skipped", StringComparison.OrdinalIgnoreCase)),
|
|
RemainingTasks = plannedItems.Count,
|
|
ProjectedFinishDate = plannedItems.Count == 0 ? null : plannedItems.Max(x => x.ScheduledDate)
|
|
};
|
|
}
|
|
|
|
public async Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync(
|
|
writingScheduleItemId,
|
|
currentUser.UserId.Value,
|
|
"Done",
|
|
DateTime.UtcNow);
|
|
return affectedRows > 0;
|
|
}
|
|
|
|
public async Task<bool> SkipScheduleItemAsync(int writingScheduleItemId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync(
|
|
writingScheduleItemId,
|
|
currentUser.UserId.Value,
|
|
"Skipped",
|
|
null);
|
|
return affectedRows > 0;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int? projectId = null)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today, projectId);
|
|
}
|
|
|
|
public async Task<WritingScheduleRebalancePreviewViewModel> PreviewRebalanceAsync(int writingPlanId)
|
|
{
|
|
var plan = await GetActivePlanForCurrentUserAsync(writingPlanId);
|
|
var plannedItems = (await writingSchedule.GetSchedulePreviewItemsAsync(plan.WritingPlanID))
|
|
.Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
if (plannedItems.Count == 0)
|
|
{
|
|
return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID };
|
|
}
|
|
|
|
var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
|
|
if (availability.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Available days must include at least one valid day.");
|
|
}
|
|
|
|
var proposedDates = GetNextAvailableDates(DateTime.Today, availability, plannedItems);
|
|
var changes = plannedItems
|
|
.Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] })
|
|
.Where(x => x.Item.ScheduledDate.Date != x.ProposedDate.Date)
|
|
.Select(x => new WritingScheduleRebalanceChangeViewModel
|
|
{
|
|
WritingScheduleItemID = x.Item.WritingScheduleItemID,
|
|
TaskType = x.Item.TaskType,
|
|
SceneName = $"Scene {x.Item.SceneNumber}: {x.Item.SceneTitle}",
|
|
CurrentDate = x.Item.ScheduledDate.Date,
|
|
ProposedDate = x.ProposedDate.Date
|
|
})
|
|
.ToList();
|
|
|
|
return new WritingScheduleRebalancePreviewViewModel
|
|
{
|
|
WritingPlanID = plan.WritingPlanID,
|
|
OriginalFinishDate = plannedItems.Max(x => x.ScheduledDate.Date),
|
|
ProposedFinishDate = proposedDates.Max(),
|
|
Changes = changes
|
|
};
|
|
}
|
|
|
|
public async Task<WritingScheduleRebalancePreviewViewModel> ApplyRebalanceAsync(int writingPlanId)
|
|
{
|
|
var preview = await PreviewRebalanceAsync(writingPlanId);
|
|
await writingSchedule.UpdatePlannedScheduleItemDatesAsync(
|
|
preview.WritingPlanID,
|
|
preview.Changes
|
|
.Select(x => new WritingScheduleItemDateChange
|
|
{
|
|
WritingScheduleItemID = x.WritingScheduleItemID,
|
|
ScheduledDate = x.ProposedDate
|
|
})
|
|
.ToList());
|
|
return preview;
|
|
}
|
|
|
|
private async Task<WritingPlan> GetActivePlanForCurrentUserAsync(int writingPlanId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Sign in before rebalancing a writing schedule.");
|
|
}
|
|
|
|
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
|
|
if (plan is null || plan.UserID != currentUser.UserId.Value || !plan.IsActive)
|
|
{
|
|
throw new InvalidOperationException("Writing plan not found or is not active.");
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
private async Task<WritingPlan> GetPlanForCurrentUserAsync(int writingPlanId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Sign in before managing a writing schedule.");
|
|
}
|
|
|
|
var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId);
|
|
if (plan is null || plan.UserID != currentUser.UserId.Value)
|
|
{
|
|
throw new InvalidOperationException("Writing plan not found.");
|
|
}
|
|
|
|
return plan;
|
|
}
|
|
|
|
private static void ValidateSetup(WritingScheduleSetupViewModel model)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(model.PlanName))
|
|
{
|
|
throw new InvalidOperationException("Plan name is required.");
|
|
}
|
|
|
|
if (!model.ProjectID.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Choose a project.");
|
|
}
|
|
|
|
if (model.DeadlineDate.Date < model.StartDate.Date)
|
|
{
|
|
throw new InvalidOperationException("Deadline date must be on or after the start date.");
|
|
}
|
|
|
|
ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
|
|
}
|
|
|
|
private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(model.PlanName))
|
|
{
|
|
throw new InvalidOperationException("Plan name is required.");
|
|
}
|
|
|
|
if (model.DeadlineDate.Date < model.StartDate.Date)
|
|
{
|
|
throw new InvalidOperationException("Deadline date must be on or after the start date.");
|
|
}
|
|
|
|
ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
|
|
}
|
|
|
|
private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue)
|
|
=> values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList();
|
|
|
|
private static IReadOnlyList<SelectListItem> SessionLengthOptionList()
|
|
=>
|
|
[
|
|
new("30 minutes", "30"),
|
|
new("1 hour", "60"),
|
|
new("1 1/2 hours", "90"),
|
|
new("2 hours", "120"),
|
|
new("2 1/2 hours", "150"),
|
|
new("3 hours", "180"),
|
|
new("4 hours", "240"),
|
|
new("5 hours", "300"),
|
|
new("6 hours", "360"),
|
|
new("8 hours", "480"),
|
|
new("10 hours", "600"),
|
|
new("12 hours", "720")
|
|
];
|
|
|
|
private static void ValidateAvailability(IReadOnlyList<WritingDayAvailabilityViewModel> availabilityInputs, int fallbackSessionLengthMinutes)
|
|
{
|
|
var availability = NormaliseWritingAvailability(availabilityInputs, fallbackSessionLengthMinutes);
|
|
if (availability.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Select at least one writing day.");
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<WritingDayAvailability> NormaliseWritingAvailability(IReadOnlyList<WritingDayAvailabilityViewModel>? availabilityInputs, int fallbackSessionLengthMinutes)
|
|
{
|
|
var inputs = availabilityInputs ?? [];
|
|
var availability = new List<WritingDayAvailability>();
|
|
foreach (var day in WritingDays)
|
|
{
|
|
var input = inputs.FirstOrDefault(x => string.Equals(x.Day, day, StringComparison.OrdinalIgnoreCase));
|
|
if (input is null || !input.IsAvailable)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var minutes = input.Minutes > 0 ? input.Minutes : fallbackSessionLengthMinutes;
|
|
if (!AllowedSessionLengths.Contains(minutes))
|
|
{
|
|
throw new InvalidOperationException("Selected writing days must have a valid session length.");
|
|
}
|
|
|
|
if (TryBuildAvailability(day, minutes, out var dayAvailability))
|
|
{
|
|
availability.Add(dayAvailability);
|
|
}
|
|
}
|
|
|
|
return availability;
|
|
}
|
|
|
|
private static IReadOnlyList<WritingDayAvailabilityViewModel> BuildWritingDayAvailabilityInputs(IReadOnlyList<WritingDayAvailability> availability, int fallbackSessionLengthMinutes)
|
|
{
|
|
var availabilityByDay = availability.ToDictionary(x => x.Day, StringComparer.OrdinalIgnoreCase);
|
|
var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes) ? fallbackSessionLengthMinutes : 60;
|
|
return WritingDays
|
|
.Select(day =>
|
|
{
|
|
availabilityByDay.TryGetValue(day, out var dayAvailability);
|
|
return new WritingDayAvailabilityViewModel
|
|
{
|
|
Day = day,
|
|
IsAvailable = dayAvailability is not null,
|
|
Minutes = dayAvailability?.Minutes ?? fallbackMinutes
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<object> ToAvailabilityJson(IReadOnlyList<WritingDayAvailability> availability)
|
|
=> availability.Select(x => new { day = x.Day, minutes = x.Minutes }).Cast<object>().ToList();
|
|
|
|
private static bool TryBuildAvailability(string? dayName, int minutes, out WritingDayAvailability availability)
|
|
{
|
|
availability = default!;
|
|
if (string.IsNullOrWhiteSpace(dayName)
|
|
|| !Enum.TryParse<DayOfWeek>(dayName, ignoreCase: true, out var dayOfWeek)
|
|
|| !AllowedSessionLengths.Contains(minutes))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var canonicalDay = WritingDays.FirstOrDefault(day => string.Equals(day, dayName, StringComparison.OrdinalIgnoreCase));
|
|
if (canonicalDay is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
availability = new WritingDayAvailability(canonicalDay, dayOfWeek, minutes);
|
|
return true;
|
|
}
|
|
|
|
private static IReadOnlyList<WritingDayAvailability> OrderAvailability(IEnumerable<WritingDayAvailability> availability)
|
|
{
|
|
var order = WritingDays.Select((day, index) => new { day, index }).ToDictionary(x => x.day, x => x.index, StringComparer.OrdinalIgnoreCase);
|
|
return availability
|
|
.OrderBy(x => order.TryGetValue(x.Day, out var index) ? index : int.MaxValue)
|
|
.ToList();
|
|
}
|
|
|
|
private static string FormatAvailabilitySummary(string? availableDaysJson, int fallbackSessionLengthMinutes)
|
|
{
|
|
var availability = ParseWritingAvailability(availableDaysJson, fallbackSessionLengthMinutes);
|
|
return availability.Count == 0
|
|
? "-"
|
|
: string.Join(", ", availability.Select(x => $"{x.Day[..3]} {FormatMinutes(x.Minutes)}"));
|
|
}
|
|
|
|
private static string FormatMinutes(int minutes)
|
|
{
|
|
if (minutes < 60)
|
|
{
|
|
return $"{minutes}m";
|
|
}
|
|
|
|
var hours = minutes / 60;
|
|
var remainder = minutes % 60;
|
|
return remainder == 0
|
|
? $"{hours}h"
|
|
: $"{hours}.{remainder / 30 * 5}h";
|
|
}
|
|
|
|
private static void ValidatePlan(WritingPlanViewModel model)
|
|
{
|
|
if (model.DeadlineDate.Date < model.StartDate.Date)
|
|
{
|
|
throw new InvalidOperationException("Deadline date must be on or after the start date.");
|
|
}
|
|
|
|
if (model.SessionLengthMinutes <= 0)
|
|
{
|
|
throw new InvalidOperationException("Session length must be greater than zero.");
|
|
}
|
|
}
|
|
|
|
private static void ValidateScheduleItem(WritingScheduleItemViewModel model)
|
|
{
|
|
if (model.TargetWords < 0)
|
|
{
|
|
throw new InvalidOperationException("Target words cannot be negative.");
|
|
}
|
|
|
|
if (model.EstimatedMinutes < 0)
|
|
{
|
|
throw new InvalidOperationException("Estimated minutes cannot be negative.");
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<WritingDayAvailability> ParseWritingAvailability(string? availableDaysJson, int fallbackSessionLengthMinutes)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(availableDaysJson))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(availableDaysJson);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes)
|
|
? fallbackSessionLengthMinutes
|
|
: 60;
|
|
var availability = new List<WritingDayAvailability>();
|
|
|
|
foreach (var element in document.RootElement.EnumerateArray())
|
|
{
|
|
string? dayName = null;
|
|
var minutes = fallbackMinutes;
|
|
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
{
|
|
dayName = element.GetString();
|
|
}
|
|
else if (element.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (element.TryGetProperty("day", out var dayProperty) && dayProperty.ValueKind == JsonValueKind.String)
|
|
{
|
|
dayName = dayProperty.GetString();
|
|
}
|
|
else if (element.TryGetProperty("Day", out var pascalDayProperty) && pascalDayProperty.ValueKind == JsonValueKind.String)
|
|
{
|
|
dayName = pascalDayProperty.GetString();
|
|
}
|
|
|
|
if (element.TryGetProperty("minutes", out var minutesProperty) && minutesProperty.TryGetInt32(out var parsedMinutes))
|
|
{
|
|
minutes = parsedMinutes;
|
|
}
|
|
else if (element.TryGetProperty("Minutes", out var pascalMinutesProperty) && pascalMinutesProperty.TryGetInt32(out var parsedPascalMinutes))
|
|
{
|
|
minutes = parsedPascalMinutes;
|
|
}
|
|
}
|
|
|
|
if (TryBuildAvailability(dayName, minutes, out var dayAvailability)
|
|
&& !availability.Any(x => x.DayOfWeek == dayAvailability.DayOfWeek))
|
|
{
|
|
availability.Add(dayAvailability);
|
|
}
|
|
}
|
|
|
|
return OrderAvailability(availability);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<AvailableWritingDate> GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlyList<WritingDayAvailability> availability)
|
|
{
|
|
var dates = new List<AvailableWritingDate>();
|
|
var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek);
|
|
for (var date = startDate.Date; date <= deadlineDate.Date; date = date.AddDays(1))
|
|
{
|
|
if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability))
|
|
{
|
|
dates.Add(new AvailableWritingDate(date, dayAvailability.Minutes));
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
private static IReadOnlyList<DateTime> GetNextAvailableDates(DateTime startDate, IReadOnlyList<WritingDayAvailability> availability, IReadOnlyList<WritingSchedulePreviewItem> plannedItems)
|
|
{
|
|
var dates = new List<DateTime>();
|
|
var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek);
|
|
var maxAvailableMinutes = availability.Count == 0 ? 0 : availability.Max(x => x.Minutes);
|
|
if (plannedItems.Any(x => (x.EstimatedMinutes ?? 0) > maxAvailableMinutes))
|
|
{
|
|
throw new InvalidOperationException("One or more schedule items exceed the available session lengths for this plan.");
|
|
}
|
|
|
|
foreach (var item in plannedItems)
|
|
{
|
|
var estimatedMinutes = item.EstimatedMinutes ?? 0;
|
|
var placed = false;
|
|
for (var date = dates.Count == 0 ? startDate.Date : dates[^1].AddDays(1); !placed; date = date.AddDays(1))
|
|
{
|
|
if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability)
|
|
&& dayAvailability.Minutes >= estimatedMinutes)
|
|
{
|
|
dates.Add(date);
|
|
placed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
private static IReadOnlyList<WritingScheduleItem> BuildGeneratedScheduleItems(int writingPlanId, IReadOnlyList<WritingScheduleScene> scenes, IReadOnlyList<AvailableWritingDate> availableDates)
|
|
{
|
|
var items = new List<WritingScheduleItem>();
|
|
var dateIndex = 0;
|
|
|
|
foreach (var scene in scenes)
|
|
{
|
|
var task = CreateTaskEstimate(scene);
|
|
var taskDates = new List<AvailableWritingDate>();
|
|
var remainingMinutes = task.EstimatedMinutes;
|
|
|
|
while (remainingMinutes > 0)
|
|
{
|
|
if (dateIndex >= availableDates.Count)
|
|
{
|
|
throw new InvalidOperationException("Insufficient available writing sessions before the selected deadline.");
|
|
}
|
|
|
|
var availableDate = availableDates[dateIndex++];
|
|
taskDates.Add(availableDate);
|
|
remainingMinutes -= Math.Min(remainingMinutes, availableDate.Minutes);
|
|
}
|
|
|
|
var partCount = taskDates.Count;
|
|
var remainingTaskMinutes = task.EstimatedMinutes;
|
|
var assignedWords = 0;
|
|
|
|
for (var partIndex = 0; partIndex < partCount; partIndex++)
|
|
{
|
|
var partMinutes = Math.Min(remainingTaskMinutes, taskDates[partIndex].Minutes);
|
|
remainingTaskMinutes -= partMinutes;
|
|
int? targetWords = null;
|
|
|
|
if (task.TargetWords.HasValue)
|
|
{
|
|
targetWords = partIndex == partCount - 1
|
|
? task.TargetWords.Value - assignedWords
|
|
: (int)Math.Round(task.TargetWords.Value * (partMinutes / (double)task.EstimatedMinutes), MidpointRounding.AwayFromZero);
|
|
assignedWords += targetWords.Value;
|
|
}
|
|
|
|
items.Add(new WritingScheduleItem
|
|
{
|
|
WritingPlanID = writingPlanId,
|
|
SceneID = task.Scene.SceneID,
|
|
ScheduledDate = taskDates[partIndex].Date,
|
|
TaskType = task.TaskType,
|
|
EstimatedMinutes = partMinutes,
|
|
TargetWords = targetWords,
|
|
ScheduleStatus = "Planned",
|
|
Notes = partCount > 1 ? $"Part {partIndex + 1} of {partCount}" : null
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private static ScheduleTaskEstimate CreateTaskEstimate(WritingScheduleScene scene)
|
|
{
|
|
var (taskType, estimatedMinutes, targetWords) = scene.RevisionStatusName switch
|
|
{
|
|
"Outlined" => ("Draft", EstimateDraftMinutes(scene.EstimatedWordCount), scene.EstimatedWordCount > 0 ? scene.EstimatedWordCount : null),
|
|
"Needs Work" => ("Revise", 45, null),
|
|
"Revised" => ("Polish", 30, null),
|
|
_ => throw new InvalidOperationException("Scene revision status is not schedulable.")
|
|
};
|
|
|
|
return new ScheduleTaskEstimate(scene, taskType, estimatedMinutes, targetWords);
|
|
}
|
|
|
|
private static int EstimateDraftMinutes(int? estimatedWordCount)
|
|
=> estimatedWordCount is > 0
|
|
? (int)Math.Ceiling(estimatedWordCount.GetValueOrDefault() / 750.0 * 60)
|
|
: 90;
|
|
|
|
private static WritingPlanViewModel ToViewModel(WritingPlan plan) => new()
|
|
{
|
|
WritingPlanID = plan.WritingPlanID,
|
|
UserID = plan.UserID,
|
|
ProjectID = plan.ProjectID,
|
|
BookID = plan.BookID,
|
|
PlanName = plan.PlanName,
|
|
GoalType = plan.GoalType,
|
|
StartDate = plan.StartDate,
|
|
DeadlineDate = plan.DeadlineDate,
|
|
AvailableDaysJson = plan.AvailableDaysJson,
|
|
SessionLengthMinutes = plan.SessionLengthMinutes,
|
|
IncludeBlockedScenes = plan.IncludeBlockedScenes,
|
|
RebalanceMode = plan.RebalanceMode,
|
|
IsActive = plan.IsActive,
|
|
CreatedDateUtc = plan.CreatedDateUtc,
|
|
ModifiedDateUtc = plan.ModifiedDateUtc
|
|
};
|
|
|
|
private static WritingPlan ToModel(WritingPlanViewModel model) => new()
|
|
{
|
|
WritingPlanID = model.WritingPlanID,
|
|
UserID = model.UserID,
|
|
ProjectID = model.ProjectID,
|
|
BookID = model.BookID,
|
|
PlanName = model.PlanName,
|
|
GoalType = model.GoalType,
|
|
StartDate = model.StartDate,
|
|
DeadlineDate = model.DeadlineDate,
|
|
AvailableDaysJson = model.AvailableDaysJson,
|
|
SessionLengthMinutes = model.SessionLengthMinutes,
|
|
IncludeBlockedScenes = model.IncludeBlockedScenes,
|
|
RebalanceMode = model.RebalanceMode,
|
|
IsActive = model.IsActive,
|
|
CreatedDateUtc = model.CreatedDateUtc,
|
|
ModifiedDateUtc = model.ModifiedDateUtc
|
|
};
|
|
|
|
private static WritingScheduleItemViewModel ToViewModel(WritingScheduleItem item) => new()
|
|
{
|
|
WritingScheduleItemID = item.WritingScheduleItemID,
|
|
WritingPlanID = item.WritingPlanID,
|
|
SceneID = item.SceneID,
|
|
ScheduledDate = item.ScheduledDate,
|
|
TaskType = item.TaskType,
|
|
EstimatedMinutes = item.EstimatedMinutes,
|
|
TargetWords = item.TargetWords,
|
|
ScheduleStatus = item.ScheduleStatus,
|
|
CompletedDateUtc = item.CompletedDateUtc,
|
|
Notes = item.Notes,
|
|
CreatedDateUtc = item.CreatedDateUtc,
|
|
ModifiedDateUtc = item.ModifiedDateUtc
|
|
};
|
|
|
|
private static WritingScheduleItem ToModel(WritingScheduleItemViewModel model) => new()
|
|
{
|
|
WritingScheduleItemID = model.WritingScheduleItemID,
|
|
WritingPlanID = model.WritingPlanID,
|
|
SceneID = model.SceneID,
|
|
ScheduledDate = model.ScheduledDate,
|
|
TaskType = model.TaskType,
|
|
EstimatedMinutes = model.EstimatedMinutes,
|
|
TargetWords = model.TargetWords,
|
|
ScheduleStatus = model.ScheduleStatus,
|
|
CompletedDateUtc = model.CompletedDateUtc,
|
|
Notes = model.Notes,
|
|
CreatedDateUtc = model.CreatedDateUtc,
|
|
ModifiedDateUtc = model.ModifiedDateUtc
|
|
};
|
|
}
|
|
|
|
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.BookDisplayTitle} / 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.BookDisplayTitle} / 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.BookDisplayTitle }))
|
|
{
|
|
builder.AppendLine(markdown ? $"### {book.Key.BookDisplayTitle}" : book.Key.BookDisplayTitle);
|
|
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} [{r.RelationshipCategoryName}]: 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.BookDisplayTitle} / 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.BookDisplayTitle} / 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.BookDisplayTitle}", 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,
|
|
IBookRepository books,
|
|
IBookCoverService bookCovers,
|
|
IProjectActivityService activity,
|
|
ICurrentUserService currentUser) : 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 = currentUser.UserId.HasValue
|
|
? await archives.ListProjectFiltersForUserAsync(currentUser.UserId.Value)
|
|
: Array.Empty<Project>();
|
|
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 = currentUser.UserId.HasValue
|
|
? await archives.ListForUserAsync(projectId, normalisedType, currentUser.UserId.Value)
|
|
: Array.Empty<ArchivedItem>()
|
|
};
|
|
}
|
|
|
|
public async Task ArchiveAsync(string entityType, int entityId, string? reason)
|
|
{
|
|
var item = await FindArchivedItemContextAsync(entityType, entityId);
|
|
await archives.ArchiveAsync(entityType, entityId, reason);
|
|
if (item?.ProjectID.HasValue == true)
|
|
{
|
|
await activity.RecordAsync(item.ProjectID.Value, "Archived", entityType, entityId, item.DisplayName, reason);
|
|
}
|
|
}
|
|
|
|
public async Task RestoreAsync(string entityType, int entityId)
|
|
{
|
|
var item = await FindArchivedItemContextAsync(entityType, entityId);
|
|
await archives.RestoreAsync(entityType, entityId);
|
|
if (item?.ProjectID.HasValue == true)
|
|
{
|
|
await activity.RecordAsync(item.ProjectID.Value, "Restored", entityType, entityId, item.DisplayName);
|
|
}
|
|
}
|
|
|
|
public async Task<ArchiveDeleteResult> DeleteForeverAsync(string entityType, int entityId, string confirmationText)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new ArchiveDeleteResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "This archived item could not be found or you do not have permission to delete it."
|
|
};
|
|
}
|
|
|
|
var userId = currentUser.UserId.Value;
|
|
var item = await FindArchivedItemContextAsync(entityType, entityId);
|
|
if (item is null)
|
|
{
|
|
return new ArchiveDeleteResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "This archived item could not be found or you do not have permission to delete it."
|
|
};
|
|
}
|
|
|
|
var bookCoverPaths = string.Equals(entityType, "Book", StringComparison.OrdinalIgnoreCase)
|
|
? await books.GetCoverPathsAsync(entityId)
|
|
: null;
|
|
var result = await archives.DeleteForeverAsync(entityType, entityId, confirmationText, userId);
|
|
if (result.Succeeded && item?.ProjectID.HasValue == true)
|
|
{
|
|
await activity.RecordAsync(item.ProjectID.Value, "Deleted", entityType, entityId, item.DisplayName);
|
|
}
|
|
|
|
if (result.Succeeded && bookCoverPaths is not null)
|
|
{
|
|
await bookCovers.DeleteCoverFilesAsync(bookCoverPaths);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async Task<ArchivedItem?> FindArchivedItemContextAsync(string entityType, int entityId)
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var rows = await archives.ListForUserAsync(null, entityType, currentUser.UserId.Value);
|
|
return rows.FirstOrDefault(x => x.EntityType == entityType && x.EntityID == entityId);
|
|
}
|
|
}
|
|
|
|
public sealed class AcceptanceService(IProjectRepository projects, IAcceptanceRepository acceptance, ICurrentUserService currentUser) : IAcceptanceService
|
|
{
|
|
public async Task<Phase1AcceptanceViewModel> GetPhase1ChecklistAsync(int? projectId)
|
|
{
|
|
var projectRows = currentUser.UserId.HasValue
|
|
? await projects.ListForUserAsync(currentUser.UserId.Value)
|
|
: Array.Empty<Project>();
|
|
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 StorageService(
|
|
IBookRepository books,
|
|
IChapterRepository chapters,
|
|
ISceneRepository scenes,
|
|
IProjectCollaborationRepository collaboration,
|
|
ISubscriptionService subscriptions,
|
|
IUserFileRepository userFiles) : IStorageService
|
|
{
|
|
public async Task<StorageUsage?> GetSceneStorageUsageAsync(int sceneId)
|
|
{
|
|
var context = await GetSceneOwnerContextAsync(sceneId);
|
|
return context is null ? null : await subscriptions.GetStorageUsageAsync(context.Value.OwnerUserID);
|
|
}
|
|
|
|
public async Task<StorageLimitResult?> CanUploadSceneAttachmentAsync(int sceneId, long fileSizeBytes)
|
|
{
|
|
var context = await GetSceneOwnerContextAsync(sceneId);
|
|
return context is null ? null : await subscriptions.CanUploadFileAsync(context.Value.OwnerUserID, fileSizeBytes);
|
|
}
|
|
|
|
public async Task TrackSceneAttachmentAsync(int sceneId, int sceneAttachmentId, string storagePath, string originalFileName, string? contentType, long fileSizeBytes)
|
|
{
|
|
var context = await GetSceneOwnerContextAsync(sceneId);
|
|
if (context is null || string.IsNullOrWhiteSpace(storagePath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
await userFiles.CreateAsync(new UserFile
|
|
{
|
|
UserID = context.Value.OwnerUserID,
|
|
ProjectID = context.Value.ProjectID,
|
|
EntityType = "SceneAttachment",
|
|
EntityID = sceneAttachmentId,
|
|
FileName = Path.GetFileName(storagePath),
|
|
OriginalFileName = originalFileName,
|
|
ContentType = contentType,
|
|
FileSizeBytes = fileSizeBytes,
|
|
StoragePath = storagePath
|
|
});
|
|
}
|
|
|
|
public Task MarkDeletedByStoragePathAsync(string? storagePath)
|
|
{
|
|
return string.IsNullOrWhiteSpace(storagePath)
|
|
? Task.CompletedTask
|
|
: userFiles.MarkDeletedByStoragePathAsync(storagePath);
|
|
}
|
|
|
|
private async Task<(int ProjectID, int OwnerUserID)?> GetSceneOwnerContextAsync(int sceneId)
|
|
{
|
|
var scene = await scenes.GetAsync(sceneId);
|
|
var chapter = scene is null ? null : await chapters.GetAsync(scene.ChapterID);
|
|
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
|
|
if (book is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var ownerUserId = await collaboration.GetOwnerUserIdAsync(book.ProjectID);
|
|
return ownerUserId.HasValue ? (book.ProjectID, ownerUserId.Value) : null;
|
|
}
|
|
}
|
|
|
|
public sealed class AssetService(
|
|
IProjectRepository projects,
|
|
IAssetRepository assets,
|
|
ILocationRepository locations,
|
|
IProjectActivityService activity,
|
|
IVisualIdentityImageService visualIdentityImages,
|
|
ICurrentUserService currentUser) : 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,
|
|
Aliases = (await assets.ListAliasesAsync(asset.StoryAssetID)).Select(x => x.Alias).ToList(),
|
|
AssetKindID = asset.AssetKindID,
|
|
Description = asset.Description,
|
|
Importance = asset.Importance,
|
|
CurrentStateID = asset.CurrentStateID,
|
|
CurrentLocationID = asset.CurrentLocationID,
|
|
IsResolved = asset.IsResolved,
|
|
ShowInQuickAddBar = asset.ShowInQuickAddBar,
|
|
ImagePath = asset.ImagePath,
|
|
ThumbnailPath = asset.ThumbnailPath,
|
|
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 async Task<int> SaveAssetAsync(StoryAssetEditViewModel model)
|
|
{
|
|
var isNew = model.StoryAssetID == 0;
|
|
var existing = isNew ? null : await assets.GetAssetAsync(model.StoryAssetID);
|
|
var assetId = await 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,
|
|
ImagePath = model.ImagePath ?? existing?.ImagePath,
|
|
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
|
|
});
|
|
await SyncAssetAliasesAsync(assetId, model.Aliases);
|
|
var imageHost = existing ?? new StoryAsset { StoryAssetID = assetId, ProjectID = model.ProjectID };
|
|
imageHost.StoryAssetID = assetId;
|
|
imageHost.ProjectID = model.ProjectID;
|
|
if (model.ImageUpload is { Length: > 0 } upload)
|
|
{
|
|
var result = await visualIdentityImages.UploadAssetImageAsync(imageHost, upload);
|
|
if (!result.Succeeded)
|
|
{
|
|
throw new EntityImageUploadException(result.ErrorMessage ?? "Asset image could not be uploaded.", assetId);
|
|
}
|
|
}
|
|
else if (model.RemoveImage && existing is not null)
|
|
{
|
|
await visualIdentityImages.RemoveAssetImageAsync(existing);
|
|
}
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Asset", assetId, model.AssetName);
|
|
return assetId;
|
|
}
|
|
|
|
public async Task ArchiveAssetAsync(int storyAssetId)
|
|
{
|
|
var asset = await assets.GetAssetAsync(storyAssetId);
|
|
await assets.ArchiveAssetAsync(storyAssetId);
|
|
if (asset is not null)
|
|
{
|
|
await activity.RecordAsync(asset.ProjectID, "Archived", "Asset", storyAssetId, asset.AssetName);
|
|
}
|
|
}
|
|
|
|
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
|
|
});
|
|
await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Asset", assetId, model.NewAssetName.Trim());
|
|
}
|
|
|
|
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 assetEventId = await assets.SaveEventAsync(new AssetEvent
|
|
{
|
|
StoryAssetID = assetId,
|
|
SceneID = model.SceneID,
|
|
AssetEventTypeID = eventType.AssetEventTypeID,
|
|
FromStateID = model.FromStateID,
|
|
ToStateID = model.ToStateID,
|
|
EventTitle = title,
|
|
EventDescription = model.EventDescription
|
|
});
|
|
var asset = await assets.GetAssetAsync(assetId);
|
|
if (asset is not null)
|
|
{
|
|
await activity.RecordAsync(asset.ProjectID, "Created", "Asset Event", assetEventId, title);
|
|
}
|
|
return assetEventId;
|
|
}
|
|
|
|
public Task DeleteAssetEventAsync(int assetEventId) => assets.DeleteEventAsync(assetEventId);
|
|
|
|
public async Task AcceptSceneAssetSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to review asset suggestions.");
|
|
}
|
|
|
|
var result = await assets.AcceptSceneAssetSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("That asset suggestion is no longer available.");
|
|
}
|
|
}
|
|
|
|
public async Task RejectSceneAssetSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to review asset suggestions.");
|
|
}
|
|
|
|
var result = await assets.RejectSceneAssetSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("That asset suggestion is no longer available.");
|
|
}
|
|
}
|
|
|
|
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 async Task SyncAssetAliasesAsync(int storyAssetId, IEnumerable<string> postedAliases)
|
|
{
|
|
var aliases = AliasInput.Clean(postedAliases);
|
|
var existing = await assets.ListAliasesAsync(storyAssetId);
|
|
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
|
|
|
|
for (var index = 0; index < aliases.Count; index++)
|
|
{
|
|
var alias = aliases[index];
|
|
var sortOrder = (index + 1) * 10;
|
|
if (existingByAlias.TryGetValue(alias, out var row))
|
|
{
|
|
await assets.UpdateAliasAsync(row.AssetAliasID, alias, sortOrder);
|
|
}
|
|
else
|
|
{
|
|
await assets.AddAliasAsync(storyAssetId, alias, sortOrder);
|
|
}
|
|
}
|
|
|
|
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
|
|
{
|
|
await assets.DeleteAliasAsync(alias.AssetAliasID);
|
|
}
|
|
}
|
|
|
|
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,
|
|
IProjectActivityService activity,
|
|
ICurrentUserService currentUser) : 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,
|
|
Aliases = (await locations.ListAliasesAsync(location.LocationID)).Select(x => x.Alias).ToList(),
|
|
LocationTypeID = location.LocationTypeID,
|
|
Description = location.Description,
|
|
ShowInQuickAddBar = location.ShowInQuickAddBar,
|
|
ExcludeFromCompanionDetection = location.ExcludeFromCompanionDetection,
|
|
DetectionPriority = location.DetectionPriority,
|
|
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 async Task<int> SaveAsync(LocationEditViewModel model)
|
|
{
|
|
var isNew = model.LocationID == 0;
|
|
var locationId = await 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,
|
|
ExcludeFromCompanionDetection = model.ExcludeFromCompanionDetection,
|
|
DetectionPriority = model.DetectionPriority
|
|
});
|
|
await SyncLocationAliasesAsync(locationId, model.Aliases);
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName);
|
|
return locationId;
|
|
}
|
|
|
|
public async Task ArchiveAsync(int locationId)
|
|
{
|
|
var location = await locations.GetAsync(locationId);
|
|
await locations.ArchiveAsync(locationId);
|
|
if (location is not null)
|
|
{
|
|
await activity.RecordAsync(location.ProjectID, "Archived", "Location", locationId, location.LocationName);
|
|
}
|
|
}
|
|
|
|
public async Task<int> SaveRelationshipAsync(LocationRelationshipEditViewModel model)
|
|
{
|
|
var isNew = model.LocationRelationshipID == 0;
|
|
var relationshipId = await 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
|
|
});
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Location relationship");
|
|
return relationshipId;
|
|
}
|
|
|
|
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);
|
|
|
|
public async Task AcceptSceneLocationSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to accept location suggestions.");
|
|
}
|
|
|
|
var result = await locations.AcceptSceneLocationSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("Unable to accept this location suggestion.");
|
|
}
|
|
}
|
|
|
|
public async Task RejectSceneLocationSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to reject location suggestions.");
|
|
}
|
|
|
|
var result = await locations.RejectSceneLocationSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("Unable to reject this location suggestion.");
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private async Task SyncLocationAliasesAsync(int locationId, IEnumerable<string> postedAliases)
|
|
{
|
|
var aliases = AliasInput.Clean(postedAliases);
|
|
var existing = await locations.ListAliasesAsync(locationId);
|
|
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
|
|
|
|
for (var index = 0; index < aliases.Count; index++)
|
|
{
|
|
var alias = aliases[index];
|
|
var sortOrder = (index + 1) * 10;
|
|
if (existingByAlias.TryGetValue(alias, out var row))
|
|
{
|
|
await locations.UpdateAliasAsync(row.LocationAliasID, alias, sortOrder);
|
|
}
|
|
else
|
|
{
|
|
await locations.AddAliasAsync(locationId, alias, sortOrder);
|
|
}
|
|
}
|
|
|
|
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
|
|
{
|
|
await locations.DeleteAliasAsync(alias.LocationAliasID);
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class FloorPlanService(
|
|
IProjectRepository projects,
|
|
ISceneRepository scenes,
|
|
ISceneFloorPlanOccupancyRepository sceneOccupancy,
|
|
IFloorPlanRepository floorPlans,
|
|
ILocationRepository locations,
|
|
ICharacterRepository characters,
|
|
IFloorPlanBackgroundImageService backgroundImages,
|
|
IProjectActivityService activity) : IFloorPlanService
|
|
{
|
|
public async Task<FloorPlanListViewModel?> ListAsync(int projectId)
|
|
{
|
|
var project = await projects.GetAsync(projectId);
|
|
var projectLocations = project is null ? [] : await locations.ListByProjectAsync(projectId);
|
|
return project is null ? null : new FloorPlanListViewModel
|
|
{
|
|
Project = project,
|
|
FloorPlans = await floorPlans.ListByProjectAsync(projectId),
|
|
NewFloorPlan = new FloorPlanEditViewModel { ProjectID = projectId, Name = "New floor plan" },
|
|
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create a matching Location")
|
|
};
|
|
}
|
|
|
|
public async Task<FloorPlanEditorViewModel?> GetEditorAsync(int floorPlanId)
|
|
{
|
|
var data = await floorPlans.GetEditorAsync(floorPlanId);
|
|
if (data.FloorPlan is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var project = await projects.GetAsync(data.FloorPlan.ProjectID);
|
|
if (project is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var projectLocations = await locations.ListByProjectAsync(project.ProjectID);
|
|
var projectCharacters = (await characters.ListCharactersAsync(project.ProjectID)).ToDictionary(x => x.CharacterID);
|
|
var orderedFloors = data.Floors.OrderBy(x => x.SortOrder).ThenBy(x => x.Name).ToList();
|
|
var occupancyScenes = await scenes.ListByFloorPlanAsync(floorPlanId);
|
|
var occupancyRows = await sceneOccupancy.ListByFloorPlanAsync(floorPlanId);
|
|
var occupancyParticipants = await sceneOccupancy.ListParticipantsByFloorPlanAsync(floorPlanId);
|
|
var firstBlockByLocation = data.Blocks
|
|
.GroupBy(x => x.LocationID)
|
|
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
|
|
var firstBlockByFloorLocation = data.Blocks
|
|
.GroupBy(x => (x.FloorPlanFloorID, x.LocationID))
|
|
.ToDictionary(x => x.Key, x => x.OrderBy(block => block.Y).ThenBy(block => block.X).First());
|
|
return new FloorPlanEditorViewModel
|
|
{
|
|
Project = project,
|
|
FloorPlan = new FloorPlanEditViewModel
|
|
{
|
|
FloorPlanID = data.FloorPlan.FloorPlanID,
|
|
ProjectID = data.FloorPlan.ProjectID,
|
|
LocationID = data.FloorPlan.LocationID,
|
|
LocationName = data.FloorPlan.LocationName,
|
|
LocationPath = data.FloorPlan.LocationPath,
|
|
Name = data.FloorPlan.Name,
|
|
Description = data.FloorPlan.Description
|
|
},
|
|
Floors = orderedFloors.Select(ToFloorEdit).ToList(),
|
|
Blocks = data.Blocks.Select(ToBlockEdit).ToList(),
|
|
Transitions = data.Transitions.Select(ToTransitionEdit).ToList(),
|
|
NewFloor = new FloorPlanFloorEditViewModel
|
|
{
|
|
FloorPlanID = data.FloorPlan.FloorPlanID,
|
|
Name = "New floor",
|
|
SortOrder = orderedFloors.Any() ? orderedFloors.Max(x => x.SortOrder) + 1 : 0,
|
|
GridWidth = 24,
|
|
GridHeight = 16
|
|
},
|
|
NewBlock = new FloorPlanBlockEditViewModel
|
|
{
|
|
FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
|
|
BlockType = FloorPlanBlockTypes.Room,
|
|
WidthCells = 2,
|
|
HeightCells = 1
|
|
},
|
|
NewTransition = new FloorPlanTransitionEditViewModel
|
|
{
|
|
FloorPlanFloorID = orderedFloors.FirstOrDefault()?.FloorPlanFloorID ?? 0,
|
|
TransitionType = FloorPlanTransitionTypes.Door,
|
|
PositionWithinLocation = FloorPlanTransitionPositions.Centre
|
|
},
|
|
LocationOptions = ToOptionalLocationOptions(projectLocations, "Create/link automatically"),
|
|
Locations = projectLocations,
|
|
BlockTypeOptions = FloorPlanBlockTypes.All.Select(x => new SelectListItem(BlockTypeLabel(x), x)).ToList(),
|
|
TransitionTypeOptions = FloorPlanTransitionTypes.All.Select(x => new SelectListItem(x, x)).ToList(),
|
|
OccupancyScenes = occupancyScenes
|
|
.OrderBy(x => x.BookSortOrder)
|
|
.ThenBy(x => x.BookNumber)
|
|
.ThenBy(x => x.BookDisplayTitle)
|
|
.ThenBy(x => x.ChapterSortOrder)
|
|
.ThenBy(x => x.ChapterNumber)
|
|
.ThenBy(x => x.SceneNumber)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.SceneTitle)
|
|
.Select(x => new FloorPlanOccupancySceneViewModel
|
|
{
|
|
SceneID = x.SceneID,
|
|
InitialFloorPlanFloorID = x.InitialFloorPlanFloorID,
|
|
Label = OccupancySceneLabel(x)
|
|
})
|
|
.ToList(),
|
|
OccupancyTokens = occupancyRows
|
|
.Select(x =>
|
|
{
|
|
FloorPlanBlock? block = null;
|
|
if (x.FloorPlanFloorID.HasValue)
|
|
{
|
|
firstBlockByFloorLocation.TryGetValue((x.FloorPlanFloorID.Value, x.LocationID), out block);
|
|
}
|
|
else
|
|
{
|
|
firstBlockByLocation.TryGetValue(x.LocationID, out block);
|
|
}
|
|
|
|
if (block is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var characterImagePath = x.CharacterImagePath;
|
|
var characterThumbnailPath = x.CharacterThumbnailPath;
|
|
if (x.CharacterID.HasValue && projectCharacters.TryGetValue(x.CharacterID.Value, out var character))
|
|
{
|
|
characterImagePath = character.AvatarImagePath ?? character.ImagePath ?? characterImagePath;
|
|
characterThumbnailPath = character.AvatarThumbnailPath ?? character.ThumbnailPath ?? characterThumbnailPath;
|
|
}
|
|
|
|
return new FloorPlanOccupancyTokenViewModel
|
|
{
|
|
SceneID = x.SceneID,
|
|
FloorPlanFloorID = block.FloorPlanFloorID,
|
|
LocationID = x.LocationID,
|
|
LocationName = x.LocationName,
|
|
EntityType = x.CharacterID.HasValue ? "Character" : "Asset",
|
|
EntityID = x.CharacterID ?? x.StoryAssetID ?? 0,
|
|
DisplayName = x.CharacterName ?? x.AssetName ?? "Occupant",
|
|
ImagePath = characterImagePath ?? x.AssetImagePath,
|
|
ThumbnailPath = characterThumbnailPath ?? x.AssetThumbnailPath
|
|
};
|
|
})
|
|
.Where(x => x is not null)
|
|
.Cast<FloorPlanOccupancyTokenViewModel>()
|
|
.ToList(),
|
|
OccupancyWarnings = BuildFloorPlanOccupancyWarnings(occupancyParticipants, occupancyRows, data.Blocks, data.Transitions)
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<FloorPlanOccupancyWarningViewModel> BuildFloorPlanOccupancyWarnings(
|
|
IReadOnlyList<FloorPlanOccupancyParticipant> participants,
|
|
IReadOnlyList<SceneFloorPlanOccupancy> occupancyRows,
|
|
IReadOnlyList<FloorPlanBlock> blocks,
|
|
IReadOnlyList<FloorPlanTransition> transitions)
|
|
{
|
|
var warnings = new List<FloorPlanOccupancyWarningViewModel>();
|
|
var blocksByLocation = blocks.GroupBy(x => x.LocationID).ToDictionary(x => x.Key, x => x.ToList());
|
|
var floors = blocks.Select(x => x.FloorPlanFloorID).Distinct().ToList();
|
|
var occupancyRowsByScene = occupancyRows.GroupBy(x => x.SceneID).ToDictionary(x => x.Key, x => x.ToList());
|
|
var visibleEntranceLocationIds = transitions
|
|
.Where(x => IsVisibleEntranceTransition(x.TransitionType))
|
|
.SelectMany(x => new[] { x.FromLocationID, x.ToLocationID })
|
|
.ToHashSet();
|
|
|
|
foreach (var sceneGroup in participants.GroupBy(x => x.SceneID))
|
|
{
|
|
var sceneId = sceneGroup.Key;
|
|
var sceneRows = occupancyRowsByScene.GetValueOrDefault(sceneId) ?? [];
|
|
var occupiedCharacterIds = sceneRows.Where(x => x.CharacterID.HasValue).Select(x => x.CharacterID!.Value).ToHashSet();
|
|
var occupiedAssetIds = sceneRows.Where(x => x.StoryAssetID.HasValue).Select(x => x.StoryAssetID!.Value).ToHashSet();
|
|
|
|
foreach (var character in sceneGroup
|
|
.Where(x => string.Equals(x.EntityType, "Character", StringComparison.OrdinalIgnoreCase))
|
|
.GroupBy(x => x.EntityID)
|
|
.Select(x => x.First())
|
|
.Where(x => !occupiedCharacterIds.Contains(x.EntityID)))
|
|
{
|
|
warnings.Add(new FloorPlanOccupancyWarningViewModel
|
|
{
|
|
SceneID = sceneId,
|
|
Severity = "warning",
|
|
Message = $"{character.DisplayName} appears in this scene but has no floor-plan location."
|
|
});
|
|
}
|
|
|
|
foreach (var asset in sceneGroup
|
|
.Where(x => string.Equals(x.EntityType, "Asset", StringComparison.OrdinalIgnoreCase))
|
|
.GroupBy(x => x.EntityID)
|
|
.Select(x => x.First())
|
|
.Where(x => !occupiedAssetIds.Contains(x.EntityID)))
|
|
{
|
|
warnings.Add(new FloorPlanOccupancyWarningViewModel
|
|
{
|
|
SceneID = sceneId,
|
|
Severity = "warning",
|
|
Message = $"{asset.DisplayName} appears in this scene but has no floor-plan location."
|
|
});
|
|
}
|
|
|
|
foreach (var row in sceneRows.Where(x => !blocksByLocation.ContainsKey(x.LocationID)))
|
|
{
|
|
warnings.Add(new FloorPlanOccupancyWarningViewModel
|
|
{
|
|
SceneID = sceneId,
|
|
Severity = "warning",
|
|
Message = $"{OccupantName(row)} is assigned to {row.LocationName}, but that location is not placed on this floor plan."
|
|
});
|
|
}
|
|
|
|
var rowsOnPlacedLocations = sceneRows.Where(x => blocksByLocation.ContainsKey(x.LocationID)).ToList();
|
|
var occupiedFloorIds = rowsOnPlacedLocations
|
|
.SelectMany(x => blocksByLocation[x.LocationID].Select(block => block.FloorPlanFloorID))
|
|
.Distinct()
|
|
.ToHashSet();
|
|
foreach (var floorId in floors.Where(floorId => rowsOnPlacedLocations.Any() && !occupiedFloorIds.Contains(floorId)))
|
|
{
|
|
warnings.Add(new FloorPlanOccupancyWarningViewModel
|
|
{
|
|
SceneID = sceneId,
|
|
FloorPlanFloorID = floorId,
|
|
Severity = "info",
|
|
Message = "Some occupants are on other floors."
|
|
});
|
|
}
|
|
|
|
foreach (var row in rowsOnPlacedLocations.Where(x => !visibleEntranceLocationIds.Contains(x.LocationID)))
|
|
{
|
|
foreach (var floorId in blocksByLocation[row.LocationID].Select(x => x.FloorPlanFloorID).Distinct())
|
|
{
|
|
warnings.Add(new FloorPlanOccupancyWarningViewModel
|
|
{
|
|
SceneID = sceneId,
|
|
FloorPlanFloorID = floorId,
|
|
Severity = "info",
|
|
Message = $"{row.LocationName} has no visible entrance."
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return warnings
|
|
.GroupBy(x => new { x.SceneID, x.FloorPlanFloorID, x.Severity, x.Message })
|
|
.Select(x => x.First())
|
|
.OrderByDescending(x => string.Equals(x.Severity, "warning", StringComparison.OrdinalIgnoreCase))
|
|
.ThenBy(x => x.Message)
|
|
.ToList();
|
|
}
|
|
|
|
private static string OccupantName(SceneFloorPlanOccupancy row)
|
|
=> row.CharacterName ?? row.AssetName ?? "Occupant";
|
|
|
|
private static string OccupancySceneLabel(Scene scene)
|
|
{
|
|
var chapter = $"Chapter {scene.ChapterNumber:0.00}";
|
|
var sceneLabel = $"Scene {scene.SceneNumber:0.00}: {scene.SceneTitle}";
|
|
return scene.BookNumber > 0
|
|
? $"Book {scene.BookNumber}, {chapter}, {sceneLabel}"
|
|
: $"{chapter}, {sceneLabel}";
|
|
}
|
|
|
|
private static bool IsVisibleEntranceTransition(string? transitionType)
|
|
{
|
|
var normalized = (transitionType ?? string.Empty)
|
|
.Trim()
|
|
.Replace(" ", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.Replace("-", string.Empty, StringComparison.OrdinalIgnoreCase)
|
|
.ToLowerInvariant();
|
|
|
|
return normalized is "door"
|
|
or "doubledoor"
|
|
or "frenchdoor"
|
|
or "frenchdoors"
|
|
or "slidingdoor"
|
|
or "slidingdoors"
|
|
or "exteriordoor"
|
|
or "archway"
|
|
or "openpassage"
|
|
or "stairsup"
|
|
or "stairsdown";
|
|
}
|
|
|
|
public async Task<int> SavePlanAsync(FloorPlanEditViewModel model)
|
|
{
|
|
var isNew = model.FloorPlanID == 0;
|
|
var locationId = await ResolveFloorPlanLocationAsync(model);
|
|
var floorPlanId = await floorPlans.SavePlanAsync(new FloorPlan
|
|
{
|
|
FloorPlanID = model.FloorPlanID,
|
|
ProjectID = model.ProjectID,
|
|
LocationID = locationId,
|
|
Name = model.Name.Trim(),
|
|
Description = model.Description
|
|
});
|
|
|
|
if (isNew)
|
|
{
|
|
var floorLocationId = await ResolveFloorLocationAsync(floorPlanId, "Ground Floor", null);
|
|
await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
|
{
|
|
FloorPlanID = floorPlanId,
|
|
LocationID = floorLocationId,
|
|
Name = "Ground Floor",
|
|
SortOrder = 0,
|
|
GridWidth = 24,
|
|
GridHeight = 16
|
|
});
|
|
}
|
|
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Floor Plan", floorPlanId, model.Name);
|
|
return floorPlanId;
|
|
}
|
|
|
|
public async Task DeletePlanAsync(int floorPlanId)
|
|
{
|
|
var floorPlan = await floorPlans.GetAsync(floorPlanId);
|
|
await floorPlans.DeletePlanAsync(floorPlanId);
|
|
if (floorPlan is not null)
|
|
{
|
|
await activity.RecordAsync(floorPlan.ProjectID, "Deleted", "Floor Plan", floorPlanId, floorPlan.Name);
|
|
}
|
|
}
|
|
|
|
public async Task<int> SaveFloorAsync(FloorPlanFloorEditViewModel model)
|
|
{
|
|
ValidateFloor(model);
|
|
var locationId = await ResolveFloorLocationAsync(model.FloorPlanID, model.Name, model.LocationID);
|
|
return await floorPlans.SaveFloorAsync(new FloorPlanFloor
|
|
{
|
|
FloorPlanFloorID = model.FloorPlanFloorID,
|
|
FloorPlanID = model.FloorPlanID,
|
|
LocationID = locationId,
|
|
Name = model.Name.Trim(),
|
|
Description = model.Description,
|
|
SortOrder = model.SortOrder,
|
|
GridWidth = model.GridWidth,
|
|
GridHeight = model.GridHeight
|
|
});
|
|
}
|
|
|
|
public Task DeleteFloorAsync(int floorPlanFloorId) => floorPlans.DeleteFloorAsync(floorPlanFloorId);
|
|
|
|
public async Task<int> SaveBlockAsync(FloorPlanBlockEditViewModel model)
|
|
{
|
|
var floor = await floorPlans.GetFloorAsync(model.FloorPlanFloorID)
|
|
?? throw new InvalidOperationException("Floor not found.");
|
|
var plan = await floorPlans.GetAsync(floor.FloorPlanID)
|
|
?? throw new InvalidOperationException("Floor plan not found.");
|
|
var projectId = plan.ProjectID;
|
|
if (model.FloorPlanBlockID == 0)
|
|
{
|
|
model.LocationID = await ResolveBlockLocationAsync(model, floor, plan);
|
|
var existingBlocks = await floorPlans.ListBlocksByFloorAsync(floor.FloorPlanFloorID);
|
|
(model.X, model.Y) = FindFirstAvailablePosition(floor, existingBlocks, model.WidthCells, model.HeightCells);
|
|
}
|
|
|
|
await ValidateBlockAsync(model, floor, projectId);
|
|
return await floorPlans.SaveBlockAsync(ToBlock(model));
|
|
}
|
|
|
|
public async Task<FloorPlanBlockEditViewModel?> GetBlockAsync(int floorPlanBlockId)
|
|
{
|
|
var block = await floorPlans.GetBlockAsync(floorPlanBlockId);
|
|
return block is null ? null : ToBlockEdit(block);
|
|
}
|
|
|
|
public async Task SaveLayoutAsync(FloorPlanEditorViewModel model)
|
|
{
|
|
var data = await floorPlans.GetEditorAsync(model.FloorPlan.FloorPlanID);
|
|
if (data.FloorPlan is null)
|
|
{
|
|
throw new InvalidOperationException("Floor plan not found.");
|
|
}
|
|
|
|
await SavePlanAsync(model.FloorPlan);
|
|
var floorsById = data.Floors.ToDictionary(x => x.FloorPlanFloorID);
|
|
|
|
foreach (var floorModel in model.Floors)
|
|
{
|
|
ValidateFloor(floorModel);
|
|
if (floorModel.FloorPlanID != data.FloorPlan.FloorPlanID)
|
|
{
|
|
throw new InvalidOperationException("Floor does not belong to this floor plan.");
|
|
}
|
|
|
|
await SaveFloorAsync(floorModel);
|
|
}
|
|
|
|
foreach (var blockModel in model.Blocks)
|
|
{
|
|
if (!floorsById.TryGetValue(blockModel.FloorPlanFloorID, out var floor))
|
|
{
|
|
throw new InvalidOperationException("Block floor not found.");
|
|
}
|
|
|
|
await ValidateBlockAsync(blockModel, floor, data.FloorPlan.ProjectID);
|
|
await floorPlans.SaveBlockAsync(ToBlock(blockModel));
|
|
}
|
|
}
|
|
|
|
public Task DeleteBlockAsync(int floorPlanBlockId) => floorPlans.DeleteBlockAsync(floorPlanBlockId);
|
|
|
|
public async Task<int> SaveTransitionAsync(int floorPlanId, FloorPlanTransitionEditViewModel model)
|
|
{
|
|
var data = await floorPlans.GetEditorAsync(floorPlanId);
|
|
if (data.FloorPlan is null)
|
|
{
|
|
throw new InvalidOperationException("Floor plan not found.");
|
|
}
|
|
|
|
var floor = data.Floors.FirstOrDefault(x => x.FloorPlanFloorID == model.FloorPlanFloorID)
|
|
?? throw new InvalidOperationException("Floor not found.");
|
|
|
|
ValidateTransition(model, floor, data.Blocks, data.Transitions, data.Floors);
|
|
if (!IsInsideLocationTransition(model.TransitionType))
|
|
{
|
|
model.PositionWithinLocation = FloorPlanTransitionPositions.Centre;
|
|
}
|
|
else if (model.FloorPlanTransitionID == 0)
|
|
{
|
|
var existingPrimary = data.Transitions.FirstOrDefault(x =>
|
|
x.FloorPlanFloorID == model.FloorPlanFloorID
|
|
&& x.FromLocationID == model.FromLocationID!.Value
|
|
&& x.ToLocationID == model.ToLocationID!.Value
|
|
&& string.Equals(x.TransitionType, model.TransitionType, StringComparison.OrdinalIgnoreCase));
|
|
if (existingPrimary is not null)
|
|
{
|
|
model.FloorPlanTransitionID = existingPrimary.FloorPlanTransitionID;
|
|
}
|
|
}
|
|
var transitionId = await floorPlans.SaveTransitionAsync(ToTransition(model));
|
|
if (IsStairTransition(model.TransitionType))
|
|
{
|
|
await SaveReciprocalStairTransitionAsync(model, floor, data.Floors, data.Blocks, data.Transitions);
|
|
}
|
|
await activity.RecordAsync(data.FloorPlan.ProjectID, model.FloorPlanTransitionID == 0 ? "Created" : "Updated", "Floor Plan Transition", transitionId, model.TransitionType);
|
|
return transitionId;
|
|
}
|
|
|
|
private async Task SaveReciprocalStairTransitionAsync(
|
|
FloorPlanTransitionEditViewModel model,
|
|
FloorPlanFloor floor,
|
|
IReadOnlyList<FloorPlanFloor> floors,
|
|
IReadOnlyList<FloorPlanBlock> blocks,
|
|
IReadOnlyList<FloorPlanTransition> transitions)
|
|
{
|
|
var transitionKind = NormalizeTransitionType(model.TransitionType);
|
|
var targetLevel = floor.SortOrder + (transitionKind == "stairsup" ? 1 : -1);
|
|
var targetFloor = floors
|
|
.Where(x => x.SortOrder == targetLevel)
|
|
.OrderBy(x => x.Name)
|
|
.FirstOrDefault();
|
|
|
|
if (targetFloor is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var targetLocationIds = blocks
|
|
.Where(x => x.FloorPlanFloorID == targetFloor.FloorPlanFloorID)
|
|
.Select(x => x.LocationID)
|
|
.ToHashSet();
|
|
if (!targetLocationIds.Contains(model.ToLocationID!.Value))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var reciprocalType = transitionKind == "stairsup"
|
|
? FloorPlanTransitionTypes.StairsDown
|
|
: FloorPlanTransitionTypes.StairsUp;
|
|
var existing = transitions.FirstOrDefault(x =>
|
|
x.FloorPlanFloorID == targetFloor.FloorPlanFloorID
|
|
&& x.FromLocationID == model.ToLocationID.Value
|
|
&& x.ToLocationID == model.FromLocationID!.Value
|
|
&& string.Equals(x.TransitionType, reciprocalType, StringComparison.OrdinalIgnoreCase));
|
|
|
|
await floorPlans.SaveTransitionAsync(new FloorPlanTransition
|
|
{
|
|
FloorPlanTransitionID = existing?.FloorPlanTransitionID ?? 0,
|
|
FloorPlanFloorID = targetFloor.FloorPlanFloorID,
|
|
FromLocationID = model.ToLocationID.Value,
|
|
ToLocationID = model.FromLocationID!.Value,
|
|
TransitionType = reciprocalType,
|
|
Notes = model.Notes,
|
|
IsLocked = model.IsLocked,
|
|
DisplayLabel = model.DisplayLabel,
|
|
PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation)
|
|
? FloorPlanTransitionPositions.Centre
|
|
: model.PositionWithinLocation
|
|
});
|
|
}
|
|
|
|
public async Task DeleteTransitionAsync(int floorPlanId, int floorPlanTransitionId)
|
|
{
|
|
var data = await floorPlans.GetEditorAsync(floorPlanId);
|
|
if (data.FloorPlan is null)
|
|
{
|
|
throw new InvalidOperationException("Floor plan not found.");
|
|
}
|
|
|
|
var transition = data.Transitions.FirstOrDefault(x => x.FloorPlanTransitionID == floorPlanTransitionId)
|
|
?? throw new InvalidOperationException("Transition not found.");
|
|
await floorPlans.DeleteTransitionAsync(floorPlanTransitionId);
|
|
await activity.RecordAsync(data.FloorPlan.ProjectID, "Deleted", "Floor Plan Transition", floorPlanTransitionId, transition.TransitionType);
|
|
}
|
|
|
|
public async Task<FloorPlanBackgroundImageUploadResult> UploadBackgroundAsync(int floorPlanId, int floorPlanFloorId, IFormFile upload)
|
|
{
|
|
var (plan, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
|
var result = await backgroundImages.UploadAsync(plan, floor, upload);
|
|
if (!result.Succeeded)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
var previousPath = floor.BackgroundImagePath;
|
|
floor.BackgroundImagePath = result.StoragePath;
|
|
floor.BackgroundImageOriginalFileName = result.OriginalFileName;
|
|
floor.BackgroundOpacity = Clamp(floor.BackgroundOpacity ?? 0.35m, 0.05m, 1m);
|
|
floor.BackgroundScale = Clamp(floor.BackgroundScale ?? 1m, 0.1m, 5m);
|
|
floor.BackgroundOffsetX = Clamp(floor.BackgroundOffsetX ?? 0m, -10000m, 10000m);
|
|
floor.BackgroundOffsetY = Clamp(floor.BackgroundOffsetY ?? 0m, -10000m, 10000m);
|
|
floor.BackgroundLocked = true;
|
|
|
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
|
{
|
|
backgroundImages.DeleteBackgroundFile(result.StoragePath);
|
|
return new(false, ErrorMessage: "Background image could not be linked to this floor.");
|
|
}
|
|
|
|
backgroundImages.DeleteBackgroundFile(previousPath);
|
|
await activity.RecordAsync(plan.ProjectID, "Updated", "Floor Plan Background", floorPlanFloorId, floor.Name);
|
|
return result;
|
|
}
|
|
|
|
public async Task SaveBackgroundSettingsAsync(FloorPlanFloorBackgroundSettingsViewModel model)
|
|
{
|
|
var (_, floor) = await GetPlanFloorAsync(model.FloorPlanID, model.FloorPlanFloorID);
|
|
floor.BackgroundOpacity = Clamp(model.BackgroundOpacity, 0.05m, 1m);
|
|
floor.BackgroundScale = Clamp(model.BackgroundScale, 0.1m, 5m);
|
|
floor.BackgroundOffsetX = Clamp(model.BackgroundOffsetX, -10000m, 10000m);
|
|
floor.BackgroundOffsetY = Clamp(model.BackgroundOffsetY, -10000m, 10000m);
|
|
floor.BackgroundLocked = model.BackgroundLocked;
|
|
|
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
|
{
|
|
throw new InvalidOperationException("Background settings could not be saved.");
|
|
}
|
|
}
|
|
|
|
public async Task ResetBackgroundAsync(int floorPlanId, int floorPlanFloorId)
|
|
{
|
|
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
|
floor.BackgroundOpacity = 0.35m;
|
|
floor.BackgroundScale = 1m;
|
|
floor.BackgroundOffsetX = 0m;
|
|
floor.BackgroundOffsetY = 0m;
|
|
floor.BackgroundLocked = true;
|
|
|
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
|
{
|
|
throw new InvalidOperationException("Background settings could not be reset.");
|
|
}
|
|
}
|
|
|
|
public async Task RemoveBackgroundAsync(int floorPlanId, int floorPlanFloorId)
|
|
{
|
|
var (_, floor) = await GetPlanFloorAsync(floorPlanId, floorPlanFloorId);
|
|
var previousPath = floor.BackgroundImagePath;
|
|
floor.BackgroundImagePath = null;
|
|
floor.BackgroundImageOriginalFileName = null;
|
|
floor.BackgroundOpacity = 0.35m;
|
|
floor.BackgroundScale = 1m;
|
|
floor.BackgroundOffsetX = 0m;
|
|
floor.BackgroundOffsetY = 0m;
|
|
floor.BackgroundLocked = true;
|
|
|
|
if (!await floorPlans.UpdateFloorBackgroundAsync(floor))
|
|
{
|
|
throw new InvalidOperationException("Background image could not be removed.");
|
|
}
|
|
|
|
backgroundImages.DeleteBackgroundFile(previousPath);
|
|
}
|
|
|
|
private async Task ValidateBlockAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, int projectId)
|
|
{
|
|
if (!FloorPlanBlockTypes.IsValid(model.BlockType))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid block type.");
|
|
}
|
|
|
|
if (model.X < 0 || model.Y < 0 || model.WidthCells < 1 || model.HeightCells < 1)
|
|
{
|
|
throw new InvalidOperationException("Blocks must have a valid position and size.");
|
|
}
|
|
|
|
if (model.X + model.WidthCells > floor.GridWidth || model.Y + model.HeightCells > floor.GridHeight)
|
|
{
|
|
throw new InvalidOperationException("Block is outside the floor grid.");
|
|
}
|
|
|
|
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
|
|
{
|
|
throw new InvalidOperationException("Choose or create a location for this block.");
|
|
}
|
|
|
|
var location = await locations.GetAsync(model.LocationID.Value);
|
|
if (location is null || location.ProjectID != projectId)
|
|
{
|
|
throw new InvalidOperationException("Choose a location from this project.");
|
|
}
|
|
}
|
|
|
|
private static string NormalizeTransitionType(string? transitionType)
|
|
=> new((transitionType ?? string.Empty).Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
|
|
|
|
private static bool IsSameFloorAdjacentTransition(string? transitionType)
|
|
=> NormalizeTransitionType(transitionType) is "door"
|
|
or "doubledoor"
|
|
or "frenchdoor"
|
|
or "frenchdoors"
|
|
or "slidingdoor"
|
|
or "slidingdoors"
|
|
or "window"
|
|
or "archway"
|
|
or "exteriordoor"
|
|
or "hiddendoor"
|
|
or "blockeddoor";
|
|
|
|
private static bool IsUnrestrictedTransition(string? transitionType)
|
|
=> NormalizeTransitionType(transitionType) is "hiddenpassage" or "openpassage" or "secretpassage";
|
|
|
|
private static bool IsStairTransition(string? transitionType)
|
|
=> NormalizeTransitionType(transitionType) is "stairsup" or "stairsdown";
|
|
|
|
private static bool IsLadderTransition(string? transitionType)
|
|
=> NormalizeTransitionType(transitionType) is "ladder" or "ladders";
|
|
|
|
private static bool IsInsideLocationTransition(string? transitionType)
|
|
=> IsStairTransition(transitionType) || IsLadderTransition(transitionType);
|
|
|
|
private static bool BlocksTouchAlongEdge(FloorPlanBlock a, FloorPlanBlock b)
|
|
{
|
|
var verticalTouch = a.X + a.WidthCells == b.X || b.X + b.WidthCells == a.X;
|
|
var verticalOverlap = a.Y < b.Y + b.HeightCells && b.Y < a.Y + a.HeightCells;
|
|
var horizontalTouch = a.Y + a.HeightCells == b.Y || b.Y + b.HeightCells == a.Y;
|
|
var horizontalOverlap = a.X < b.X + b.WidthCells && b.X < a.X + a.WidthCells;
|
|
return (verticalTouch && verticalOverlap) || (horizontalTouch && horizontalOverlap);
|
|
}
|
|
|
|
private static void ValidateTransition(
|
|
FloorPlanTransitionEditViewModel model,
|
|
FloorPlanFloor floor,
|
|
IReadOnlyList<FloorPlanBlock> blocks,
|
|
IReadOnlyList<FloorPlanTransition> transitions,
|
|
IReadOnlyList<FloorPlanFloor> floors)
|
|
{
|
|
if (!FloorPlanTransitionTypes.IsValid(model.TransitionType))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid transition type.");
|
|
}
|
|
|
|
if (!model.FromLocationID.HasValue || !model.ToLocationID.HasValue)
|
|
{
|
|
throw new InvalidOperationException("Choose both transition locations.");
|
|
}
|
|
|
|
if (model.FromLocationID.Value == model.ToLocationID.Value)
|
|
{
|
|
throw new InvalidOperationException("From and To locations must be different.");
|
|
}
|
|
|
|
if (!FloorPlanTransitionPositions.IsValid(model.PositionWithinLocation))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid stair position.");
|
|
}
|
|
|
|
var currentFloorBlocks = blocks
|
|
.Where(x => x.FloorPlanFloorID == floor.FloorPlanFloorID)
|
|
.ToList();
|
|
var floorLocationIds = currentFloorBlocks.Select(x => x.LocationID).ToHashSet();
|
|
|
|
if (!floorLocationIds.Contains(model.FromLocationID.Value))
|
|
{
|
|
throw new InvalidOperationException("From Location must exist on the active floor.");
|
|
}
|
|
|
|
if (IsStairTransition(model.TransitionType))
|
|
{
|
|
var targetLevel = floor.SortOrder + (NormalizeTransitionType(model.TransitionType) == "stairsup" ? 1 : -1);
|
|
var targetFloorIds = floors.Where(x => x.SortOrder == targetLevel).Select(x => x.FloorPlanFloorID).ToHashSet();
|
|
var targetLocationIds = blocks
|
|
.Where(x => targetFloorIds.Contains(x.FloorPlanFloorID))
|
|
.Select(x => x.LocationID)
|
|
.ToHashSet();
|
|
|
|
if (!targetLocationIds.Contains(model.ToLocationID.Value))
|
|
{
|
|
throw new InvalidOperationException("Stairs must connect to a location on the adjacent floor level.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (IsLadderTransition(model.TransitionType))
|
|
{
|
|
var targetFloorIds = floors
|
|
.Where(x => x.SortOrder > floor.SortOrder)
|
|
.Select(x => x.FloorPlanFloorID)
|
|
.ToHashSet();
|
|
var targetLocationIds = blocks
|
|
.Where(x => targetFloorIds.Contains(x.FloorPlanFloorID))
|
|
.Select(x => x.LocationID)
|
|
.ToHashSet();
|
|
|
|
if (!targetLocationIds.Contains(model.ToLocationID.Value))
|
|
{
|
|
throw new InvalidOperationException("Ladders must connect to a placed location on a floor above.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (IsUnrestrictedTransition(model.TransitionType))
|
|
{
|
|
var planLocationIds = blocks.Select(x => x.LocationID).ToHashSet();
|
|
if (!planLocationIds.Contains(model.ToLocationID.Value))
|
|
{
|
|
throw new InvalidOperationException("To Location must exist in this floor plan.");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!IsSameFloorAdjacentTransition(model.TransitionType))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid transition type.");
|
|
}
|
|
|
|
if (!floorLocationIds.Contains(model.ToLocationID.Value))
|
|
{
|
|
throw new InvalidOperationException("To Location must exist on the active floor.");
|
|
}
|
|
|
|
var fromBlocks = currentFloorBlocks.Where(x => x.LocationID == model.FromLocationID.Value).ToList();
|
|
var toBlocks = currentFloorBlocks.Where(x => x.LocationID == model.ToLocationID.Value).ToList();
|
|
if (!fromBlocks.Any(from => toBlocks.Any(to => BlocksTouchAlongEdge(from, to))))
|
|
{
|
|
throw new InvalidOperationException("Door and window transitions must connect adjacent locations.");
|
|
}
|
|
}
|
|
|
|
private async Task<(FloorPlan Plan, FloorPlanFloor Floor)> GetPlanFloorAsync(int floorPlanId, int floorPlanFloorId)
|
|
{
|
|
var plan = await floorPlans.GetAsync(floorPlanId)
|
|
?? throw new InvalidOperationException("Floor plan not found.");
|
|
var floor = await floorPlans.GetFloorAsync(floorPlanFloorId)
|
|
?? throw new InvalidOperationException("Floor not found.");
|
|
|
|
if (floor.FloorPlanID != plan.FloorPlanID)
|
|
{
|
|
throw new InvalidOperationException("Floor does not belong to this floor plan.");
|
|
}
|
|
|
|
return (plan, floor);
|
|
}
|
|
|
|
private static decimal Clamp(decimal value, decimal min, decimal max)
|
|
=> Math.Min(Math.Max(value, min), max);
|
|
|
|
private async Task<int?> ResolveFloorPlanLocationAsync(FloorPlanEditViewModel model)
|
|
{
|
|
if (model.LocationID.HasValue && model.LocationID.Value > 0)
|
|
{
|
|
var existing = await locations.GetAsync(model.LocationID.Value);
|
|
if (existing is null || existing.ProjectID != model.ProjectID)
|
|
{
|
|
throw new InvalidOperationException("Floor plan location must belong to this project.");
|
|
}
|
|
|
|
return model.LocationID.Value;
|
|
}
|
|
|
|
return await FindOrCreateLocationAsync(model.ProjectID, null, model.Name.Trim());
|
|
}
|
|
|
|
private async Task<int?> ResolveFloorLocationAsync(int floorPlanId, string floorName, int? locationId)
|
|
{
|
|
var plan = await floorPlans.GetAsync(floorPlanId)
|
|
?? throw new InvalidOperationException("Floor plan not found.");
|
|
|
|
if (locationId.HasValue && locationId.Value > 0)
|
|
{
|
|
var existing = await locations.GetAsync(locationId.Value);
|
|
if (existing is null || existing.ProjectID != plan.ProjectID)
|
|
{
|
|
throw new InvalidOperationException("Floor location must belong to this project.");
|
|
}
|
|
|
|
return locationId.Value;
|
|
}
|
|
|
|
var planLocationId = plan.LocationID ?? await FindOrCreateLocationAsync(plan.ProjectID, null, plan.Name);
|
|
if (plan.LocationID != planLocationId)
|
|
{
|
|
await floorPlans.SavePlanAsync(new FloorPlan
|
|
{
|
|
FloorPlanID = plan.FloorPlanID,
|
|
ProjectID = plan.ProjectID,
|
|
LocationID = planLocationId,
|
|
Name = plan.Name,
|
|
Description = plan.Description
|
|
});
|
|
}
|
|
|
|
return await FindOrCreateLocationAsync(plan.ProjectID, planLocationId, floorName.Trim());
|
|
}
|
|
|
|
private async Task<int> ResolveBlockLocationAsync(FloorPlanBlockEditViewModel model, FloorPlanFloor floor, FloorPlan plan)
|
|
{
|
|
if (string.Equals(model.AddBlockLocationMode, "Create", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var locationName = string.IsNullOrWhiteSpace(model.NewLocationName) ? model.LabelOverride : model.NewLocationName;
|
|
locationName = string.IsNullOrWhiteSpace(locationName) ? model.LabelOverride : locationName;
|
|
if (string.IsNullOrWhiteSpace(locationName))
|
|
{
|
|
throw new InvalidOperationException("Enter a room/location name for the new block.");
|
|
}
|
|
|
|
var floorLocationId = floor.LocationID ?? await ResolveFloorLocationAsync(floor.FloorPlanID, floor.Name, null);
|
|
return await FindOrCreateLocationAsync(plan.ProjectID, floorLocationId, locationName.Trim());
|
|
}
|
|
|
|
if (!model.LocationID.HasValue || model.LocationID.Value <= 0)
|
|
{
|
|
throw new InvalidOperationException("Choose a location for this block.");
|
|
}
|
|
|
|
return model.LocationID.Value;
|
|
}
|
|
|
|
private async Task<int> FindOrCreateLocationAsync(int projectId, int? parentLocationId, string locationName)
|
|
{
|
|
var name = locationName.Trim();
|
|
var existing = (await locations.ListByProjectAsync(projectId))
|
|
.FirstOrDefault(x => x.ParentLocationID == parentLocationId
|
|
&& string.Equals(x.LocationName.Trim(), name, StringComparison.OrdinalIgnoreCase)
|
|
&& !x.IsArchived);
|
|
|
|
if (existing is not null)
|
|
{
|
|
return existing.LocationID;
|
|
}
|
|
|
|
return await locations.SaveAsync(new LocationItem
|
|
{
|
|
ProjectID = projectId,
|
|
ParentLocationID = parentLocationId,
|
|
LocationName = name,
|
|
ShowInQuickAddBar = false
|
|
});
|
|
}
|
|
|
|
private static void ValidateFloor(FloorPlanFloorEditViewModel model)
|
|
{
|
|
if (model.SortOrder is < -10 or > 50)
|
|
{
|
|
throw new InvalidOperationException("Floor level must be between -10 and 50.");
|
|
}
|
|
|
|
if (model.GridWidth is < 8 or > 80 || model.GridHeight is < 8 or > 80)
|
|
{
|
|
throw new InvalidOperationException("Grid width and height must be between 8 and 80.");
|
|
}
|
|
}
|
|
|
|
private static (int X, int Y) FindFirstAvailablePosition(FloorPlanFloor floor, IReadOnlyList<FloorPlanBlock> existingBlocks, int widthCells, int heightCells)
|
|
{
|
|
var width = Math.Max(1, widthCells);
|
|
var height = Math.Max(1, heightCells);
|
|
if (width > floor.GridWidth || height > floor.GridHeight)
|
|
{
|
|
return (0, 0);
|
|
}
|
|
|
|
for (var y = 0; y <= floor.GridHeight - height; y++)
|
|
{
|
|
for (var x = 0; x <= floor.GridWidth - width; x++)
|
|
{
|
|
if (!existingBlocks.Any(block => BlocksOverlap(x, y, width, height, block.X, block.Y, block.WidthCells, block.HeightCells)))
|
|
{
|
|
return (x, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
return (0, 0);
|
|
}
|
|
|
|
private static bool BlocksOverlap(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh)
|
|
{
|
|
return ax < bx + bw
|
|
&& ax + aw > bx
|
|
&& ay < by + bh
|
|
&& ay + ah > by;
|
|
}
|
|
|
|
private static FloorPlanFloorEditViewModel ToFloorEdit(FloorPlanFloor floor) => new()
|
|
{
|
|
FloorPlanFloorID = floor.FloorPlanFloorID,
|
|
FloorPlanID = floor.FloorPlanID,
|
|
LocationID = floor.LocationID,
|
|
LocationName = floor.LocationName,
|
|
LocationPath = floor.LocationPath,
|
|
Name = floor.Name,
|
|
Description = floor.Description,
|
|
SortOrder = floor.SortOrder,
|
|
GridWidth = floor.GridWidth,
|
|
GridHeight = floor.GridHeight,
|
|
BackgroundImagePath = floor.BackgroundImagePath,
|
|
BackgroundImageOriginalFileName = floor.BackgroundImageOriginalFileName,
|
|
BackgroundOpacity = floor.BackgroundOpacity ?? 0.35m,
|
|
BackgroundScale = floor.BackgroundScale ?? 1m,
|
|
BackgroundOffsetX = floor.BackgroundOffsetX ?? 0m,
|
|
BackgroundOffsetY = floor.BackgroundOffsetY ?? 0m,
|
|
BackgroundLocked = floor.BackgroundLocked
|
|
};
|
|
|
|
private static FloorPlanBlockEditViewModel ToBlockEdit(FloorPlanBlock block) => new()
|
|
{
|
|
FloorPlanBlockID = block.FloorPlanBlockID,
|
|
FloorPlanFloorID = block.FloorPlanFloorID,
|
|
LocationID = block.LocationID,
|
|
LocationName = block.LocationName,
|
|
LocationPath = block.LocationPath,
|
|
X = block.X,
|
|
Y = block.Y,
|
|
WidthCells = block.WidthCells,
|
|
HeightCells = block.HeightCells,
|
|
BlockType = block.BlockType,
|
|
LabelOverride = block.LabelOverride,
|
|
Notes = block.Notes
|
|
};
|
|
|
|
private static FloorPlanBlock ToBlock(FloorPlanBlockEditViewModel model) => new()
|
|
{
|
|
FloorPlanBlockID = model.FloorPlanBlockID,
|
|
FloorPlanFloorID = model.FloorPlanFloorID,
|
|
LocationID = model.LocationID!.Value,
|
|
X = model.X,
|
|
Y = model.Y,
|
|
WidthCells = model.WidthCells,
|
|
HeightCells = model.HeightCells,
|
|
BlockType = model.BlockType,
|
|
LabelOverride = model.LabelOverride,
|
|
Notes = model.Notes
|
|
};
|
|
|
|
private static FloorPlanTransitionEditViewModel ToTransitionEdit(FloorPlanTransition transition) => new()
|
|
{
|
|
FloorPlanTransitionID = transition.FloorPlanTransitionID,
|
|
FloorPlanFloorID = transition.FloorPlanFloorID,
|
|
FromLocationID = transition.FromLocationID,
|
|
FromLocationName = transition.FromLocationName,
|
|
FromLocationPath = transition.FromLocationPath,
|
|
ToLocationID = transition.ToLocationID,
|
|
ToLocationName = transition.ToLocationName,
|
|
ToLocationPath = transition.ToLocationPath,
|
|
TransitionType = transition.TransitionType,
|
|
Notes = transition.Notes,
|
|
IsLocked = transition.IsLocked,
|
|
DisplayLabel = transition.DisplayLabel,
|
|
PositionWithinLocation = string.IsNullOrWhiteSpace(transition.PositionWithinLocation)
|
|
? FloorPlanTransitionPositions.Centre
|
|
: transition.PositionWithinLocation
|
|
};
|
|
|
|
private static FloorPlanTransition ToTransition(FloorPlanTransitionEditViewModel model) => new()
|
|
{
|
|
FloorPlanTransitionID = model.FloorPlanTransitionID,
|
|
FloorPlanFloorID = model.FloorPlanFloorID,
|
|
FromLocationID = model.FromLocationID!.Value,
|
|
ToLocationID = model.ToLocationID!.Value,
|
|
TransitionType = model.TransitionType,
|
|
Notes = model.Notes,
|
|
IsLocked = model.IsLocked,
|
|
DisplayLabel = model.DisplayLabel,
|
|
PositionWithinLocation = string.IsNullOrWhiteSpace(model.PositionWithinLocation)
|
|
? FloorPlanTransitionPositions.Centre
|
|
: model.PositionWithinLocation
|
|
};
|
|
|
|
private static string BlockTypeLabel(string blockType) => blockType switch
|
|
{
|
|
FloorPlanBlockTypes.OpenSpace => "Open space",
|
|
_ => blockType
|
|
};
|
|
|
|
private static IReadOnlyList<SelectListItem> ToOptionalLocationOptions(IEnumerable<LocationItem> projectLocations, string emptyLabel)
|
|
{
|
|
var items = new List<SelectListItem> { new(emptyLabel, string.Empty) };
|
|
items.AddRange(projectLocations.Select(x => new SelectListItem(string.IsNullOrWhiteSpace(x.LocationPath) ? x.LocationName : x.LocationPath, x.LocationID.ToString())));
|
|
return items;
|
|
}
|
|
}
|
|
|
|
public sealed class CharacterService(
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
ICharacterRepository characters,
|
|
IProjectCollaborationRepository collaboration,
|
|
ISubscriptionService subscriptions,
|
|
IProjectActivityService activity,
|
|
ICurrentUserService currentUser,
|
|
IVisualIdentityImageService visualIdentityImages) : 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
|
|
: await PopulateCharacterEditOptionsAsync(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 await PopulateCharacterEditOptionsAsync(new CharacterEditViewModel
|
|
{
|
|
CharacterID = character.CharacterID,
|
|
ProjectID = character.ProjectID,
|
|
CharacterName = character.CharacterName,
|
|
Aliases = (await characters.ListAliasesAsync(character.CharacterID)).Select(x => x.Alias).ToList(),
|
|
SexValueID = character.SexValueID,
|
|
Sex = character.Sex,
|
|
BirthDate = character.BirthDate,
|
|
AgeAtSeriesStart = character.AgeAtSeriesStart,
|
|
Height = character.Height,
|
|
EyeColour = character.EyeColour,
|
|
CharacterImportance = character.CharacterImportance,
|
|
ShowInQuickAddBar = character.ShowInQuickAddBar,
|
|
DefaultDescription = character.DefaultDescription,
|
|
ImagePath = character.ImagePath,
|
|
ThumbnailPath = character.ThumbnailPath,
|
|
AvatarSourceCharacterImageID = character.AvatarSourceCharacterImageID,
|
|
AvatarImagePath = character.AvatarImagePath,
|
|
AvatarThumbnailPath = character.AvatarThumbnailPath,
|
|
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,
|
|
Aliases = await characters.ListAliasesAsync(character.CharacterID),
|
|
ProjectCharacters = projectCharacters,
|
|
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
|
|
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.CategorySortOrder == 0 ? 900 : x.Relationship.CategorySortOrder)
|
|
.ThenBy(x => x.Relationship.RelationshipTypeSortOrder)
|
|
.ThenBy(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 = ToRelationshipTypeSelectList(relationshipLookups.RelationshipTypes),
|
|
RelationshipStateOptions = relationshipLookups.RelationshipStates.Select(x => new SelectListItem(x.StateName, x.RelationshipStateID.ToString())).ToList(),
|
|
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookDisplayTitle}", x.BookID.ToString())).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
|
|
{
|
|
var image = await characters.GetCharacterImageAsync(characterImageId);
|
|
if (image is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var character = await characters.GetCharacterAsync(image.CharacterID);
|
|
if (character is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new CharacterAvatarCropViewModel
|
|
{
|
|
CharacterID = character.CharacterID,
|
|
CharacterImageID = image.CharacterImageID,
|
|
CharacterName = character.CharacterName,
|
|
ImagePath = image.ImagePath,
|
|
Caption = image.Caption
|
|
};
|
|
}
|
|
|
|
public async Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model)
|
|
{
|
|
if (model.CharacterID == 0)
|
|
{
|
|
await SubscriptionLimitGuards.EnsureAllowedAsync(userId => subscriptions.CanCreateCharacterAsync(model.ProjectID, userId), currentUser.UserId);
|
|
}
|
|
|
|
var isNew = model.CharacterID == 0;
|
|
var existing = isNew ? null : await characters.GetCharacterAsync(model.CharacterID);
|
|
var sex = await ResolveCharacterSexAsync(model.ProjectID, model.SexValueID, model.CustomSex, model.Sex);
|
|
var characterId = await characters.SaveCharacterAsync(new Character
|
|
{
|
|
CharacterID = model.CharacterID,
|
|
ProjectID = model.ProjectID,
|
|
CharacterName = model.CharacterName,
|
|
ShortName = existing?.ShortName,
|
|
SexValueID = sex.SexValueID,
|
|
Sex = sex.SexName,
|
|
BirthDate = model.BirthDate,
|
|
AgeAtSeriesStart = model.AgeAtSeriesStart,
|
|
Height = model.Height,
|
|
EyeColour = model.EyeColour,
|
|
CharacterImportance = model.CharacterImportance,
|
|
ShowInQuickAddBar = model.ShowInQuickAddBar,
|
|
DefaultDescription = model.DefaultDescription,
|
|
ImagePath = model.ImagePath ?? existing?.ImagePath,
|
|
ThumbnailPath = model.ThumbnailPath ?? existing?.ThumbnailPath
|
|
});
|
|
await SyncCharacterAliasesAsync(characterId, model.Aliases);
|
|
var imageHost = existing ?? new Character { CharacterID = characterId, ProjectID = model.ProjectID };
|
|
imageHost.CharacterID = characterId;
|
|
imageHost.ProjectID = model.ProjectID;
|
|
int? uploadedCharacterImageId = null;
|
|
if (model.ImageUpload is { Length: > 0 } upload)
|
|
{
|
|
var result = await visualIdentityImages.UploadCharacterImageAsync(imageHost, upload);
|
|
if (!result.Succeeded)
|
|
{
|
|
throw new EntityImageUploadException(result.ErrorMessage ?? "Character image could not be uploaded.", characterId);
|
|
}
|
|
|
|
uploadedCharacterImageId = result.CharacterImageID;
|
|
}
|
|
else if (model.RemoveImage && existing is not null)
|
|
{
|
|
await visualIdentityImages.RemoveCharacterImageAsync(existing);
|
|
}
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Character", characterId, model.CharacterName);
|
|
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
|
|
}
|
|
|
|
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
|
|
{
|
|
var aliases = AliasInput.Clean(postedAliases);
|
|
var existing = await characters.ListAliasesAsync(characterId);
|
|
var existingByAlias = existing.ToDictionary(x => x.Alias, StringComparer.OrdinalIgnoreCase);
|
|
|
|
for (var index = 0; index < aliases.Count; index++)
|
|
{
|
|
var alias = aliases[index];
|
|
var sortOrder = (index + 1) * 10;
|
|
if (existingByAlias.TryGetValue(alias, out var row))
|
|
{
|
|
await characters.UpdateAliasAsync(row.CharacterAliasID, alias, sortOrder);
|
|
}
|
|
else
|
|
{
|
|
await characters.AddAliasAsync(characterId, alias, sortOrder);
|
|
}
|
|
}
|
|
|
|
var postedSet = aliases.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var alias in existing.Where(x => !postedSet.Contains(x.Alias)))
|
|
{
|
|
await characters.DeleteAliasAsync(alias.CharacterAliasID);
|
|
}
|
|
}
|
|
|
|
private async Task<CharacterEditViewModel> PopulateCharacterEditOptionsAsync(CharacterEditViewModel model)
|
|
{
|
|
var ownerUserId = await collaboration.GetOwnerUserIdAsync(model.ProjectID);
|
|
IReadOnlyList<CharacterSexValue> sexValues = ownerUserId.HasValue
|
|
? await characters.ListSexValuesAsync(ownerUserId.Value)
|
|
: [];
|
|
|
|
if (model.SexValueID is null && !string.IsNullOrWhiteSpace(model.Sex))
|
|
{
|
|
var matching = sexValues.FirstOrDefault(value => string.Equals(value.SexName, model.Sex, StringComparison.OrdinalIgnoreCase));
|
|
if (matching is not null)
|
|
{
|
|
model.SexValueID = matching.CharacterSexValueID;
|
|
}
|
|
}
|
|
|
|
model.SexOptions = new[] { new SelectListItem("Not set", string.Empty) }
|
|
.Concat(sexValues.Select(value => new SelectListItem(value.SexName, value.CharacterSexValueID.ToString(), value.CharacterSexValueID == model.SexValueID)))
|
|
.Concat(new[] { new SelectListItem("Add new...", CharacterEditViewModel.CustomSexValue.ToString(), model.SexValueID == CharacterEditViewModel.CustomSexValue) })
|
|
.ToList();
|
|
|
|
return model;
|
|
}
|
|
|
|
private async Task<(int? SexValueID, string? SexName)> ResolveCharacterSexAsync(int projectId, int? sexValueId, string? customSex, string? fallbackSex)
|
|
{
|
|
var ownerUserId = await collaboration.GetOwnerUserIdAsync(projectId);
|
|
if (!ownerUserId.HasValue)
|
|
{
|
|
return (sexValueId, fallbackSex);
|
|
}
|
|
|
|
if (sexValueId == CharacterEditViewModel.CustomSexValue)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(customSex))
|
|
{
|
|
throw new InvalidOperationException("Enter a custom sex value.");
|
|
}
|
|
|
|
var trimmed = customSex.Trim();
|
|
var customId = await characters.GetOrCreateSexValueAsync(ownerUserId.Value, trimmed);
|
|
return (customId, trimmed);
|
|
}
|
|
|
|
if (sexValueId.HasValue)
|
|
{
|
|
var sexValue = await characters.GetSexValueAsync(sexValueId.Value, ownerUserId.Value);
|
|
return sexValue is null
|
|
? (null, null)
|
|
: (sexValue.CharacterSexValueID, sexValue.SexName);
|
|
}
|
|
|
|
return (null, null);
|
|
}
|
|
|
|
public async Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model)
|
|
{
|
|
var character = await characters.GetCharacterAsync(model.CharacterID);
|
|
if (character is null)
|
|
{
|
|
return new(false, "Character not found.");
|
|
}
|
|
|
|
return model.ImageUpload is null
|
|
? new(false, "Choose an image to upload.")
|
|
: await visualIdentityImages.UploadCharacterGalleryImageAsync(character, model.ImageUpload, model.Caption);
|
|
}
|
|
|
|
public async Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model)
|
|
{
|
|
var character = await characters.GetCharacterAsync(model.CharacterID);
|
|
var image = await characters.GetCharacterImageAsync(model.CharacterImageID);
|
|
if (character is null || image is null || image.CharacterID != character.CharacterID)
|
|
{
|
|
return new(false, "Character image not found.");
|
|
}
|
|
|
|
return await visualIdentityImages.CreateCharacterAvatarAsync(character, image, model.CropX, model.CropY, model.CropSize);
|
|
}
|
|
|
|
public async Task RemoveCharacterAvatarAsync(int characterId)
|
|
{
|
|
var character = await characters.GetCharacterAsync(characterId);
|
|
if (character is not null)
|
|
{
|
|
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
|
|
await activity.RecordAsync(character.ProjectID, "Updated", "Character", characterId, character.CharacterName);
|
|
}
|
|
}
|
|
|
|
public async Task DeleteCharacterImageAsync(int characterImageId)
|
|
{
|
|
var image = await characters.GetCharacterImageAsync(characterImageId);
|
|
if (image is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var character = await characters.GetCharacterAsync(image.CharacterID);
|
|
if (character is not null && string.Equals(character.ImagePath, image.ImagePath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await characters.UpdateCharacterImageAsync(character.CharacterID, null, null);
|
|
}
|
|
|
|
await visualIdentityImages.DeleteCharacterGalleryImageAsync(image);
|
|
if (character?.AvatarSourceCharacterImageID == image.CharacterImageID)
|
|
{
|
|
await visualIdentityImages.RemoveCharacterAvatarAsync(character);
|
|
}
|
|
|
|
if (character is not null)
|
|
{
|
|
await activity.RecordAsync(character.ProjectID, "Updated", "Character", character.CharacterID, character.CharacterName);
|
|
}
|
|
}
|
|
|
|
public async Task ArchiveCharacterAsync(int characterId)
|
|
{
|
|
var character = await characters.GetCharacterAsync(characterId);
|
|
await characters.ArchiveCharacterAsync(characterId);
|
|
if (character is not null)
|
|
{
|
|
await activity.RecordAsync(character.ProjectID, "Archived", "Character", characterId, character.CharacterName);
|
|
}
|
|
}
|
|
|
|
public async Task ArchiveRelationshipAsync(int characterRelationshipId)
|
|
{
|
|
var relationship = await characters.GetRelationshipAsync(characterRelationshipId);
|
|
await characters.ArchiveRelationshipAsync(characterRelationshipId);
|
|
if (relationship is not null)
|
|
{
|
|
await activity.RecordAsync(relationship.ProjectID, "Archived", "Relationship", characterRelationshipId, relationship.RelationshipTypeName);
|
|
}
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
var isNew = model.CharacterRelationshipID == 0;
|
|
var relationshipId = 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
|
|
});
|
|
await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Relationship", relationshipId, "Character relationship");
|
|
return relationshipId;
|
|
}
|
|
|
|
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()
|
|
});
|
|
await activity.RecordAsync(model.ReturnProjectID.Value, "Created", "Character", characterId, 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 async Task AcceptSceneCharacterSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to review character suggestions.");
|
|
}
|
|
|
|
var result = await characters.AcceptSceneCharacterSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("That character suggestion is no longer available.");
|
|
}
|
|
}
|
|
|
|
public async Task RejectSceneCharacterSuggestionAsync(int suggestionId)
|
|
{
|
|
if (currentUser.UserId is not int userId)
|
|
{
|
|
throw new InvalidOperationException("Sign in to review character suggestions.");
|
|
}
|
|
|
|
var result = await characters.RejectSceneCharacterSuggestionAsync(suggestionId, userId);
|
|
if (result is null)
|
|
{
|
|
throw new InvalidOperationException("That character suggestion is no longer available.");
|
|
}
|
|
}
|
|
|
|
public Task<int> AddAttributeEventAsync(CharacterAttributeEventCreateViewModel model) => SaveAttributeEventAsync(model);
|
|
|
|
public Task<int> UpdateAttributeEventAsync(CharacterAttributeEventCreateViewModel model) => SaveAttributeEventAsync(model);
|
|
|
|
private Task<int> SaveAttributeEventAsync(CharacterAttributeEventCreateViewModel model) => characters.SaveAttributeEventAsync(new CharacterAttributeEvent
|
|
{
|
|
CharacterAttributeEventID = model.CharacterAttributeEventID,
|
|
CharacterID = model.CharacterID,
|
|
CharacterAttributeTypeID = model.CharacterAttributeTypeID,
|
|
SceneID = model.SceneID,
|
|
AttributeValue = model.AttributeValue,
|
|
Description = model.Description
|
|
});
|
|
|
|
public Task<int> AddKnowledgeAsync(CharacterKnowledgeCreateViewModel model) => SaveKnowledgeAsync(model);
|
|
|
|
public Task<int> UpdateKnowledgeAsync(CharacterKnowledgeCreateViewModel model) => SaveKnowledgeAsync(model);
|
|
|
|
private Task<int> SaveKnowledgeAsync(CharacterKnowledgeCreateViewModel model) => characters.SaveKnowledgeAsync(new CharacterKnowledgeItem
|
|
{
|
|
CharacterKnowledgeID = model.CharacterKnowledgeID,
|
|
CharacterID = model.CharacterID,
|
|
StoryAssetID = model.StoryAssetID,
|
|
PlotThreadID = model.PlotThreadID,
|
|
SceneID = model.SceneID,
|
|
KnowledgeStateID = model.KnowledgeStateID,
|
|
SourceEventID = model.SourceEventID,
|
|
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
|
|
});
|
|
}
|
|
|
|
var relationshipEventId = await characters.SaveRelationshipEventAsync(new RelationshipEvent
|
|
{
|
|
CharacterRelationshipID = relationshipId,
|
|
SceneID = model.SceneID,
|
|
RelationshipStateID = model.RelationshipStateID,
|
|
Intensity = model.Intensity,
|
|
IsKnownToOtherCharacter = model.IsKnownToOtherCharacter,
|
|
Description = model.Description
|
|
});
|
|
var relationship = await characters.GetRelationshipAsync(relationshipId);
|
|
if (relationship is not null)
|
|
{
|
|
await activity.RecordAsync(relationship.ProjectID, "Created", "Relationship Event", relationshipEventId, relationship.RelationshipTypeName);
|
|
}
|
|
return relationshipEventId;
|
|
}
|
|
|
|
public Task<int> UpdateRelationshipEventAsync(RelationshipEventCreateViewModel model) => characters.SaveRelationshipEventAsync(new RelationshipEvent
|
|
{
|
|
RelationshipEventID = model.RelationshipEventID,
|
|
CharacterRelationshipID = model.CharacterRelationshipID.GetValueOrDefault(),
|
|
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)
|
|
{
|
|
var age = CharacterAgeDisplayService.GetAgeForScene(character, sceneStartDateTime.HasValue ? "Exact DateTime" : null, sceneStartDateTime);
|
|
|
|
return age.DisplayText ?? "Unknown";
|
|
}
|
|
|
|
private static IReadOnlyList<SelectListItem> ToRelationshipTypeSelectList(IEnumerable<RelationshipType> relationshipTypes)
|
|
{
|
|
var groups = relationshipTypes
|
|
.GroupBy(x => string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName)
|
|
.ToDictionary(x => x.Key, x => new SelectListGroup { Name = x.Key });
|
|
|
|
return relationshipTypes
|
|
.OrderBy(x => x.CategorySortOrder == 0 ? 900 : x.CategorySortOrder)
|
|
.ThenBy(x => x.SortOrder)
|
|
.ThenBy(x => x.TypeName)
|
|
.Select(x => new SelectListItem(x.TypeName, x.RelationshipTypeID.ToString())
|
|
{
|
|
Group = groups[string.IsNullOrWhiteSpace(x.RelationshipCategoryName) ? "Other" : x.RelationshipCategoryName]
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|