1826 lines
61 KiB
C#
1826 lines
61 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);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneCharactersEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharactersEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneKnowledgeEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneKnowledgeEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneRelationshipsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneRelationshipsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneCharacterAttributesEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneCharacterAttributesEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneSuggestionsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneSuggestionsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneLocationsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneLocationsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneAssetsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAssetsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneNotesEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneNotesEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneChecklistEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneChecklistEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneWorkflowEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneWorkflowEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneAttachmentsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneAttachmentsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneThreadsEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneThreadsEditor.cshtml", model);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> SceneDependenciesEditor(int sceneId)
|
|
{
|
|
var model = await scenes.GetEditAsync(sceneId);
|
|
return model is null
|
|
? NotFound()
|
|
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneDependenciesEditor.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> SaveSceneCharacters(SceneCharactersSaveViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var existingRows = current.SceneCharacters.ToDictionary(x => x.SceneCharacterID);
|
|
var retainedCharacterIds = new HashSet<int>();
|
|
foreach (var row in model.Characters.Where(x => !x.Remove))
|
|
{
|
|
if (!existingRows.ContainsKey(row.SceneCharacterID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!retainedCharacterIds.Add(row.CharacterID))
|
|
{
|
|
return BadRequest(new { success = false, message = "Each character can only appear once in a scene." });
|
|
}
|
|
}
|
|
|
|
var addCharacterId = model.NewCharacter.CharacterID.GetValueOrDefault();
|
|
if (addCharacterId > 0 && !retainedCharacterIds.Add(addCharacterId))
|
|
{
|
|
return BadRequest(new { success = false, message = "That character is already in this scene." });
|
|
}
|
|
|
|
foreach (var row in model.Characters)
|
|
{
|
|
if (!existingRows.ContainsKey(row.SceneCharacterID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (row.Remove)
|
|
{
|
|
await characters.DeleteSceneCharacterAsync(row.SceneCharacterID);
|
|
continue;
|
|
}
|
|
|
|
await characters.UpdateSceneCharacterAsync(new SceneCharacterEditViewModel
|
|
{
|
|
SceneCharacterID = row.SceneCharacterID,
|
|
SceneID = model.SceneID,
|
|
CharacterID = row.CharacterID,
|
|
RoleInSceneTypeID = row.RoleInSceneTypeID,
|
|
PresenceTypeID = row.PresenceTypeID,
|
|
LocationID = row.LocationID,
|
|
EntryLocationID = row.EntryLocationID,
|
|
ExitLocationID = row.ExitLocationID,
|
|
AppearanceNotes = row.AppearanceNotes,
|
|
OutfitDescription = row.OutfitDescription,
|
|
PhysicalCondition = row.PhysicalCondition,
|
|
EmotionalState = row.EmotionalState
|
|
});
|
|
}
|
|
|
|
if (addCharacterId > 0)
|
|
{
|
|
model.NewCharacter.SceneID = model.SceneID;
|
|
model.NewCharacter.NewCharacterName = null;
|
|
model.NewCharacter.KnowledgeNotes = null;
|
|
model.NewCharacter.ReturnProjectID = current.ReturnProjectID;
|
|
model.NewCharacter.ReturnBookID = current.ReturnBookID;
|
|
await characters.AddSceneCharacterAsync(model.NewCharacter);
|
|
}
|
|
|
|
var updatedBeforePov = await scenes.GetEditAsync(model.SceneID);
|
|
if (updatedBeforePov is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var validPovIds = updatedBeforePov.SceneCharacters.Select(x => x.CharacterID).ToHashSet();
|
|
if (model.POVCharacterID.HasValue && !validPovIds.Contains(model.POVCharacterID.Value))
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose a POV character who is in this scene, or clear the POV." });
|
|
}
|
|
|
|
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),
|
|
people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID,
|
|
updated.SceneTitle,
|
|
PovLabel = updated.CharacterOptions.FirstOrDefault(x => x.Value == updated.POVCharacterID?.ToString())?.Text ?? "No POV"
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveSceneLocations(SceneLocationsSaveViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (model.PrimaryLocationID.HasValue &&
|
|
!current.LocationOptions.Any(x => x.Value == model.PrimaryLocationID.Value.ToString()))
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an existing project location, or clear the primary location." });
|
|
}
|
|
|
|
current.PrimaryLocationID = model.PrimaryLocationID;
|
|
await scenes.SaveAsync(current);
|
|
|
|
var updated = await scenes.GetEditAsync(model.SceneID);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var primaryLocationName = updated.LocationOptions.FirstOrDefault(x => x.Value == updated.PrimaryLocationID?.ToString())?.Text?.Trim();
|
|
|
|
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),
|
|
world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID,
|
|
PrimaryLocationName = primaryLocationName ?? "No location"
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveSceneAssets(SceneAssetsSaveViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var existingRows = current.AssetEvents.ToDictionary(x => x.AssetEventID);
|
|
var retainedAssetIds = new HashSet<int>();
|
|
foreach (var row in model.AssetEvents.Where(x => !x.Remove))
|
|
{
|
|
if (!existingRows.ContainsKey(row.AssetEventID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
retainedAssetIds.Add(row.StoryAssetID);
|
|
}
|
|
|
|
var addAssetId = model.NewAssetEvent.StoryAssetID.GetValueOrDefault();
|
|
if (addAssetId > 0)
|
|
{
|
|
if (!current.AssetOptions.Any(x => x.Value == addAssetId.ToString()))
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an existing project asset." });
|
|
}
|
|
|
|
if (!retainedAssetIds.Add(addAssetId))
|
|
{
|
|
return BadRequest(new { success = false, message = "That asset is already in this scene." });
|
|
}
|
|
}
|
|
|
|
foreach (var row in model.AssetEvents)
|
|
{
|
|
if (!existingRows.ContainsKey(row.AssetEventID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (row.Remove)
|
|
{
|
|
await assets.DeleteAssetEventAsync(row.AssetEventID);
|
|
}
|
|
}
|
|
|
|
if (addAssetId > 0)
|
|
{
|
|
model.NewAssetEvent.SceneID = model.SceneID;
|
|
model.NewAssetEvent.NewAssetName = null;
|
|
model.NewAssetEvent.NewAssetKindID = null;
|
|
model.NewAssetEvent.ReturnProjectID = current.ReturnProjectID;
|
|
model.NewAssetEvent.ReturnBookID = current.ReturnBookID;
|
|
await assets.AddAssetEventAsync(model.NewAssetEvent);
|
|
}
|
|
|
|
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),
|
|
world = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveSceneNotes(SceneNotesSaveViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var existingRows = current.Notes.ToDictionary(x => x.SceneNoteID);
|
|
var validNoteTypeIds = current.SceneNoteTypeOptions
|
|
.Where(x => int.TryParse(x.Value, out _))
|
|
.Select(x => int.Parse(x.Value))
|
|
.ToHashSet();
|
|
|
|
foreach (var note in model.Notes)
|
|
{
|
|
if (!existingRows.ContainsKey(note.SceneNoteID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (note.Remove)
|
|
{
|
|
await writerWorkspace.DeleteNoteAsync(note.SceneNoteID);
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(note.NoteText))
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter note text before saving." });
|
|
}
|
|
|
|
if (!validNoteTypeIds.Contains(note.SceneNoteTypeID))
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an existing note type." });
|
|
}
|
|
|
|
note.SceneID = model.SceneID;
|
|
await writerWorkspace.SaveNoteAsync(new SceneNoteEditViewModel
|
|
{
|
|
SceneNoteID = note.SceneNoteID,
|
|
SceneID = note.SceneID,
|
|
SceneNoteTypeID = note.SceneNoteTypeID,
|
|
NoteTitle = note.NoteTitle,
|
|
NoteText = note.NoteText,
|
|
SortOrder = note.SortOrder,
|
|
IsPinned = note.IsPinned,
|
|
IsResolved = note.IsResolved
|
|
});
|
|
}
|
|
|
|
var hasNewNoteContent = !string.IsNullOrWhiteSpace(model.NewNote.NoteTitle)
|
|
|| !string.IsNullOrWhiteSpace(model.NewNote.NoteText);
|
|
if (hasNewNoteContent)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(model.NewNote.NoteText))
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter note text before saving." });
|
|
}
|
|
|
|
if (!validNoteTypeIds.Contains(model.NewNote.SceneNoteTypeID))
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an existing note type." });
|
|
}
|
|
|
|
model.NewNote.SceneID = model.SceneID;
|
|
await writerWorkspace.SaveNoteAsync(model.NewNote);
|
|
}
|
|
|
|
var updated = await scenes.GetEditAsync(model.SceneID);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
|
|
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveSceneChecklist(SceneChecklistSaveViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var existingRows = current.ChecklistItems.ToDictionary(x => x.SceneChecklistItemID);
|
|
foreach (var item in model.ChecklistItems)
|
|
{
|
|
if (!existingRows.ContainsKey(item.SceneChecklistItemID))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (item.Remove)
|
|
{
|
|
await writerWorkspace.DeleteChecklistItemAsync(item.SceneChecklistItemID);
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(item.ItemText))
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter checklist text before saving." });
|
|
}
|
|
|
|
item.SceneID = model.SceneID;
|
|
await writerWorkspace.SaveChecklistItemAsync(new SceneChecklistItemEditViewModel
|
|
{
|
|
SceneChecklistItemID = item.SceneChecklistItemID,
|
|
SceneID = item.SceneID,
|
|
ItemText = item.ItemText,
|
|
IsCompleted = item.IsCompleted,
|
|
SortOrder = item.SortOrder
|
|
});
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(model.NewChecklistItem.ItemText))
|
|
{
|
|
model.NewChecklistItem.SceneID = model.SceneID;
|
|
await writerWorkspace.SaveChecklistItemAsync(model.NewChecklistItem);
|
|
}
|
|
|
|
var updated = await scenes.GetEditAsync(model.SceneID);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
|
|
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> SaveSceneWorkflow(SceneWorkflowEditViewModel model)
|
|
{
|
|
var current = await scenes.GetEditAsync(model.SceneID);
|
|
if (current is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
await writerWorkspace.SaveSceneWorkflowAsync(model);
|
|
|
|
var updated = await scenes.GetEditAsync(model.SceneID);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated),
|
|
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
|
|
overview = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneOverviewCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
[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)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
TempData["ThreadEventAddError"] = ex.Message;
|
|
}
|
|
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneStoryRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
TempData[$"ThreadEventEditError:{model.ThreadEventID}"] = ex.Message;
|
|
}
|
|
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneStoryRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneStoryRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: false);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: false);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: true);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: true);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: true);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneSuggestionsRefreshJsonAsync(sceneId, refreshPeople: true, refreshWorld: true);
|
|
}
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
TempData["SceneLocationSuggestionError"] = ex.Message;
|
|
}
|
|
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddSceneDependency(SceneDependencyCreateViewModel model)
|
|
{
|
|
await scenes.AddDependencyAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneStoryRefreshJsonAsync(model.CurrentSceneID);
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneStoryRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose a character before adding an attribute." });
|
|
}
|
|
|
|
TempData["CharacterAttributeAddError"] = "Choose a character before adding an attribute.";
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
if (model.CharacterAttributeTypeID <= 0)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an attribute type before saving." });
|
|
}
|
|
|
|
TempData["CharacterAttributeAddError"] = "Choose an attribute type before saving.";
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(model.AttributeValue))
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter an attribute value before saving." });
|
|
}
|
|
|
|
TempData["CharacterAttributeAddError"] = "Enter an attribute value before saving.";
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
await characters.AddAttributeEventAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneCharacterAttributesRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateCharacterAttributeEvent(CharacterAttributeEventCreateViewModel model)
|
|
{
|
|
if (model.CharacterAttributeEventID <= 0 || model.SceneID <= 0)
|
|
{
|
|
return BadRequest();
|
|
}
|
|
|
|
if (model.CharacterID <= 0)
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose a character before saving this attribute." });
|
|
}
|
|
|
|
if (model.CharacterAttributeTypeID <= 0)
|
|
{
|
|
return BadRequest(new { success = false, message = "Choose an attribute type before saving." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(model.AttributeValue))
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter an attribute value before saving." });
|
|
}
|
|
|
|
await characters.UpdateAttributeEventAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneCharacterAttributesRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddCharacterKnowledge(CharacterKnowledgeCreateViewModel model)
|
|
{
|
|
await characters.AddKnowledgeAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneKnowledgeRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateCharacterKnowledge(CharacterKnowledgeCreateViewModel model)
|
|
{
|
|
await characters.UpdateKnowledgeAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneKnowledgeRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneKnowledgeRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> AddRelationshipEvent(RelationshipEventCreateViewModel model)
|
|
{
|
|
try
|
|
{
|
|
await characters.AddRelationshipEventAsync(model);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = ex.Message });
|
|
}
|
|
|
|
TempData["RelationshipEventAddError"] = ex.Message;
|
|
}
|
|
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await ScenePeopleContinuityRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
|
|
}
|
|
|
|
[HttpPost]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> UpdateRelationshipEvent(RelationshipEventCreateViewModel model)
|
|
{
|
|
await characters.UpdateRelationshipEventAsync(model);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await ScenePeopleContinuityRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
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);
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await ScenePeopleContinuityRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
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))
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Enter an attachment title before saving." });
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Attachment files must be 25 MB or smaller." });
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = storageLimit.Message });
|
|
}
|
|
|
|
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))
|
|
{
|
|
if (IsAjaxRequest())
|
|
{
|
|
return BadRequest(new { success = false, message = "Upload a file or enter an external link." });
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneWriterRefreshJsonAsync(model.SceneID);
|
|
}
|
|
|
|
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");
|
|
if (IsAjaxRequest())
|
|
{
|
|
return await SceneWriterRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
return RedirectForScene(sceneId, projectId, bookId);
|
|
}
|
|
|
|
private bool IsAjaxRequest() =>
|
|
string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private async Task<IActionResult> SceneWriterRefreshJsonAsync(int sceneId)
|
|
{
|
|
var updated = await scenes.GetEditAsync(sceneId);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
writer = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWriterCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<IActionResult> SceneStoryRefreshJsonAsync(int sceneId)
|
|
{
|
|
var updated = await scenes.GetEditAsync(sceneId);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
story = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneStoryCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<IActionResult> SceneKnowledgeRefreshJsonAsync(int sceneId)
|
|
{
|
|
return await ScenePeopleContinuityRefreshJsonAsync(sceneId);
|
|
}
|
|
|
|
private async Task<IActionResult> SceneCharacterAttributesRefreshJsonAsync(int sceneId)
|
|
{
|
|
var updated = await scenes.GetEditAsync(sceneId);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
|
|
people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated),
|
|
continuity = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml", updated)
|
|
},
|
|
editorHtml = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneCharacterAttributesEditor.cshtml", updated),
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<IActionResult> SceneSuggestionsRefreshJsonAsync(int sceneId, bool refreshPeople, bool refreshWorld)
|
|
{
|
|
var updated = await scenes.GetEditAsync(sceneId);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var html = new Dictionary<string, string>
|
|
{
|
|
["health"] = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated)
|
|
};
|
|
|
|
if (refreshPeople)
|
|
{
|
|
html["people"] = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated);
|
|
}
|
|
|
|
if (refreshWorld)
|
|
{
|
|
html["world"] = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneWorldCard.cshtml", updated);
|
|
}
|
|
|
|
var primaryLocationName = updated.LocationOptions.FirstOrDefault(x => x.Value == updated.PrimaryLocationID?.ToString())?.Text?.Trim();
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html,
|
|
editorHtml = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneSuggestionsEditor.cshtml", updated),
|
|
scene = new
|
|
{
|
|
updated.SceneID,
|
|
PrimaryLocationName = primaryLocationName ?? "No location"
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<IActionResult> ScenePeopleContinuityRefreshJsonAsync(int sceneId)
|
|
{
|
|
var updated = await scenes.GetEditAsync(sceneId);
|
|
if (updated is null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Json(new
|
|
{
|
|
success = true,
|
|
html = new
|
|
{
|
|
health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", updated),
|
|
people = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_ScenePeopleCard.cshtml", updated),
|
|
continuity = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml", updated)
|
|
},
|
|
scene = new
|
|
{
|
|
updated.SceneID
|
|
}
|
|
});
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|