PlotDirector/PlotLine/Controllers/ScenesController.cs

939 lines
32 KiB
C#

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<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 (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<IActionResult> 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<IActionResult> 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<IActionResult> SceneMetricsEditor(int sceneId)
{
var model = await scenes.GetEditAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneMetricsEditor.cshtml", model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<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)
{
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<IActionResult> 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<IActionResult> DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId)
{
await plots.DeleteThreadEventAsync(id);
return RedirectForScene(sceneId, projectId, bookId);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddAssetEvent(AssetEventCreateViewModel model)
{
await assets.AddAssetEventAsync(model);
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteAssetEvent(int id, int sceneId, int? projectId, int? bookId)
{
await assets.DeleteAssetEventAsync(id);
return RedirectForScene(sceneId, projectId, bookId);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddAssetCustodyEvent(AssetCustodyCreateViewModel model)
{
await assets.AddCustodyEventAsync(model);
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
}
[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);
return RedirectForScene(sceneId, projectId, bookId);
}
[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> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<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)
{
model.ReturnUrl = GetLocalReturnUrl();
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);
if (TryGetLocalReturnUrl(model.ReturnUrl, out var returnUrl))
{
return LocalRedirect(returnUrl);
}
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)
{
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<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);
uploadStorage.TryDeleteFile(originalFilePath, "uploads/scenes");
}
}
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);
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<IActionResult> 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<IActionResult> 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<string> 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<string> 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;
}
}