551 lines
20 KiB
C#
551 lines
20 KiB
C#
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<IActionResult> Create(int chapterId)
|
|
{
|
|
var model = await scenes.GetCreateAsync(chapterId);
|
|
return model is null ? NotFound() : View("Edit", model);
|
|
}
|
|
|
|
public async Task<IActionResult> Edit(int id)
|
|
{
|
|
var model = await scenes.GetEditAsync(id);
|
|
return model is null ? NotFound() : View(model);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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 (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<IActionResult> 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<IActionResult> MoveUp(int id, int chapterId)
|
|
{
|
|
await scenes.MoveAsync(id, "Up");
|
|
return RedirectToAction("Details", "Chapters", new { id = chapterId });
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> MoveDown(int id, int chapterId)
|
|
{
|
|
await scenes.MoveAsync(id, "Down");
|
|
return RedirectToAction("Details", "Chapters", new { id = chapterId });
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> AddSceneAssetLocation(SceneAssetLocationCreateViewModel model)
|
|
{
|
|
await locations.SaveSceneAssetLocationAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteSceneAssetLocation(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await locations.DeleteSceneAssetLocationAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> UpdateSceneCharacter(SceneCharacterEditViewModel model)
|
|
{
|
|
await characters.UpdateSceneCharacterAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteSceneCharacter(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await characters.DeleteSceneCharacterAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddSceneDependency(SceneDependencyCreateViewModel model)
|
|
{
|
|
await scenes.AddDependencyAsync(model);
|
|
return RedirectForScene(model.CurrentSceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteSceneDependency(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await scenes.DeleteDependencyAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> PreviewMove(SceneMoveRequestViewModel model)
|
|
{
|
|
var preview = await scenes.GetMovePreviewAsync(model);
|
|
return preview is null ? NotFound() : View("MovePreview", preview);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> ConfirmMove(SceneMoveRequestViewModel model)
|
|
{
|
|
await scenes.MoveToChapterAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> AddCharacterAttributeEvent(CharacterAttributeEventCreateViewModel model)
|
|
{
|
|
await characters.AddAttributeEventAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddCharacterKnowledge(CharacterKnowledgeCreateViewModel model)
|
|
{
|
|
await characters.AddKnowledgeAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteCharacterKnowledge(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await characters.DeleteKnowledgeAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddRelationshipEvent(RelationshipEventCreateViewModel model)
|
|
{
|
|
await characters.AddRelationshipEventAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DeleteRelationshipEvent(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await characters.DeleteRelationshipEventAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveWorkflow(SceneWorkflowEditViewModel model)
|
|
{
|
|
await writerWorkspace.SaveSceneWorkflowAsync(model);
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> DeleteNote(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await writerWorkspace.DeleteNoteAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> DeleteChecklistItem(int id, int sceneId, int? projectId, int? bookId)
|
|
{
|
|
await writerWorkspace.DeleteChecklistItemAsync(id);
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> 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<IActionResult> 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<string> 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);
|
|
}
|
|
}
|