using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; 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) : 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 }); } [HttpPost] [ValidateAntiForgeryToken] public async Task Archive(int id, int chapterId) { await scenes.ArchiveAsync(id); 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) { TempData["ThreadEventAddError"] = ex.Message; } 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] [ValidateAntiForgeryToken] public async Task DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId) { await plots.DeleteThreadEventAsync(id); 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 AddSceneDependency(SceneDependencyCreateViewModel model) { await scenes.AddDependencyAsync(model); 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); 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) { TempData["CharacterAttributeAddError"] = "Choose a character before adding an attribute."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } if (model.CharacterAttributeTypeID <= 0) { TempData["CharacterAttributeAddError"] = "Choose an attribute type before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } if (string.IsNullOrWhiteSpace(model.AttributeValue)) { TempData["CharacterAttributeAddError"] = "Enter an attribute value before saving."; return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } await characters.AddAttributeEventAsync(model); return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddCharacterKnowledge(CharacterKnowledgeCreateViewModel model) { await characters.AddKnowledgeAsync(model); 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); return RedirectForScene(sceneId, projectId, bookId); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddRelationshipEvent(RelationshipEventCreateViewModel model) { await characters.AddRelationshipEventAsync(model); 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); 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)) { 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) { 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) { 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)) { 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"); } } 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"); return RedirectForScene(sceneId, projectId, bookId); } 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 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; } }