using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] public sealed class ScenesController( ISceneService scenes, IPlotService plots, IAssetService assets, ICharacterService characters, ILocationService locations, IWriterWorkspaceService writerWorkspace, IStorageService storage, IUploadStorageService uploadStorage, ICompositeViewEngine viewEngine) : Controller { private const long MaxAttachmentBytes = 25 * 1024 * 1024; public async Task Create(int chapterId) { var model = await scenes.GetCreateAsync(chapterId); return model is null ? NotFound() : View("Edit", model); } public async Task Edit(int id) { var model = await scenes.GetEditAsync(id); return model is null ? NotFound() : View(model); } [HttpPost] [ValidateAntiForgeryToken] public async Task Save(SceneEditViewModel model) { if (!ModelState.IsValid) { var hydratedModel = model.SceneID > 0 ? await scenes.GetEditAsync(model.SceneID) : await scenes.GetCreateAsync(model.ChapterID); if (hydratedModel is null) { return NotFound(); } CopyPostedSceneFields(model, hydratedModel); return View("Edit", hydratedModel); } int sceneId; try { sceneId = await scenes.SaveAsync(model); } catch (SubscriptionLimitException ex) when (model.SceneID == 0) { ModelState.AddModelError(string.Empty, ex.Message); var createModel = await scenes.GetCreateAsync(model.ChapterID); if (createModel is null) { return NotFound(); } CopyPostedSceneFields(model, createModel); return View("Edit", createModel); } if (TryGetLocalReturnUrl(out var returnUrl)) { return LocalRedirect(returnUrl); } if (model.ReturnToTimeline && model.ReturnProjectID.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = model.ReturnProjectID.Value, bookId = model.ReturnBookID, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } [HttpGet] public async Task SceneDetailsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneDetailsEditor.cshtml", model); } [HttpGet] public async Task ScenePurposeEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_ScenePurposeEditor.cshtml", model); } [HttpGet] public async Task SceneMetricsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneMetricsEditor.cshtml", model); } [HttpGet] public async Task SceneCharactersEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharactersEditor.cshtml", model); } [HttpGet] public async Task SceneKnowledgeEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneKnowledgeEditor.cshtml", model); } [HttpGet] public async Task SceneRelationshipsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneRelationshipsEditor.cshtml", model); } [HttpGet] public async Task SceneCharacterAttributesEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharacterAttributesEditor.cshtml", model); } [HttpGet] public async Task SceneLocationsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneLocationsEditor.cshtml", model); } [HttpGet] public async Task SceneAssetsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml", model); } [HttpGet] public async Task SceneNotesEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneNotesEditor.cshtml", model); } [HttpGet] public async Task SceneChecklistEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneChecklistEditor.cshtml", model); } [HttpGet] public async Task SceneWorkflowEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneWorkflowEditor.cshtml", model); } [HttpGet] public async Task SceneAttachmentsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneAttachmentsEditor.cshtml", model); } [HttpGet] public async Task SceneThreadsEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneThreadsEditor.cshtml", model); } [HttpGet] public async Task SceneDependenciesEditor(int sceneId) { var model = await scenes.GetEditAsync(sceneId); return model is null ? NotFound() : PartialView("~/Views/Shared/SceneInspectorV2/_SceneDependenciesEditor.cshtml", model); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneDetails(SceneDetailsSaveViewModel model) { if (!ModelState.IsValid) { return BadRequest(new { success = false, message = FirstModelStateError() }); } var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } current.SceneNumber = model.SceneNumber; current.SceneTitle = model.SceneTitle; current.RevisionStatusID = model.RevisionStatusID; current.PrimaryLocationID = model.PrimaryLocationID; current.Summary = model.Summary; current.TimeModeID = model.TimeModeID; current.TimeConfidenceID = model.TimeConfidenceID; current.StartDateTime = model.StartDateTime; current.EndDateTime = model.EndDateTime; current.DurationAmount = model.DurationAmount; current.DurationUnitID = model.DurationUnitID; current.RelativeTimeText = model.RelativeTimeText; await scenes.SaveAsync(current); await scenes.UpdatePovCharacterAsync(model.SceneID, model.POVCharacterID); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated) }, scene = new { updated.SceneID, updated.SceneTitle, updated.SceneNumber, RevisionStatusName = updated.RevisionStatuses.FirstOrDefault(x => x.Value == updated.RevisionStatusID.ToString())?.Text, PovLabel = updated.CharacterOptions.FirstOrDefault(x => x.Value == updated.POVCharacterID?.ToString())?.Text ?? "No POV", PrimaryLocationName = updated.LocationOptions.FirstOrDefault(x => x.Value == updated.PrimaryLocationID?.ToString())?.Text?.Trim() } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveScenePurpose(ScenePurposeSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } current.SelectedPurposeTypeIds = model.SelectedPurposeTypeIds; current.ScenePurposeNotes = model.ScenePurposeNotes; current.SceneOutcomeNotes = model.SceneOutcomeNotes; await scenes.SaveAsync(current); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { story = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneStoryCard.cshtml", updated) } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneMetrics(SceneMetricsSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } foreach (var metric in current.Metrics) { var postedMetric = model.Metrics.FirstOrDefault(x => x.MetricTypeID == metric.MetricTypeID); if (postedMetric is not null) { metric.Value = postedMetric.Value; metric.Notes = postedMetric.Notes; } } await scenes.SaveAsync(current); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { story = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneStoryCard.cshtml", updated) } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneCharacters(SceneCharactersSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } var existingRows = current.SceneCharacters.ToDictionary(x => x.SceneCharacterID); var retainedCharacterIds = new HashSet(); foreach (var row in model.Characters.Where(x => !x.Remove)) { if (!existingRows.ContainsKey(row.SceneCharacterID)) { continue; } if (!retainedCharacterIds.Add(row.CharacterID)) { return BadRequest(new { success = false, message = "Each character can only appear once in a scene." }); } } var addCharacterId = model.NewCharacter.CharacterID.GetValueOrDefault(); if (addCharacterId > 0 && !retainedCharacterIds.Add(addCharacterId)) { return BadRequest(new { success = false, message = "That character is already in this scene." }); } foreach (var row in model.Characters) { if (!existingRows.ContainsKey(row.SceneCharacterID)) { continue; } if (row.Remove) { await characters.DeleteSceneCharacterAsync(row.SceneCharacterID); continue; } await characters.UpdateSceneCharacterAsync(new SceneCharacterEditViewModel { SceneCharacterID = row.SceneCharacterID, SceneID = model.SceneID, CharacterID = row.CharacterID, RoleInSceneTypeID = row.RoleInSceneTypeID, PresenceTypeID = row.PresenceTypeID, LocationID = row.LocationID, EntryLocationID = row.EntryLocationID, ExitLocationID = row.ExitLocationID, AppearanceNotes = row.AppearanceNotes, OutfitDescription = row.OutfitDescription, PhysicalCondition = row.PhysicalCondition, EmotionalState = row.EmotionalState }); } if (addCharacterId > 0) { model.NewCharacter.SceneID = model.SceneID; model.NewCharacter.NewCharacterName = null; model.NewCharacter.KnowledgeNotes = null; model.NewCharacter.ReturnProjectID = current.ReturnProjectID; model.NewCharacter.ReturnBookID = current.ReturnBookID; await characters.AddSceneCharacterAsync(model.NewCharacter); } var updatedBeforePov = await scenes.GetEditAsync(model.SceneID); if (updatedBeforePov is null) { return NotFound(); } var validPovIds = updatedBeforePov.SceneCharacters.Select(x => x.CharacterID).ToHashSet(); if (model.POVCharacterID.HasValue && !validPovIds.Contains(model.POVCharacterID.Value)) { return BadRequest(new { success = false, message = "Choose a POV character who is in this scene, or clear the POV." }); } await scenes.UpdatePovCharacterAsync(model.SceneID, model.POVCharacterID); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated), people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated) }, scene = new { updated.SceneID, updated.SceneTitle, PovLabel = updated.CharacterOptions.FirstOrDefault(x => x.Value == updated.POVCharacterID?.ToString())?.Text ?? "No POV" } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneLocations(SceneLocationsSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } if (model.PrimaryLocationID.HasValue && !current.LocationOptions.Any(x => x.Value == model.PrimaryLocationID.Value.ToString())) { return BadRequest(new { success = false, message = "Choose an existing project location, or clear the primary location." }); } current.PrimaryLocationID = model.PrimaryLocationID; await scenes.SaveAsync(current); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } var primaryLocationName = updated.LocationOptions.FirstOrDefault(x => x.Value == updated.PrimaryLocationID?.ToString())?.Text?.Trim(); return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated), world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated) }, scene = new { updated.SceneID, PrimaryLocationName = primaryLocationName ?? "No location" } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneAssets(SceneAssetsSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } var existingRows = current.AssetEvents.ToDictionary(x => x.AssetEventID); var retainedAssetIds = new HashSet(); foreach (var row in model.AssetEvents.Where(x => !x.Remove)) { if (!existingRows.ContainsKey(row.AssetEventID)) { continue; } retainedAssetIds.Add(row.StoryAssetID); } var addAssetId = model.NewAssetEvent.StoryAssetID.GetValueOrDefault(); if (addAssetId > 0) { if (!current.AssetOptions.Any(x => x.Value == addAssetId.ToString())) { return BadRequest(new { success = false, message = "Choose an existing project asset." }); } if (!retainedAssetIds.Add(addAssetId)) { return BadRequest(new { success = false, message = "That asset is already in this scene." }); } } foreach (var row in model.AssetEvents) { if (!existingRows.ContainsKey(row.AssetEventID)) { continue; } if (row.Remove) { await assets.DeleteAssetEventAsync(row.AssetEventID); } } if (addAssetId > 0) { model.NewAssetEvent.SceneID = model.SceneID; model.NewAssetEvent.NewAssetName = null; model.NewAssetEvent.NewAssetKindID = null; model.NewAssetEvent.ReturnProjectID = current.ReturnProjectID; model.NewAssetEvent.ReturnBookID = current.ReturnBookID; await assets.AddAssetEventAsync(model.NewAssetEvent); } var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated) }, scene = new { updated.SceneID } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneNotes(SceneNotesSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } var existingRows = current.Notes.ToDictionary(x => x.SceneNoteID); var validNoteTypeIds = current.SceneNoteTypeOptions .Where(x => int.TryParse(x.Value, out _)) .Select(x => int.Parse(x.Value)) .ToHashSet(); foreach (var note in model.Notes) { if (!existingRows.ContainsKey(note.SceneNoteID)) { continue; } if (note.Remove) { await writerWorkspace.DeleteNoteAsync(note.SceneNoteID); continue; } if (string.IsNullOrWhiteSpace(note.NoteText)) { return BadRequest(new { success = false, message = "Enter note text before saving." }); } if (!validNoteTypeIds.Contains(note.SceneNoteTypeID)) { return BadRequest(new { success = false, message = "Choose an existing note type." }); } note.SceneID = model.SceneID; await writerWorkspace.SaveNoteAsync(new SceneNoteEditViewModel { SceneNoteID = note.SceneNoteID, SceneID = note.SceneID, SceneNoteTypeID = note.SceneNoteTypeID, NoteTitle = note.NoteTitle, NoteText = note.NoteText, SortOrder = note.SortOrder, IsPinned = note.IsPinned, IsResolved = note.IsResolved }); } var hasNewNoteContent = !string.IsNullOrWhiteSpace(model.NewNote.NoteTitle) || !string.IsNullOrWhiteSpace(model.NewNote.NoteText); if (hasNewNoteContent) { if (string.IsNullOrWhiteSpace(model.NewNote.NoteText)) { return BadRequest(new { success = false, message = "Enter note text before saving." }); } if (!validNoteTypeIds.Contains(model.NewNote.SceneNoteTypeID)) { return BadRequest(new { success = false, message = "Choose an existing note type." }); } model.NewNote.SceneID = model.SceneID; await writerWorkspace.SaveNoteAsync(model.NewNote); } var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated), health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated) }, scene = new { updated.SceneID } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneChecklist(SceneChecklistSaveViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } var existingRows = current.ChecklistItems.ToDictionary(x => x.SceneChecklistItemID); foreach (var item in model.ChecklistItems) { if (!existingRows.ContainsKey(item.SceneChecklistItemID)) { continue; } if (item.Remove) { await writerWorkspace.DeleteChecklistItemAsync(item.SceneChecklistItemID); continue; } if (string.IsNullOrWhiteSpace(item.ItemText)) { return BadRequest(new { success = false, message = "Enter checklist text before saving." }); } item.SceneID = model.SceneID; await writerWorkspace.SaveChecklistItemAsync(new SceneChecklistItemEditViewModel { SceneChecklistItemID = item.SceneChecklistItemID, SceneID = item.SceneID, ItemText = item.ItemText, IsCompleted = item.IsCompleted, SortOrder = item.SortOrder }); } if (!string.IsNullOrWhiteSpace(model.NewChecklistItem.ItemText)) { model.NewChecklistItem.SceneID = model.SceneID; await writerWorkspace.SaveChecklistItemAsync(model.NewChecklistItem); } var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated), health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated) }, scene = new { updated.SceneID } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveSceneWorkflow(SceneWorkflowEditViewModel model) { var current = await scenes.GetEditAsync(model.SceneID); if (current is null) { return NotFound(); } await writerWorkspace.SaveSceneWorkflowAsync(model); var updated = await scenes.GetEditAsync(model.SceneID); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated), health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated) }, scene = new { updated.SceneID } }); } [HttpPost] [ValidateAntiForgeryToken] public async Task Archive(int id, int chapterId) { try { await scenes.ArchiveAsync(id); } catch (InvalidOperationException ex) { TempData["ArchiveMessage"] = ex.Message; return RedirectToAction("Details", "Chapters", new { id = chapterId }); } TempData["ArchiveMessage"] = "Scene archived. You can restore it from Archived Items."; if (TryGetLocalReturnUrl(out var returnUrl)) { return LocalRedirect(RemoveSelectedScene(returnUrl)); } return RedirectToAction("Details", "Chapters", new { id = chapterId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task MoveUp(int id, int chapterId) { await scenes.MoveAsync(id, "Up"); return RedirectToAction("Details", "Chapters", new { id = chapterId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task MoveDown(int id, int chapterId) { await scenes.MoveAsync(id, "Down"); return RedirectToAction("Details", "Chapters", new { id = chapterId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddThreadEvent(ThreadEventCreateViewModel model) { try { await plots.AddThreadEventAsync(model); } catch (InvalidOperationException ex) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = ex.Message }); } TempData["ThreadEventAddError"] = ex.Message; } if (IsAjaxRequest()) { return await SceneStoryRefreshJsonAsync(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) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = ex.Message }); } TempData[$"ThreadEventEditError:{model.ThreadEventID}"] = ex.Message; } if (IsAjaxRequest()) { return await SceneStoryRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId) { await plots.DeleteThreadEventAsync(id); if (IsAjaxRequest()) { return await SceneStoryRefreshJsonAsync(sceneId); } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddAssetEvent(AssetEventCreateViewModel model) { await assets.AddAssetEventAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteAssetEvent(int id, int sceneId, int? projectId, int? bookId) { await assets.DeleteAssetEventAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddAssetCustodyEvent(AssetCustodyCreateViewModel model) { await assets.AddCustodyEventAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddSceneAssetLocation(SceneAssetLocationCreateViewModel model) { await locations.SaveSceneAssetLocationAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteSceneAssetLocation(int id, int sceneId, int? projectId, int? bookId) { await locations.DeleteSceneAssetLocationAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteAssetCustodyEvent(int id, int sceneId, int? projectId, int? bookId) { await assets.DeleteCustodyEventAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddSceneCharacter(SceneCharacterCreateViewModel model) { try { await characters.AddSceneCharacterAsync(model); } catch (InvalidOperationException ex) { TempData["SceneCharacterAddError"] = ex.Message; } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateSceneCharacter(SceneCharacterEditViewModel model) { await characters.UpdateSceneCharacterAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteSceneCharacter(int id, int sceneId, int? projectId, int? bookId) { await characters.DeleteSceneCharacterAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AcceptCharacterSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await characters.AcceptSceneCharacterSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneCharacterSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task RejectCharacterSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await characters.RejectSceneCharacterSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneCharacterSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AcceptAssetSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await assets.AcceptSceneAssetSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneAssetSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task RejectAssetSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await assets.RejectSceneAssetSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneAssetSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AcceptLocationSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await locations.AcceptSceneLocationSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneLocationSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task RejectLocationSuggestion(int id, int sceneId, int? projectId, int? bookId) { try { await locations.RejectSceneLocationSuggestionAsync(id); } catch (InvalidOperationException ex) { TempData["SceneLocationSuggestionError"] = ex.Message; } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddSceneDependency(SceneDependencyCreateViewModel model) { await scenes.AddDependencyAsync(model); if (IsAjaxRequest()) { return await SceneStoryRefreshJsonAsync(model.CurrentSceneID); } return RedirectForScene(model.CurrentSceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteSceneDependency(int id, int sceneId, int? projectId, int? bookId) { await scenes.DeleteDependencyAsync(id); if (IsAjaxRequest()) { return await SceneStoryRefreshJsonAsync(sceneId); } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task PreviewMove(SceneMoveRequestViewModel model) { model.ReturnUrl = GetLocalReturnUrl(); var preview = await scenes.GetMovePreviewAsync(model); return preview is null ? NotFound() : View("MovePreview", preview); } [HttpPost] [ValidateAntiForgeryToken] public async Task ConfirmMove(SceneMoveRequestViewModel model) { await scenes.MoveToChapterAsync(model); if (TryGetLocalReturnUrl(model.ReturnUrl, out var returnUrl)) { return LocalRedirect(returnUrl); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task RenumberChapter(int chapterId, int? projectId, int? bookId, int? sceneId) { await scenes.RenumberChapterAsync(chapterId); return sceneId.HasValue ? RedirectForScene(sceneId.Value, projectId, bookId) : RedirectToAction("Details", "Chapters", new { id = chapterId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddCharacterAttributeEvent(CharacterAttributeEventCreateViewModel model) { if (model.SceneID <= 0) { return BadRequest(); } if (model.CharacterID <= 0) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Choose a character before adding an attribute." }); } TempData["CharacterAttributeAddError"] = "Choose a character before adding an attribute."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } if (model.CharacterAttributeTypeID <= 0) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Choose an attribute type before saving." }); } TempData["CharacterAttributeAddError"] = "Choose an attribute type before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } if (string.IsNullOrWhiteSpace(model.AttributeValue)) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Enter an attribute value before saving." }); } TempData["CharacterAttributeAddError"] = "Enter an attribute value before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } await characters.AddAttributeEventAsync(model); if (IsAjaxRequest()) { return await SceneCharacterAttributesRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateCharacterAttributeEvent(CharacterAttributeEventCreateViewModel model) { if (model.CharacterAttributeEventID <= 0 || model.SceneID <= 0) { return BadRequest(); } if (model.CharacterID <= 0) { return BadRequest(new { success = false, message = "Choose a character before saving this attribute." }); } if (model.CharacterAttributeTypeID <= 0) { return BadRequest(new { success = false, message = "Choose an attribute type before saving." }); } if (string.IsNullOrWhiteSpace(model.AttributeValue)) { return BadRequest(new { success = false, message = "Enter an attribute value before saving." }); } await characters.UpdateAttributeEventAsync(model); if (IsAjaxRequest()) { return await SceneCharacterAttributesRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddCharacterKnowledge(CharacterKnowledgeCreateViewModel model) { await characters.AddKnowledgeAsync(model); if (IsAjaxRequest()) { return await SceneKnowledgeRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateCharacterKnowledge(CharacterKnowledgeCreateViewModel model) { await characters.UpdateKnowledgeAsync(model); if (IsAjaxRequest()) { return await SceneKnowledgeRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteCharacterKnowledge(int id, int sceneId, int? projectId, int? bookId) { await characters.DeleteKnowledgeAsync(id); if (IsAjaxRequest()) { return await SceneKnowledgeRefreshJsonAsync(sceneId); } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddRelationshipEvent(RelationshipEventCreateViewModel model) { try { await characters.AddRelationshipEventAsync(model); } catch (InvalidOperationException ex) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = ex.Message }); } TempData["RelationshipEventAddError"] = ex.Message; } if (IsAjaxRequest()) { return await ScenePeopleContinuityRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task UpdateRelationshipEvent(RelationshipEventCreateViewModel model) { await characters.UpdateRelationshipEventAsync(model); if (IsAjaxRequest()) { return await ScenePeopleContinuityRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteRelationshipEvent(int id, int sceneId, int? projectId, int? bookId) { await characters.DeleteRelationshipEventAsync(id); if (IsAjaxRequest()) { return await ScenePeopleContinuityRefreshJsonAsync(sceneId); } return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveWorkflow(SceneWorkflowEditViewModel model) { await writerWorkspace.SaveSceneWorkflowAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveNote(SceneNoteEditViewModel model) { if (string.IsNullOrWhiteSpace(model.NoteText)) { TempData["WriterWorkspaceError"] = "Enter note text before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } await writerWorkspace.SaveNoteAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteNote(int id, int sceneId, int? projectId, int? bookId) { await writerWorkspace.DeleteNoteAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveChecklistItem(SceneChecklistItemEditViewModel model) { if (string.IsNullOrWhiteSpace(model.ItemText)) { TempData["WriterWorkspaceError"] = "Enter checklist text before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } await writerWorkspace.SaveChecklistItemAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteChecklistItem(int id, int sceneId, int? projectId, int? bookId) { await writerWorkspace.DeleteChecklistItemAsync(id); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveAttachment(SceneAttachmentEditViewModel model) { if (string.IsNullOrWhiteSpace(model.Title)) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Enter an attachment title before saving." }); } TempData["WriterWorkspaceError"] = "Enter an attachment title before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } if (model.UploadedFile is not null && model.UploadedFile.Length > MaxAttachmentBytes) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Attachment files must be 25 MB or smaller." }); } TempData["WriterWorkspaceError"] = "Attachment files must be 25 MB or smaller."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } var uploadedFile = model.UploadedFile; var originalFilePath = model.FilePath; string? originalFileName = null; string? contentType = null; long fileSizeBytes = 0; if (uploadedFile is not null && uploadedFile.Length > 0) { var storageLimit = await storage.CanUploadSceneAttachmentAsync(model.SceneID, uploadedFile.Length); if (storageLimit is null) { return NotFound(); } if (!storageLimit.Allowed) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = storageLimit.Message }); } TempData["WriterWorkspaceError"] = storageLimit.Message; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } originalFileName = Path.GetFileName(uploadedFile.FileName); contentType = string.IsNullOrWhiteSpace(uploadedFile.ContentType) ? null : uploadedFile.ContentType; fileSizeBytes = uploadedFile.Length; model.FilePath = await SaveUploadedAttachmentAsync(model.SceneID, uploadedFile); } if (string.IsNullOrWhiteSpace(model.FilePath) && string.IsNullOrWhiteSpace(model.ExternalUrl)) { if (IsAjaxRequest()) { return BadRequest(new { success = false, message = "Upload a file or enter an external link." }); } TempData["WriterWorkspaceError"] = "Upload a file or enter an external link."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } var attachmentId = await writerWorkspace.SaveAttachmentAsync(model); if (fileSizeBytes > 0 && !string.IsNullOrWhiteSpace(model.FilePath)) { await storage.TrackSceneAttachmentAsync( model.SceneID, attachmentId, model.FilePath, originalFileName ?? Path.GetFileName(model.FilePath), contentType, fileSizeBytes); if (!string.IsNullOrWhiteSpace(originalFilePath) && !string.Equals(originalFilePath, model.FilePath, StringComparison.OrdinalIgnoreCase)) { await storage.MarkDeletedByStoragePathAsync(originalFilePath); uploadStorage.TryDeleteFile(originalFilePath, "uploads/scenes"); } } if (IsAjaxRequest()) { return await SceneWriterRefreshJsonAsync(model.SceneID); } return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteAttachment(int id, int sceneId, int? projectId, int? bookId, string? filePath) { await writerWorkspace.DeleteAttachmentAsync(id); await storage.MarkDeletedByStoragePathAsync(filePath); uploadStorage.TryDeleteFile(filePath, "uploads/scenes"); if (IsAjaxRequest()) { return await SceneWriterRefreshJsonAsync(sceneId); } return RedirectForScene(sceneId, projectId, bookId); } private bool IsAjaxRequest() => string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase); private async Task SceneWriterRefreshJsonAsync(int sceneId) { var updated = await scenes.GetEditAsync(sceneId); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated) }, scene = new { updated.SceneID } }); } private async Task SceneStoryRefreshJsonAsync(int sceneId) { var updated = await scenes.GetEditAsync(sceneId); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { story = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneStoryCard.cshtml", updated) }, scene = new { updated.SceneID } }); } private async Task SceneKnowledgeRefreshJsonAsync(int sceneId) { return await ScenePeopleContinuityRefreshJsonAsync(sceneId); } private async Task SceneCharacterAttributesRefreshJsonAsync(int sceneId) { var updated = await scenes.GetEditAsync(sceneId); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated), continuity = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml", updated) }, editorHtml = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneCharacterAttributesEditor.cshtml", updated), scene = new { updated.SceneID } }); } private async Task ScenePeopleContinuityRefreshJsonAsync(int sceneId) { var updated = await scenes.GetEditAsync(sceneId); if (updated is null) { return NotFound(); } return Json(new { success = true, html = new { health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated), people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated), continuity = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml", updated) }, scene = new { updated.SceneID } }); } private IActionResult RedirectForScene(int sceneId, int? projectId, int? bookId) { if (TryGetLocalReturnUrl(out var returnUrl)) { return LocalRedirect(returnUrl); } if (projectId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } [HttpGet] public async Task OccupancyEditor(int sceneId, int? floorPlanId) { var model = await scenes.GetOccupancyEditorAsync(sceneId, floorPlanId); return model is null ? NotFound() : PartialView("_SceneOccupancy", model); } [HttpPost] [ValidateAntiForgeryToken] public async Task SaveOccupancy(SceneOccupancySaveViewModel model) { try { await scenes.SaveOccupancyAsync(model); var refreshed = await scenes.GetOccupancyEditorAsync(model.SceneID, model.FloorPlanID); return refreshed is null ? NotFound() : PartialView("_SceneOccupancy", refreshed); } catch (InvalidOperationException ex) { Response.StatusCode = StatusCodes.Status400BadRequest; return Json(new { ok = false, message = ex.Message }); } } private string? GetLocalReturnUrl(string? returnUrl = null) { if (string.IsNullOrWhiteSpace(returnUrl) && Request.HasFormContentType && Request.Form.TryGetValue("ReturnUrl", out var postedReturnUrl)) { returnUrl = postedReturnUrl.ToString(); } return !string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : null; } private bool TryGetLocalReturnUrl(out string returnUrl) => TryGetLocalReturnUrl(null, out returnUrl); private bool TryGetLocalReturnUrl(string? candidate, out string returnUrl) { var localReturnUrl = GetLocalReturnUrl(candidate); if (!string.IsNullOrWhiteSpace(localReturnUrl)) { returnUrl = localReturnUrl; return true; } returnUrl = string.Empty; return false; } private static string RemoveSelectedScene(string returnUrl) { var hashIndex = returnUrl.IndexOf('#'); var hash = hashIndex >= 0 ? returnUrl[hashIndex..] : string.Empty; var withoutHash = hashIndex >= 0 ? returnUrl[..hashIndex] : returnUrl; var queryIndex = withoutHash.IndexOf('?'); if (queryIndex < 0) { return returnUrl; } var path = withoutHash[..queryIndex]; var query = withoutHash[(queryIndex + 1)..] .Split('&', StringSplitOptions.RemoveEmptyEntries) .Where(part => { var equalsIndex = part.IndexOf('='); var key = equalsIndex >= 0 ? part[..equalsIndex] : part; return !string.Equals(Uri.UnescapeDataString(key), "selectedSceneId", StringComparison.OrdinalIgnoreCase); }) .ToList(); return query.Count == 0 ? path + hash : $"{path}?{string.Join("&", query)}{hash}"; } private static void CopyPostedSceneFields(SceneEditViewModel source, SceneEditViewModel target) { target.SceneTitle = source.SceneTitle; target.SceneNumber = source.SceneNumber; target.Summary = source.Summary; target.TimeModeID = source.TimeModeID; target.StartDateTime = source.StartDateTime; target.EndDateTime = source.EndDateTime; target.DurationAmount = source.DurationAmount; target.DurationUnitID = source.DurationUnitID; target.RelativeTimeText = source.RelativeTimeText; target.TimeConfidenceID = source.TimeConfidenceID; target.ScenePurposeNotes = source.ScenePurposeNotes; target.SceneOutcomeNotes = source.SceneOutcomeNotes; target.RevisionStatusID = source.RevisionStatusID; target.PrimaryLocationID = source.PrimaryLocationID; target.FloorPlanID = source.FloorPlanID; target.InitialFloorPlanFloorID = source.InitialFloorPlanFloorID; target.SelectedPurposeTypeIds = source.SelectedPurposeTypeIds; target.ReturnToTimeline = source.ReturnToTimeline; target.ReturnProjectID = source.ReturnProjectID; target.ReturnBookID = source.ReturnBookID; target.Metrics = target.Metrics.Select(metric => { var postedMetric = source.Metrics.FirstOrDefault(x => x.MetricTypeID == metric.MetricTypeID); if (postedMetric is not null) { metric.Value = postedMetric.Value; metric.Notes = postedMetric.Notes; } return metric; }).ToList(); } private string FirstModelStateError() => ModelState.Values .SelectMany(x => x.Errors) .Select(x => x.ErrorMessage) .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "Please check the highlighted fields and try again."; private async Task RenderPartialViewToStringAsync(string viewName, object model) { var viewResult = viewEngine.GetView(executingFilePath: null, viewPath: viewName, isMainPage: false); if (!viewResult.Success) { viewResult = viewEngine.FindView(ControllerContext, viewName, isMainPage: false); } if (!viewResult.Success) { throw new InvalidOperationException($"Partial view '{viewName}' was not found."); } await using var writer = new StringWriter(); var viewData = new ViewDataDictionary(ViewData) { Model = model }; var viewContext = new ViewContext( ControllerContext, viewResult.View, viewData, TempData, writer, new HtmlHelperOptions()); await viewResult.View.RenderAsync(viewContext); return writer.ToString(); } private async Task SaveUploadedAttachmentAsync(int sceneId, IFormFile uploadedFile) { var uploadRoot = uploadStorage.GetDirectory("scenes", sceneId.ToString()); var originalName = Path.GetFileName(uploadedFile.FileName); var safeName = MakeSafeFileName(originalName); var storedName = $"{Guid.NewGuid():N}_{safeName}"; var absolutePath = Path.Combine(uploadRoot, storedName); await using var stream = System.IO.File.Create(absolutePath); await uploadedFile.CopyToAsync(stream); return uploadStorage.GetPublicPath("scenes", sceneId.ToString(), storedName); } private static string MakeSafeFileName(string fileName) { var name = string.IsNullOrWhiteSpace(fileName) ? "attachment" : fileName; foreach (var invalidChar in Path.GetInvalidFileNameChars()) { name = name.Replace(invalidChar, '-'); } name = string.Join("-", name.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries)); return name.Length > 120 ? name[^120..] : name; } }