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, IWebHostEnvironment environment) : 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) { return View("Edit", model); } 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 (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."; 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) { await plots.AddThreadEventAsync(model); if (model.ReturnProjectID.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = model.ReturnProjectID.Value, bookId = model.ReturnBookID, selectedSceneId = model.SceneID }); } return RedirectToAction(nameof(Edit), new { id = model.SceneID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId) { await plots.DeleteThreadEventAsync(id); if (projectId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddAssetEvent(AssetEventCreateViewModel model) { await assets.AddAssetEventAsync(model); if (model.ReturnProjectID.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = model.ReturnProjectID.Value, bookId = model.ReturnBookID, selectedSceneId = model.SceneID }); } return RedirectToAction(nameof(Edit), new { id = model.SceneID }); } [HttpPost] [ValidateAntiForgeryToken] public async Task DeleteAssetEvent(int id, int sceneId, int? projectId, int? bookId) { await assets.DeleteAssetEventAsync(id); if (projectId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task AddAssetCustodyEvent(AssetCustodyCreateViewModel model) { await assets.AddCustodyEventAsync(model); if (model.ReturnProjectID.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = model.ReturnProjectID.Value, bookId = model.ReturnBookID, selectedSceneId = model.SceneID }); } return RedirectToAction(nameof(Edit), new { id = model.SceneID }); } [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); if (projectId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } [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) { 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); 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) { 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); DeleteUploadedAttachment(originalFilePath); } } 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); DeleteUploadedAttachment(filePath); return RedirectForScene(sceneId, projectId, bookId); } private IActionResult RedirectForScene(int sceneId, int? projectId, int? bookId) { if (projectId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Edit), new { id = sceneId }); } 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.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 = Path.Combine(environment.WebRootPath, "uploads", "scenes", sceneId.ToString()); Directory.CreateDirectory(uploadRoot); 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 $"/uploads/scenes/{sceneId}/{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; } private void DeleteUploadedAttachment(string? filePath) { if (string.IsNullOrWhiteSpace(filePath) || !filePath.StartsWith("/uploads/scenes/", StringComparison.OrdinalIgnoreCase)) { return; } var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); var relativePath = filePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar); var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, relativePath)); if (!absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(absolutePath)) { return; } System.IO.File.Delete(absolutePath); } }