diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index 70d204d..0908cdb 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -108,18 +108,32 @@ public sealed class ScenesController( [ValidateAntiForgeryToken] public async Task AddThreadEvent(ThreadEventCreateViewModel model) { - await plots.AddThreadEventAsync(model); - if (model.ReturnProjectID.HasValue) + try { - return RedirectToAction("Index", "Timeline", new - { - projectId = model.ReturnProjectID.Value, - bookId = model.ReturnBookID, - selectedSceneId = model.SceneID - }); + await plots.AddThreadEventAsync(model); + } + catch (InvalidOperationException ex) + { + TempData["ThreadEventAddError"] = ex.Message; } - return RedirectToAction(nameof(Edit), new { id = model.SceneID }); + return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task UpdateThreadEvent(ThreadEventEditViewModel model) + { + try + { + await plots.UpdateThreadEventAsync(model); + } + catch (InvalidOperationException ex) + { + TempData[$"ThreadEventEditError:{model.ThreadEventID}"] = ex.Message; + } + + return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index bab729a..8ab9805 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -126,6 +126,7 @@ public interface IPlotRepository Task> ListThreadEventsBySceneAsync(int sceneId); Task> ListThreadEventsByPlotThreadAsync(int plotThreadId); Task SaveThreadEventAsync(ThreadEvent threadEvent); + Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable plotLineIds); Task DeleteThreadEventAsync(int threadEventId); Task> ListSceneOptionsAsync(int projectId); } @@ -1950,15 +1951,47 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP public async Task> ListThreadEventsBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); - var rows = await connection.QueryAsync("dbo.ThreadEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure); - return rows.ToList(); + var rows = (await connection.QueryAsync("dbo.ThreadEvent_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure)).ToList(); + await PopulateThreadEventTargetsAsync(connection, rows); + return rows; } public async Task> ListThreadEventsByPlotThreadAsync(int plotThreadId) { using var connection = connectionFactory.CreateConnection(); - var rows = await connection.QueryAsync("dbo.ThreadEvent_ListByPlotThread", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure); - return rows.ToList(); + var rows = (await connection.QueryAsync("dbo.ThreadEvent_ListByPlotThread", new { PlotThreadID = plotThreadId }, commandType: CommandType.StoredProcedure)).ToList(); + await PopulateThreadEventTargetsAsync(connection, rows); + return rows; + } + + private static async Task PopulateThreadEventTargetsAsync(IDbConnection connection, IReadOnlyList threadEvents) + { + var threadEventIds = threadEvents.Select(x => x.ThreadEventID).ToList(); + if (threadEventIds.Count == 0) + { + return; + } + + var targetRows = await connection.QueryAsync( + "SELECT PlotEventID, PlotLineID FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID IN @ThreadEventIDs;", + new { ThreadEventIDs = threadEventIds }); + var targetsByEvent = targetRows + .GroupBy(x => x.PlotEventID) + .ToDictionary(x => x.Key, x => (IReadOnlyList)x.Select(row => row.PlotLineID).ToList()); + + foreach (var threadEvent in threadEvents) + { + if (targetsByEvent.TryGetValue(threadEvent.ThreadEventID, out var targets)) + { + threadEvent.TargetPlotLineIDs = targets; + } + } + } + + private sealed class ThreadEventTargetRow + { + public int PlotEventID { get; set; } + public int PlotLineID { get; set; } } public async Task SaveThreadEventAsync(ThreadEvent threadEvent) @@ -1980,6 +2013,24 @@ public sealed class PlotRepository(ISqlConnectionFactory connectionFactory) : IP commandType: CommandType.StoredProcedure); } + public async Task SaveThreadEventTargetsAsync(int threadEventId, IEnumerable plotLineIds) + { + using var connection = connectionFactory.CreateConnection(); + var targetIds = plotLineIds.Where(id => id > 0).Distinct().ToList(); + await connection.ExecuteAsync( + "DELETE FROM dbo.PlotEventTargetPlotLines WHERE PlotEventID = @ThreadEventID;", + new { ThreadEventID = threadEventId }); + + if (targetIds.Count == 0) + { + return; + } + + await connection.ExecuteAsync( + "INSERT dbo.PlotEventTargetPlotLines (PlotEventID, PlotLineID) VALUES (@ThreadEventID, @PlotLineID);", + targetIds.Select(plotLineId => new { ThreadEventID = threadEventId, PlotLineID = plotLineId })); + } + public async Task DeleteThreadEventAsync(int threadEventId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 2e0a580..e067ca1 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -469,6 +469,7 @@ public sealed class ThreadEvent public string PlotEventTypeName { get; set; } = "Progress"; public int? TargetPlotLineID { get; set; } public string? TargetPlotLineName { get; set; } + public IReadOnlyList TargetPlotLineIDs { get; set; } = []; public string EventTitle { get; set; } = string.Empty; public string? EventDescription { get; set; } public DateTime CreatedDate { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 7875d97..cab0524 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -122,6 +122,7 @@ public interface IPlotService Task SavePlotThreadAsync(PlotThreadEditViewModel model); Task ArchivePlotThreadAsync(int plotThreadId); Task AddThreadEventAsync(ThreadEventCreateViewModel model); + Task UpdateThreadEventAsync(ThreadEventEditViewModel model); Task DeleteThreadEventAsync(int threadEventId); } @@ -1081,8 +1082,11 @@ public sealed class SceneService( .ToList(); 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); @@ -1107,6 +1111,9 @@ public sealed class SceneService( 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 @@ -2255,12 +2262,20 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo { 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) @@ -2284,16 +2299,53 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo 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 = lookupData.PlotEventTypes.FirstOrDefault(x => x.TypeName == "Progress")?.PlotEventTypeID ?? 2, + 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) { @@ -2302,6 +2354,100 @@ public sealed class PlotService(IProjectRepository projects, IBookRepository boo 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 projectPlotLines, + int? targetPlotLineId, + IEnumerable 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) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 1db34ec..cab9792 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -178,8 +178,11 @@ public sealed class SceneEditViewModel public RelationshipEventCreateViewModel NewRelationshipEvent { get; set; } = new(); public IReadOnlyList PlotLineOptions { get; set; } = []; public IReadOnlyList PlotThreadOptions { get; set; } = []; + public IReadOnlyList PlotLineEventOptions { get; set; } = []; + public IReadOnlyList PlotThreadEventOptions { get; set; } = []; public IReadOnlyList ThreadTypeOptions { get; set; } = []; public IReadOnlyList ThreadEventTypeOptions { get; set; } = []; + public IReadOnlyList PlotEventTypeOptions { get; set; } = []; public IReadOnlyList AssetOptions { get; set; } = []; public IReadOnlyList AssetKindOptions { get; set; } = []; public IReadOnlyList AssetStateOptions { get; set; } = []; @@ -801,6 +804,26 @@ public sealed class ThreadEventCreateViewModel public string? NewThreadTitle { get; set; } public int? NewThreadTypeID { get; set; } public int EventTypeID { get; set; } + [Range(1, int.MaxValue, ErrorMessage = "Choose an event type.")] + public int PlotEventTypeID { get; set; } = 2; + public int? TargetPlotLineID { get; set; } + public List SelectedTargetPlotLineIDs { get; set; } = []; + public string EventTitle { get; set; } = string.Empty; + public string? EventDescription { get; set; } + public int? ReturnProjectID { get; set; } + public int? ReturnBookID { get; set; } +} + +public sealed class ThreadEventEditViewModel +{ + public int ThreadEventID { get; set; } + public int SceneID { get; set; } + public int PlotThreadID { get; set; } + public int EventTypeID { get; set; } + [Range(1, int.MaxValue, ErrorMessage = "Choose an event type.")] + public int PlotEventTypeID { get; set; } = 2; + public int? TargetPlotLineID { get; set; } + public List SelectedTargetPlotLineIDs { get; set; } = []; public string EventTitle { get; set; } = string.Empty; public string? EventDescription { get; set; } public int? ReturnProjectID { get; set; } diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 383e483..e0f1b03 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -695,7 +695,7 @@
@threadEvent.MarkerText @threadEvent.EventTitle - @threadEvent.PlotLineName / @threadEvent.ThreadTitle + @threadEvent.PlotEventTypeName / @threadEvent.PlotLineName / @threadEvent.ThreadTitle
@@ -704,28 +704,149 @@
+
+ Edit +
+ + + + + + + @if (TempData[$"ThreadEventEditError:{threadEvent.ThreadEventID}"] is string editThreadEventError) + { +

@editThreadEventError

+ } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
} } -
+

Choose an existing thread, or choose a plot line to create one from the event title.

+ @if (TempData["ThreadEventAddError"] is string addThreadEventError) + { +

@addThreadEventError

+ }
- + @foreach (var plotLine in Model.PlotLineEventOptions) + { + + }
- + @foreach (var plotThread in Model.PlotThreadEventOptions) + { + + }
@@ -740,6 +861,52 @@
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -754,6 +921,53 @@ +