using Microsoft.AspNetCore.Mvc; using PlotLine.Services; using PlotLine.ViewModels; namespace PlotLine.Controllers; public sealed class ScenesController( ISceneService scenes, IPlotService plots, IAssetService assets, ICharacterService characters, ILocationService locations, IWriterWorkspaceService writerWorkspace, 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); } var sceneId = await scenes.SaveAsync(model); 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); } if (model.UploadedFile is not null && model.UploadedFile.Length > 0) { model.FilePath = await SaveUploadedAttachmentAsync(model.SceneID, model.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); } await writerWorkspace.SaveAttachmentAsync(model); 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); 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 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); } }