diff --git a/.appdata/.dotnet/.workloadAdvertisingManifestSentinel10.0.300 b/.appdata/.dotnet/.workloadAdvertisingManifestSentinel10.0.300
new file mode 100644
index 0000000..e69de29
diff --git a/.appdata/.dotnet/10.0.300.aspNetCertificateSentinel b/.appdata/.dotnet/10.0.300.aspNetCertificateSentinel
new file mode 100644
index 0000000..e69de29
diff --git a/.appdata/.dotnet/10.0.300.dotnetFirstUseSentinel b/.appdata/.dotnet/10.0.300.dotnetFirstUseSentinel
new file mode 100644
index 0000000..e69de29
diff --git a/.appdata/.dotnet/10.0.300.toolpath.sentinel b/.appdata/.dotnet/10.0.300.toolpath.sentinel
new file mode 100644
index 0000000..e69de29
diff --git a/.appdata/.dotnet/10.0.300_IsDockerContainer.dotnetUserLevelCache b/.appdata/.dotnet/10.0.300_IsDockerContainer.dotnetUserLevelCache
new file mode 100644
index 0000000..c1f22fb
--- /dev/null
+++ b/.appdata/.dotnet/10.0.300_IsDockerContainer.dotnetUserLevelCache
@@ -0,0 +1 @@
+False
\ No newline at end of file
diff --git a/.appdata/.dotnet/10.0.300_MachineId.dotnetUserLevelCache b/.appdata/.dotnet/10.0.300_MachineId.dotnetUserLevelCache
new file mode 100644
index 0000000..8ec8c9f
--- /dev/null
+++ b/.appdata/.dotnet/10.0.300_MachineId.dotnetUserLevelCache
@@ -0,0 +1 @@
+ac804ed3-9efd-491a-9433-d35a726f772f
\ No newline at end of file
diff --git a/.appdata/.dotnet/MachineId.v1.dotnetUserLevelCache b/.appdata/.dotnet/MachineId.v1.dotnetUserLevelCache
new file mode 100644
index 0000000..49897c7
--- /dev/null
+++ b/.appdata/.dotnet/MachineId.v1.dotnetUserLevelCache
@@ -0,0 +1 @@
+01e2d38e-f216-48da-a539-6636fe1c9721
\ No newline at end of file
diff --git a/.appdata/.dotnet/TelemetryStorageService/20260601075543_1eba1e32018c46dc9609a8dec4380b23.trn b/.appdata/.dotnet/TelemetryStorageService/20260601075543_1eba1e32018c46dc9609a8dec4380b23.trn
new file mode 100644
index 0000000..4e9e099
--- /dev/null
+++ b/.appdata/.dotnet/TelemetryStorageService/20260601075543_1eba1e32018c46dc9609a8dec4380b23.trn
@@ -0,0 +1,5 @@
+https://dc.services.visualstudio.com/v2/track
+Content-Type:application/x-json-stream
+Content-Encoding:gzip
+
+H4sIAAAAAAAACpSTT2/cNhDFv4rAsyjzn0hRN2Odogs38SJZpEVvFGcUE5apBUU5CYz97gG1GzjprRdBGOjNvPeb0SuJ7hlJT25Pp3cvGPNCapLDVhJMaMo0ZfzITN+2vZIN10YKLv4lNQn3+J30xCjvubdIJWqkClhLBzl6CoCWMw5MtKr0dF8W0r8SFxrAl+CxmZfPmJYwR9KTv0OE+WuZ7ULjp3mFJs0T7uOSXfTFzIf97p7+dXs4PhwuXy24FHETgPSEGyaEUIZ2ykuqOhhpB5ZT3Y6ghnZw3rqLLMSMKbqpWeDpbT7MOWL2vWiEbBgVlpxrAi67YnlwCx6/n4qLDdFdqddbeXvtX8kLJtKL+ifMS7sbP4Wb52VYwwQ3l2d2y1NJeUrzCVMOuDHZYk6Ty2GO1XXSbgoYM6nJw6fqMLk8zun5N1CfLvn3/yP+sUx/9w39mhF28xpz0UpDavLe+ccQsdrfkZ4wjgJkh3QUXJd2jrpWWqq11CNyb43gpCa7OeYQ13ldqn3M+CVtAUhP/nDTghfrt8k/how+r6mk+keXWzikGVafqzf+nDWskYwV0ZpPa64+IoSEPmPJd0zrtd9/JEKLTbRbU8KYq4PLj9WfbnkkPbGu89xZZ53T3o/YMhwZMKeZAdSgEZQAA8oq0Moyr5UbuALX8mEsFVITLOuutguz0jA5qIGyDhlVQijaGddSawZjOxgkUwXxG8fqYSo65zumECS1OAJVljtqlZQUZOuM0KMxYvwFyXX7nBXAgC+Xf2VzoAE6BM2pULylylpOO9saOphusIPusPXF8z2miNMvoN4Hn+ZlHnN1PZ7qN3Qf11j+92o7pK8h0m/bju5m/4SpKjt2IZbzvq71fD6ffwAAAP//AwCvA78iNgQAAA==
\ No newline at end of file
diff --git a/.appdata/.dotnet/TelemetryStorageService/20260601075543_540f0320d843409290e579104fa0326b.trn b/.appdata/.dotnet/TelemetryStorageService/20260601075543_540f0320d843409290e579104fa0326b.trn
new file mode 100644
index 0000000..1fa0e0a
--- /dev/null
+++ b/.appdata/.dotnet/TelemetryStorageService/20260601075543_540f0320d843409290e579104fa0326b.trn
@@ -0,0 +1,5 @@
+https://dc.services.visualstudio.com/v2/track
+Content-Type:application/x-json-stream
+Content-Encoding:gzip
+
+H4sIAAAAAAAACpRTwW7bOBD9FYFnUSEpihJ1C5xd1Ei7MdqgLXobcUY1UYUyKCptEfjfF5RdpN3b3ohHvpk37w1fWIAnYj27PZ3+eqaQFlay5DdICWW4MFzIR9H2TdPrupKtakXTfGEl8/f0k/Ws1c5JZ4nXZIhrFA0f6tFxRLJSSBSq0bkmfF1Y/8LAV0jP3lE1Lx8pLn4OrGeffMD5e+4NvnLTvGIV54n2YUkQXBbzz353z9/eHh4fDpdXCy2ZXHlkPZOtUErplnfa1Vx3OPIOreSmGVEPzQDOwoXmQ6IYYKoW/PbaH+cUKLleVaquBFeWnUuGkCBLHmChx5+nrGKz6C7j5QZvx/6FPVNkvSp/mXkpd+Mmf+PmpycIeDP64JcjK9kpzieKydNmxzbhNEHycyiuTXaTp5BYyR4+FIcJ0jjHpz88+nAZff8/Jn8H7ugDFfs71jMhSWHdER+VNJkDHJracmNqM5J0tlWSlWw3h+TDOq9LsQ+JvsZNJevZ3zAtdNF3G93RJ3JpjVn6Z5Ozph8+7WbMgGAlO8QZV5eKV7ulqERVi3z5sKbTmor3hD6SS5Rneozrtfx/KMqojbRbY6SQigOkY/EGliPrmYXOSbBgAYxzIzWCRoECjGiRDBpCrbBFbTUabYUzGgapERo5jBnJunO6xbZQ2liDQkqOYDquW6E52LrhQ9MNDvQgqM4WvdpaPEyZB64TmrDmlkbk2krgVtc1x7qBVpmxbdX4myXXxKXIxZCeL19jU2AQO0IjudKy4dpayTvbtHxou8EOpqPGZc33FANNvxn1zrs4L/OYiuvCFH9Y934N+XsX2/J894H/2CK7m903ikWOHHzI23xN+Xw+n/8FAAD//wMAJ8SPOyUEAAA=
\ No newline at end of file
diff --git a/.appdata/.dotnet/TelemetryStorageService/20260601075543_594dfd13578d46e482092ba2d14e5593.trn b/.appdata/.dotnet/TelemetryStorageService/20260601075543_594dfd13578d46e482092ba2d14e5593.trn
new file mode 100644
index 0000000..a690875
--- /dev/null
+++ b/.appdata/.dotnet/TelemetryStorageService/20260601075543_594dfd13578d46e482092ba2d14e5593.trn
@@ -0,0 +1,5 @@
+https://dc.services.visualstudio.com/v2/track
+Content-Type:application/x-json-stream
+Content-Encoding:gzip
+
+H4sIAAAAAAAACoxUy27kNhD8FYHXiDJfokjdHNuLDBw/sDNIgtxaZMtmLFMDirJ3YfjfA2ns9WMRIBdBaJJd1dXV/UQi3CNpyfF+f/aAMU+kJDmsIcGEpkxTxnesaeu6VbLiDau15n+TkoRz/E5a0ijnuLNIJWqkyrOadrJ31Hu0nHHPRK2WnHAzkfaJQKg8PgSH1Tj9gWkKYyQt+TNEPz4u2BAqN4yzr9I44CZOGaJbyFxuTs7p78fXu6vrw60Jp+VxFTxpCW+YEEI11CgnqTK+p8ZbTnXde9XVHTgLh2chZkwRhmryd2/4fswRs2tFJWTFqLDkuSQeMiyUO5hw932/sFglOl3i5Rpef9sn8oCJtKJ8FfOQ7sgN4eh+6uYw+KP1S0qyT+MeUw64qnG1Ld5IcFaxSmjBGCnJBbjbELHYnBZXw1IiOMMUekkt9p4qy4FaJSX1soZG6L5pRE9Kco4p4vAu60VwaZzGPhcvIhcfcLbHJ2cRugEXjC8wTEhKspm2GaKHYYx49g3dnA+5dmlejk/GmEOcx3kqNjHjTYKX89f3J3NKGHNxDfm2+A2mW9ISC8ZxsGABtHM91gx75hlo1njUXqNXwjdeWeW1ssxpBR1XHmre9UuElOTXRcPt7BxO0xub1STDsHIoXvp0MgSM+YOMpCWMo/DSIO0F14tLgEItLdVa6h65s43grzA7SDeYSUt603Udc64TynIHwoL0PZNe91aIpgHVy7pRQuuaNeB80wiuvGzQgewlM4KU5DqNfnb5c6vl2oDrNP6DLi9SkZZoVtfGQdcJrbgBa1BxrYy22FjVA1PSdcBrA13HGdTMWSMccG1Bdb7uf6h0Fm9CxNMw7Qf4/g7XVLqSvzgAw3tQn65/SXCPj2O6uzzYuLo825GSfJ3jshGKzWKRxxDpN71M9MU85LC7TQge/cXo8Wcf4TIuxWFCbW28YpLqpkaqhOPUcGVo4xB75TrXWU1Kcjq6O0zF4jAIcZmqH8mutsVxcrcho8tzWvj9tfK42hbXA+R+TPcfNsm7wj4LUAnJ2dIZjw+HbbRy1N4b9JpToXhNlbWcGls3tGtMZzttsHaLYldz3s+5+Io+JHR5rfjFiyvmyS26u5/FeHXBi0U5W7y2PWyxVdr/tcSeS3KPMM0J79dt3T6RTYyYVuTT+TCKm3gRhiFM6MboJ9Jq0ZjKGmZfGP7nPVnbSinJn5+fn/8FAAD//wMA8VM0JxsGAAA=
\ No newline at end of file
diff --git a/.appdata/.dotnet/TelemetryStorageService/20260601075543_fdd30fa102e743f4b976945d7b082360.trn b/.appdata/.dotnet/TelemetryStorageService/20260601075543_fdd30fa102e743f4b976945d7b082360.trn
new file mode 100644
index 0000000..faf6f32
--- /dev/null
+++ b/.appdata/.dotnet/TelemetryStorageService/20260601075543_fdd30fa102e743f4b976945d7b082360.trn
@@ -0,0 +1,5 @@
+https://dc.services.visualstudio.com/v2/track
+Content-Type:application/x-json-stream
+Content-Encoding:gzip
+
+H4sIAAAAAAAACpSU0W7cKhCGX8Xi2jiAMTa+izatukrbrJpVe3TuxjBuUBy8Apw2ivbdK+yt2lwdnRuExvzDP98MfiUenpD05Pp0eveMPkVSkuTWkGBCUaYo40fW9k3Ty7riqlGiVf+SkrhbfCE9aaUx3GikNSqk0rKGDvVoqLWoOeOWiUbmnPA9kv6VgKssPjuD1Ry/Yohu9qQn35y38498N7jKTPNiqzBPuPcxgTfZzOf97pZ+vD4c7w7bqYgxiytnSU94y4QQsqWdNDWVnR1pZzWnqhmtHJoBjIZN5nzC4GGqon38c7+dk8dkelGJumJUaHIuiYUE2fIAEY8vp+xiRXST4+UaXrf9K3nGQHpR/oa5pbsyk7t6isPiJnu1rQniY1zXEUyawwspySnMJwzJ4crnOkZ8GqaXI8TH99uZvI3vfqJZEtrdvPiUK64z1RXQNEFysy8uHneTQ5/Wjyk4H535r1w1KcndfXGYII1zeHrTj/sN8/5/UP4E5sF5LPY3pCeMo7B1h3QUXGUNUGhqTZWq1Yjc6FZwUpLd7JPzy7zEYu8Tfg9rSaQn72GKuPm7DubBJTRpCbnOf1QmcAizXUwq/vSSs4pVNWNZtKTTkoovaF1AkzAXcQzLJd/XtxKhxCraLSGgT8UB0kPxAeID6YmGznDQoAGUMSM2DEdmGSjWWlRWoZXCtlZqaZXUzCgJA5cWGj6MOUJKgnl0inValWlbVMJSQItUKom0q7uRGgVty0dRKz284VjcTVkHpmMSbU01jpZKzYFqWdfU1g20Qo1tK8a/kFzmgbMM2OLz9u42B9Z2aBWnQvKGSq057XTT0qHtBj2oDhuTPd9i8Dj9BeqTM2GO85iKy4QUb9B9WXz+dxTrtPxwnv5ce3Qzm0cMRe4xOJ+fyqWt5/P5/AsAAP//AwC9ok9gggQAAA==
\ No newline at end of file
diff --git a/.appdata/NuGet/NuGet.Config b/.appdata/NuGet/NuGet.Config
new file mode 100644
index 0000000..765346e
--- /dev/null
+++ b/.appdata/NuGet/NuGet.Config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..765346e
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/PlotLine.slnx b/PlotLine.slnx
new file mode 100644
index 0000000..e9bbd91
--- /dev/null
+++ b/PlotLine.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/PlotLine/App_Data/DataProtectionKeys/key-b800ca31-7991-402d-8017-716ba11edc31.xml b/PlotLine/App_Data/DataProtectionKeys/key-b800ca31-7991-402d-8017-716ba11edc31.xml
new file mode 100644
index 0000000..26ae73d
--- /dev/null
+++ b/PlotLine/App_Data/DataProtectionKeys/key-b800ca31-7991-402d-8017-716ba11edc31.xml
@@ -0,0 +1,16 @@
+
+
+ 2026-05-31T13:09:43.9306091Z
+ 2026-05-31T13:09:43.9306091Z
+ 2026-08-29T13:09:43.9306091Z
+
+
+
+
+
+
+ rYC5RTgXQzx3wsUyn9wS1B53mDgBKXjX+/G96+7qkSTGiNqcMbK60Zgr/ujMcmusWpeyIXHuajhvceGxlWONQg==
+
+
+
+
\ No newline at end of file
diff --git a/PlotLine/Controllers/AnalyticsController.cs b/PlotLine/Controllers/AnalyticsController.cs
new file mode 100644
index 0000000..3c9c89e
--- /dev/null
+++ b/PlotLine/Controllers/AnalyticsController.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+
+namespace PlotLine.Controllers;
+
+public sealed class AnalyticsController(IAnalyticsService analytics) : Controller
+{
+ public async Task Index(
+ int projectId,
+ int? bookId,
+ decimal? startChapterNumber,
+ decimal? endChapterNumber,
+ decimal? startSceneNumber,
+ decimal? endSceneNumber)
+ {
+ var model = await analytics.GetStoryHealthAsync(projectId, bookId, startChapterNumber, endChapterNumber, startSceneNumber, endSceneNumber);
+ return model is null ? NotFound() : View(model);
+ }
+}
diff --git a/PlotLine/Controllers/BooksController.cs b/PlotLine/Controllers/BooksController.cs
new file mode 100644
index 0000000..03bd163
--- /dev/null
+++ b/PlotLine/Controllers/BooksController.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class BooksController(IBookService books) : Controller
+{
+ public async Task Details(int id)
+ {
+ var model = await books.GetDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId)
+ {
+ var model = await books.GetCreateAsync(projectId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await books.GetEditAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(BookEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var bookId = await books.SaveAsync(model);
+ return RedirectToAction(nameof(Details), new { id = bookId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await books.ArchiveAsync(id);
+ return RedirectToAction("Details", "Projects", new { id = projectId });
+ }
+}
diff --git a/PlotLine/Controllers/ChaptersController.cs b/PlotLine/Controllers/ChaptersController.cs
new file mode 100644
index 0000000..7beed10
--- /dev/null
+++ b/PlotLine/Controllers/ChaptersController.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class ChaptersController(IChapterService chapters) : Controller
+{
+ public async Task Details(int id)
+ {
+ var model = await chapters.GetDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int bookId)
+ {
+ var model = await chapters.GetCreateAsync(bookId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await chapters.GetEditAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(ChapterEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var chapterId = await chapters.SaveAsync(model);
+ return RedirectToAction(nameof(Details), new { id = chapterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int bookId)
+ {
+ await chapters.ArchiveAsync(id);
+ return RedirectToAction("Details", "Books", new { id = bookId });
+ }
+}
diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs
new file mode 100644
index 0000000..7e6afcb
--- /dev/null
+++ b/PlotLine/Controllers/CharactersController.cs
@@ -0,0 +1,53 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class CharactersController(ICharacterService characters) : Controller
+{
+ public async Task Index(int projectId)
+ {
+ var model = await characters.GetCharactersAsync(projectId);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId)
+ {
+ var model = await characters.GetCreateCharacterAsync(projectId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await characters.GetEditCharacterAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Details(int id)
+ {
+ var model = await characters.GetCharacterDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(CharacterEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var characterId = await characters.SaveCharacterAsync(model);
+ return RedirectToAction(nameof(Details), new { id = characterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await characters.ArchiveCharacterAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+}
diff --git a/PlotLine/Controllers/HomeController.cs b/PlotLine/Controllers/HomeController.cs
new file mode 100644
index 0000000..bf4cc63
--- /dev/null
+++ b/PlotLine/Controllers/HomeController.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Mvc;
+
+using PlotLine.Models;
+
+using System.Diagnostics;
+
+namespace PlotLine.Controllers
+{
+ public class HomeController : Controller
+ {
+ public IActionResult Index()
+ {
+ return View();
+ }
+
+ public IActionResult Privacy()
+ {
+ return View();
+ }
+
+ [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+ public IActionResult Error()
+ {
+ return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
+ }
+ }
+}
diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs
new file mode 100644
index 0000000..dc2b2fa
--- /dev/null
+++ b/PlotLine/Controllers/LocationsController.cs
@@ -0,0 +1,69 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class LocationsController(ILocationService locations) : Controller
+{
+ public async Task Index(int projectId)
+ {
+ var model = await locations.GetLocationsAsync(projectId);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId)
+ {
+ var model = await locations.GetCreateAsync(projectId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await locations.GetEditAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Details(int id)
+ {
+ var model = await locations.GetDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(LocationEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var locationId = await locations.SaveAsync(model);
+ return RedirectToAction(nameof(Details), new { id = locationId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await locations.ArchiveAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddRelationship(LocationRelationshipEditViewModel model)
+ {
+ await locations.SaveRelationshipAsync(model);
+ return RedirectToAction(nameof(Details), new { id = model.FromLocationID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ArchiveRelationship(int id, int returnLocationId)
+ {
+ await locations.ArchiveRelationshipAsync(id);
+ return RedirectToAction(nameof(Details), new { id = returnLocationId });
+ }
+}
diff --git a/PlotLine/Controllers/PlotLinesController.cs b/PlotLine/Controllers/PlotLinesController.cs
new file mode 100644
index 0000000..9086133
--- /dev/null
+++ b/PlotLine/Controllers/PlotLinesController.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class PlotLinesController(IPlotService plots) : Controller
+{
+ public async Task Index(int projectId)
+ {
+ var model = await plots.GetPlotLinesAsync(projectId);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId)
+ {
+ var model = await plots.GetCreatePlotLineAsync(projectId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await plots.GetEditPlotLineAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(PlotLineEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ await plots.SavePlotLineAsync(model);
+ return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await plots.ArchivePlotLineAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+}
diff --git a/PlotLine/Controllers/PlotThreadsController.cs b/PlotLine/Controllers/PlotThreadsController.cs
new file mode 100644
index 0000000..275939d
--- /dev/null
+++ b/PlotLine/Controllers/PlotThreadsController.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class PlotThreadsController(IPlotService plots) : Controller
+{
+ public async Task Index(int projectId)
+ {
+ var model = await plots.GetPlotThreadsAsync(projectId);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId, int? plotLineId)
+ {
+ var model = await plots.GetCreatePlotThreadAsync(projectId, plotLineId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await plots.GetEditPlotThreadAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(PlotThreadEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ await plots.SavePlotThreadAsync(model);
+ return RedirectToAction(nameof(Index), new { projectId = model.ProjectID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await plots.ArchivePlotThreadAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+}
diff --git a/PlotLine/Controllers/ProjectsController.cs b/PlotLine/Controllers/ProjectsController.cs
new file mode 100644
index 0000000..071bc0f
--- /dev/null
+++ b/PlotLine/Controllers/ProjectsController.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class ProjectsController(IProjectService projects) : Controller
+{
+ public async Task Index() => View(await projects.ListAsync());
+
+ public async Task Details(int id)
+ {
+ var model = await projects.GetDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public IActionResult Create() => View("Edit", new ProjectEditViewModel());
+
+ public async Task Edit(int id)
+ {
+ var model = await projects.GetEditAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(ProjectEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var projectId = await projects.SaveAsync(model);
+ return RedirectToAction(nameof(Details), new { id = projectId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id)
+ {
+ await projects.ArchiveAsync(id);
+ return RedirectToAction(nameof(Index));
+ }
+}
diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs
new file mode 100644
index 0000000..fc3e635
--- /dev/null
+++ b/PlotLine/Controllers/ScenesController.cs
@@ -0,0 +1,268 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class ScenesController(ISceneService scenes, IPlotService plots, IAssetService assets, ICharacterService characters, ILocationService locations) : Controller
+{
+ public async Task Create(int chapterId)
+ {
+ var model = await scenes.GetCreateAsync(chapterId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await scenes.GetEditAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(SceneEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var sceneId = await scenes.SaveAsync(model);
+ if (model.ReturnToTimeline && model.ReturnProjectID.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new
+ {
+ projectId = model.ReturnProjectID.Value,
+ bookId = model.ReturnBookID,
+ selectedSceneId = sceneId
+ });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = sceneId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int chapterId)
+ {
+ await scenes.ArchiveAsync(id);
+ return RedirectToAction("Details", "Chapters", new { id = chapterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task MoveUp(int id, int chapterId)
+ {
+ await scenes.MoveAsync(id, "Up");
+ return RedirectToAction("Details", "Chapters", new { id = chapterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task MoveDown(int id, int chapterId)
+ {
+ await scenes.MoveAsync(id, "Down");
+ return RedirectToAction("Details", "Chapters", new { id = chapterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddThreadEvent(ThreadEventCreateViewModel model)
+ {
+ await plots.AddThreadEventAsync(model);
+ if (model.ReturnProjectID.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new
+ {
+ projectId = model.ReturnProjectID.Value,
+ bookId = model.ReturnBookID,
+ selectedSceneId = model.SceneID
+ });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = model.SceneID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteThreadEvent(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await plots.DeleteThreadEventAsync(id);
+ if (projectId.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = sceneId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddAssetEvent(AssetEventCreateViewModel model)
+ {
+ await assets.AddAssetEventAsync(model);
+ if (model.ReturnProjectID.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new
+ {
+ projectId = model.ReturnProjectID.Value,
+ bookId = model.ReturnBookID,
+ selectedSceneId = model.SceneID
+ });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = model.SceneID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteAssetEvent(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await assets.DeleteAssetEventAsync(id);
+ if (projectId.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = sceneId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddAssetCustodyEvent(AssetCustodyCreateViewModel model)
+ {
+ await assets.AddCustodyEventAsync(model);
+ if (model.ReturnProjectID.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new
+ {
+ projectId = model.ReturnProjectID.Value,
+ bookId = model.ReturnBookID,
+ selectedSceneId = model.SceneID
+ });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = model.SceneID });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddSceneAssetLocation(SceneAssetLocationCreateViewModel model)
+ {
+ await locations.SaveSceneAssetLocationAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteSceneAssetLocation(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await locations.DeleteSceneAssetLocationAsync(id);
+ return RedirectForScene(sceneId, projectId, bookId);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteAssetCustodyEvent(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await assets.DeleteCustodyEventAsync(id);
+ if (projectId.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new { projectId = projectId.Value, bookId, selectedSceneId = sceneId });
+ }
+
+ return RedirectToAction(nameof(Edit), new { id = sceneId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddSceneCharacter(SceneCharacterCreateViewModel model)
+ {
+ await characters.AddSceneCharacterAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteSceneCharacter(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await characters.DeleteSceneCharacterAsync(id);
+ return RedirectForScene(sceneId, projectId, bookId);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddSceneDependency(SceneDependencyCreateViewModel model)
+ {
+ await scenes.AddDependencyAsync(model);
+ return RedirectForScene(model.CurrentSceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteSceneDependency(int id, int sceneId, int? projectId, int? bookId)
+ {
+ await scenes.DeleteDependencyAsync(id);
+ return RedirectForScene(sceneId, projectId, bookId);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task PreviewMove(SceneMoveRequestViewModel model)
+ {
+ var preview = await scenes.GetMovePreviewAsync(model);
+ return preview is null ? NotFound() : View("MovePreview", preview);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ConfirmMove(SceneMoveRequestViewModel model)
+ {
+ await scenes.MoveToChapterAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task RenumberChapter(int chapterId, int? projectId, int? bookId, int? sceneId)
+ {
+ await scenes.RenumberChapterAsync(chapterId);
+ return sceneId.HasValue
+ ? RedirectForScene(sceneId.Value, projectId, bookId)
+ : RedirectToAction("Details", "Chapters", new { id = chapterId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddCharacterAttributeEvent(CharacterAttributeEventCreateViewModel model)
+ {
+ await characters.AddAttributeEventAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddCharacterKnowledge(CharacterKnowledgeCreateViewModel model)
+ {
+ await characters.AddKnowledgeAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddRelationshipEvent(RelationshipEventCreateViewModel model)
+ {
+ await characters.AddRelationshipEventAsync(model);
+ return RedirectForScene(model.SceneID, model.ReturnProjectID, model.ReturnBookID);
+ }
+
+ 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 });
+ }
+}
diff --git a/PlotLine/Controllers/StoryAssetsController.cs b/PlotLine/Controllers/StoryAssetsController.cs
new file mode 100644
index 0000000..1627df7
--- /dev/null
+++ b/PlotLine/Controllers/StoryAssetsController.cs
@@ -0,0 +1,69 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+using PlotLine.ViewModels;
+
+namespace PlotLine.Controllers;
+
+public sealed class StoryAssetsController(IAssetService assets) : Controller
+{
+ public async Task Index(int projectId)
+ {
+ var model = await assets.GetAssetsAsync(projectId);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Create(int projectId)
+ {
+ var model = await assets.GetCreateAssetAsync(projectId);
+ return model is null ? NotFound() : View("Edit", model);
+ }
+
+ public async Task Edit(int id)
+ {
+ var model = await assets.GetEditAssetAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ public async Task Details(int id)
+ {
+ var model = await assets.GetAssetDetailAsync(id);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Save(StoryAssetEditViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var assetId = await assets.SaveAssetAsync(model);
+ return RedirectToAction(nameof(Details), new { id = assetId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Archive(int id, int projectId)
+ {
+ await assets.ArchiveAssetAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task AddDependency(AssetDependencyEditViewModel model, int returnAssetId)
+ {
+ await assets.SaveDependencyAsync(model);
+ return RedirectToAction(nameof(Details), new { id = returnAssetId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task DeleteDependency(int id, int returnAssetId)
+ {
+ await assets.DeleteDependencyAsync(id);
+ return RedirectToAction(nameof(Details), new { id = returnAssetId });
+ }
+}
diff --git a/PlotLine/Controllers/TimelineController.cs b/PlotLine/Controllers/TimelineController.cs
new file mode 100644
index 0000000..aa42534
--- /dev/null
+++ b/PlotLine/Controllers/TimelineController.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+
+namespace PlotLine.Controllers;
+
+public sealed class TimelineController(ITimelineService timeline) : Controller
+{
+ public async Task Index(int projectId, int? bookId, int? selectedSceneId)
+ {
+ var model = await timeline.GetAsync(projectId, bookId, selectedSceneId);
+ return model is null ? NotFound() : View(model);
+ }
+}
diff --git a/PlotLine/Controllers/WarningsController.cs b/PlotLine/Controllers/WarningsController.cs
new file mode 100644
index 0000000..da16bf8
--- /dev/null
+++ b/PlotLine/Controllers/WarningsController.cs
@@ -0,0 +1,86 @@
+using Microsoft.AspNetCore.Mvc;
+using PlotLine.Services;
+
+namespace PlotLine.Controllers;
+
+public sealed class WarningsController(IContinuityValidationService validation) : Controller
+{
+ public async Task Index(
+ int projectId,
+ int? bookId,
+ int? sceneId,
+ int? warningTypeId,
+ int? warningSeverityId,
+ string? entityType,
+ bool includeDismissed = false,
+ bool includeIntentional = false)
+ {
+ var model = await validation.GetDashboardAsync(projectId, bookId, sceneId, warningTypeId, warningSeverityId, entityType, includeDismissed, includeIntentional);
+ return model is null ? NotFound() : View(model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ValidateProject(int projectId, int? bookId)
+ {
+ var summary = await validation.ValidateProjectAsync(projectId);
+ var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, false, false, summary);
+ return model is null ? NotFound() : View("Index", model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ValidateBook(int projectId, int bookId)
+ {
+ var summary = await validation.ValidateBookAsync(bookId);
+ var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, false, false, summary);
+ return model is null ? NotFound() : View("Index", model);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task ValidateScene(int projectId, int? bookId, int sceneId, bool returnToTimeline = false)
+ {
+ await validation.ValidateSceneAsync(sceneId);
+ if (returnToTimeline)
+ {
+ return RedirectToAction("Index", "Timeline", new { projectId, bookId, selectedSceneId = sceneId });
+ }
+
+ return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId });
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Dismiss(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false)
+ {
+ await validation.DismissAsync(id);
+ return RedirectAfterAction(projectId, bookId, sceneId, returnToTimeline);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task MarkIntentional(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false)
+ {
+ await validation.MarkIntentionalAsync(id);
+ return RedirectAfterAction(projectId, bookId, sceneId, returnToTimeline);
+ }
+
+ [HttpPost]
+ [ValidateAntiForgeryToken]
+ public async Task Restore(int id, int projectId, int? bookId, int? sceneId)
+ {
+ await validation.RestoreAsync(id);
+ return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId, includeDismissed = true, includeIntentional = true });
+ }
+
+ private IActionResult RedirectAfterAction(int projectId, int? bookId, int? sceneId, bool returnToTimeline)
+ {
+ if (returnToTimeline && sceneId.HasValue)
+ {
+ return RedirectToAction("Index", "Timeline", new { projectId, bookId, selectedSceneId = sceneId.Value });
+ }
+
+ return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId });
+ }
+}
diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs
new file mode 100644
index 0000000..6cf7cac
--- /dev/null
+++ b/PlotLine/Data/Repositories.cs
@@ -0,0 +1,1372 @@
+using System.Data;
+using Dapper;
+using PlotLine.Models;
+
+namespace PlotLine.Data;
+
+public interface IProjectRepository
+{
+ Task> ListAsync();
+ Task GetAsync(int projectId);
+ Task SaveAsync(Project project);
+ Task ArchiveAsync(int projectId);
+}
+
+public interface IBookRepository
+{
+ Task> ListByProjectAsync(int projectId);
+ Task GetAsync(int bookId);
+ Task SaveAsync(Book book);
+ Task ArchiveAsync(int bookId);
+}
+
+public interface IChapterRepository
+{
+ Task> ListByBookAsync(int bookId);
+ Task GetAsync(int chapterId);
+ Task SaveAsync(Chapter chapter);
+ Task ArchiveAsync(int chapterId);
+}
+
+public interface ISceneRepository
+{
+ Task> ListByChapterAsync(int chapterId);
+ Task GetAsync(int sceneId);
+ Task SaveAsync(Scene scene);
+ Task SavePurposesAsync(int sceneId, IEnumerable purposeIds);
+ Task SaveMetricValuesAsync(int sceneId, IEnumerable metricValues);
+ Task ArchiveAsync(int sceneId);
+ Task MoveAsync(int sceneId, string direction);
+ Task> ListChapterOptionsAsync(int projectId);
+ Task GetMovePreviewAsync(int sceneId, int targetChapterId, string position);
+ Task MoveToChapterAsync(int sceneId, int targetChapterId, string position, bool renumber);
+ Task RenumberChapterAsync(int chapterId);
+}
+
+public interface ILookupRepository
+{
+ Task GetAllAsync();
+}
+
+public interface ITimelineRepository
+{
+ Task GetByProjectAsync(int projectId, int? bookId);
+}
+
+public interface IPlotRepository
+{
+ Task GetLookupsAsync();
+ Task> ListPlotLinesAsync(int projectId);
+ Task GetPlotLineAsync(int plotLineId);
+ Task SavePlotLineAsync(PlotLineItem plotLine);
+ Task ArchivePlotLineAsync(int plotLineId);
+ Task> ListPlotThreadsByProjectAsync(int projectId);
+ Task> ListPlotThreadsByPlotLineAsync(int plotLineId);
+ Task GetPlotThreadAsync(int plotThreadId);
+ Task SavePlotThreadAsync(PlotThread plotThread);
+ Task ArchivePlotThreadAsync(int plotThreadId);
+ Task> ListThreadEventsBySceneAsync(int sceneId);
+ Task> ListThreadEventsByPlotThreadAsync(int plotThreadId);
+ Task SaveThreadEventAsync(ThreadEvent threadEvent);
+ Task DeleteThreadEventAsync(int threadEventId);
+ Task> ListSceneOptionsAsync(int projectId);
+}
+
+public interface IAssetRepository
+{
+ Task GetLookupsAsync();
+ Task> ListAssetsAsync(int projectId);
+ Task GetAssetAsync(int storyAssetId);
+ Task SaveAssetAsync(StoryAsset asset);
+ Task ArchiveAssetAsync(int storyAssetId);
+ Task> ListEventsBySceneAsync(int sceneId);
+ Task> ListEventsByAssetAsync(int storyAssetId);
+ Task SaveEventAsync(AssetEvent assetEvent);
+ Task DeleteEventAsync(int assetEventId);
+ Task> ListDependenciesByAssetAsync(int storyAssetId);
+ Task SaveDependencyAsync(AssetDependency dependency);
+ Task DeleteDependencyAsync(int assetDependencyId);
+ Task> ListCustodyBySceneAsync(int sceneId);
+ Task> ListCustodyByAssetAsync(int storyAssetId);
+ Task SaveCustodyEventAsync(AssetCustodyEvent custodyEvent, string custodianNames, IEnumerable custodianCharacterIds, int custodyRoleId);
+ Task DeleteCustodyEventAsync(int assetCustodyEventId);
+ Task<(IReadOnlyList Assets, IReadOnlyList Events)> GetTimelineAsync(int projectId, int? bookId);
+ Task> CheckDependencyIssuesAsync(int storyAssetId, int sceneId, int? toStateId);
+}
+
+public interface ICharacterRepository
+{
+ Task GetLookupsAsync();
+ Task> ListCharactersAsync(int projectId);
+ Task GetCharacterAsync(int characterId);
+ Task SaveCharacterAsync(Character character);
+ Task ArchiveCharacterAsync(int characterId);
+ Task> ListSceneCharactersAsync(int sceneId);
+ Task SaveSceneCharacterAsync(SceneCharacter sceneCharacter);
+ Task DeleteSceneCharacterAsync(int sceneCharacterId);
+ Task> ListAttributeEventsByCharacterAsync(int characterId);
+ Task SaveAttributeEventAsync(CharacterAttributeEvent attributeEvent);
+ Task> ListKnowledgeBySceneAsync(int sceneId);
+ Task> ListKnowledgeByCharacterAsync(int characterId);
+ Task SaveKnowledgeAsync(CharacterKnowledgeItem knowledge);
+ Task> ListRelationshipsByProjectAsync(int projectId);
+ Task> ListRelationshipsByCharacterAsync(int characterId);
+ Task SaveRelationshipAsync(CharacterRelationship relationship);
+ Task ArchiveRelationshipAsync(int characterRelationshipId);
+ Task> ListRelationshipEventsBySceneAsync(int sceneId);
+ Task> ListRelationshipEventsByCharacterAsync(int characterId);
+ Task SaveRelationshipEventAsync(RelationshipEvent relationshipEvent);
+ Task<(IReadOnlyList Characters, IReadOnlyList Appearances)> GetTimelineAsync(int projectId, int? bookId);
+}
+
+public interface ILocationRepository
+{
+ Task GetLookupsAsync();
+ Task> ListByProjectAsync(int projectId);
+ Task GetAsync(int locationId);
+ Task SaveAsync(LocationItem location);
+ Task ArchiveAsync(int locationId);
+ Task> ListRelationshipsByProjectAsync(int projectId);
+ Task> ListRelationshipsByLocationAsync(int locationId);
+ Task SaveRelationshipAsync(LocationRelationship relationship);
+ Task ArchiveRelationshipAsync(int locationRelationshipId);
+ Task> ListSceneAssetLocationsAsync(int sceneId);
+ Task SaveSceneAssetLocationAsync(SceneAssetLocation sceneAssetLocation, bool updateCurrentLocation);
+ Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId);
+ Task> ListScenesByLocationAsync(int locationId);
+ Task> ListCharactersByLocationAsync(int locationId);
+ Task> ListAssetsByLocationAsync(int locationId);
+}
+
+public interface IWarningRepository
+{
+ Task GetLookupsAsync();
+ Task ValidateProjectAsync(int projectId);
+ Task ValidateBookAsync(int bookId);
+ Task ValidateSceneAsync(int sceneId);
+ Task> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional);
+ Task> ListBySceneAsync(int sceneId);
+ Task> GetSceneCountsAsync(int projectId, int? bookId);
+ Task DismissAsync(int continuityWarningId);
+ Task MarkIntentionalAsync(int continuityWarningId);
+ Task RestoreAsync(int continuityWarningId);
+ Task FinaliseSceneDependencyWarningsAsync(int projectId, int? bookId);
+}
+
+public interface ISceneDependencyRepository
+{
+ Task GetLookupsAsync();
+ Task> ListBySceneAsync(int sceneId);
+ Task> ListByProjectAsync(int projectId, int? bookId);
+ Task> GetCountsAsync(int projectId, int? bookId);
+ Task SaveAsync(SceneDependency dependency);
+ Task ArchiveAsync(int sceneDependencyId);
+}
+
+public interface IAnalyticsRepository
+{
+ Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
+}
+
+public sealed class TimelineData
+{
+ public Project? Project { get; init; }
+ public IReadOnlyList Books { get; init; } = [];
+ public IReadOnlyList Chapters { get; init; } = [];
+ public IReadOnlyList Scenes { get; init; } = [];
+ public IReadOnlyList PurposeLabels { get; init; } = [];
+ public IReadOnlyList MetricTypes { get; init; } = [];
+ public IReadOnlyList MetricPoints { get; init; } = [];
+ public IReadOnlyList PlotLines { get; init; } = [];
+ public IReadOnlyList ThreadEvents { get; init; } = [];
+}
+
+public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) : ILocationRepository
+{
+ public async Task GetLookupsAsync()
+ {
+ using var connection = connectionFactory.CreateConnection();
+ using var result = await connection.QueryMultipleAsync("dbo.LocationLookup_All", commandType: CommandType.StoredProcedure);
+ return new LocationLookupData
+ {
+ LocationTypes = (await result.ReadAsync()).ToList(),
+ RelationshipTypes = (await result.ReadAsync()).ToList()
+ };
+ }
+
+ public async Task> ListByProjectAsync(int projectId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.Location_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task GetAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleOrDefaultAsync("dbo.Location_Get", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task SaveAsync(LocationItem location)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.Location_Save",
+ new
+ {
+ location.LocationID,
+ location.ProjectID,
+ location.ParentLocationID,
+ location.LocationName,
+ location.LocationTypeID,
+ location.Description
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task ArchiveAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.Location_Archive", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task> ListRelationshipsByProjectAsync(int projectId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.LocationRelationship_ListByProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> ListRelationshipsByLocationAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.LocationRelationship_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task SaveRelationshipAsync(LocationRelationship relationship)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.LocationRelationship_Save",
+ new
+ {
+ relationship.LocationRelationshipID,
+ relationship.FromLocationID,
+ relationship.ToLocationID,
+ relationship.LocationRelationshipTypeID,
+ relationship.IsBidirectional,
+ relationship.Strength,
+ relationship.CanHearNormalSpeech,
+ relationship.CanHearLoudNoise,
+ relationship.CanSeeMovement,
+ relationship.CanSeeClearly,
+ relationship.CanTravelDirectly,
+ relationship.Notes
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task ArchiveRelationshipAsync(int locationRelationshipId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.LocationRelationship_Archive", new { LocationRelationshipID = locationRelationshipId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task> ListSceneAssetLocationsAsync(int sceneId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.SceneAssetLocation_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task SaveSceneAssetLocationAsync(SceneAssetLocation sceneAssetLocation, bool updateCurrentLocation)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.SceneAssetLocation_Save",
+ new
+ {
+ sceneAssetLocation.SceneAssetLocationID,
+ sceneAssetLocation.SceneID,
+ sceneAssetLocation.StoryAssetID,
+ sceneAssetLocation.LocationID,
+ sceneAssetLocation.Description,
+ UpdateCurrentLocation = updateCurrentLocation
+ },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.SceneAssetLocation_Delete", new { SceneAssetLocationID = sceneAssetLocationId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task> ListScenesByLocationAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.LocationScene_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> ListCharactersByLocationAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.LocationCharacter_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> ListAssetsByLocationAsync(int locationId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.SceneAssetLocation_ListByLocation", new { LocationID = locationId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+}
+
+public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) : IWarningRepository
+{
+ public async Task GetLookupsAsync()
+ {
+ using var connection = connectionFactory.CreateConnection();
+ using var result = await connection.QueryMultipleAsync("dbo.WarningLookup_All", commandType: CommandType.StoredProcedure);
+ return new WarningLookupData
+ {
+ WarningTypes = (await result.ReadAsync()).ToList(),
+ Severities = (await result.ReadAsync()).ToList()
+ };
+ }
+
+ public async Task ValidateProjectAsync(int projectId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.ContinuityValidation_ValidateProject",
+ new { ProjectID = projectId, TriggeredBy = "User" },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task ValidateBookAsync(int bookId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.ContinuityValidation_ValidateBook",
+ new { BookID = bookId, TriggeredBy = "User" },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task ValidateSceneAsync(int sceneId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ return await connection.QuerySingleAsync(
+ "dbo.ContinuityValidation_ValidateScene",
+ new { SceneID = sceneId, TriggeredBy = "User" },
+ commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync(
+ "dbo.ContinuityWarning_List",
+ new
+ {
+ ProjectID = projectId,
+ BookID = bookId,
+ SceneID = sceneId,
+ WarningTypeID = warningTypeId,
+ WarningSeverityID = warningSeverityId,
+ EntityType = string.IsNullOrWhiteSpace(entityType) ? null : entityType,
+ IncludeDismissed = includeDismissed,
+ IncludeIntentional = includeIntentional
+ },
+ commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> ListBySceneAsync(int sceneId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.ContinuityWarning_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> GetSceneCountsAsync(int projectId, int? bookId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.ContinuityWarning_SceneCounts", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task DismissAsync(int continuityWarningId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.ContinuityWarning_Dismiss", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task MarkIntentionalAsync(int continuityWarningId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.ContinuityWarning_MarkIntentional", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task RestoreAsync(int continuityWarningId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.ContinuityWarning_Restore", new { ContinuityWarningID = continuityWarningId }, commandType: CommandType.StoredProcedure);
+ }
+
+ public async Task FinaliseSceneDependencyWarningsAsync(int projectId, int? bookId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ await connection.ExecuteAsync("dbo.SceneDependency_FinaliseWarnings", new { ProjectID = projectId, BookID = bookId }, commandType: CommandType.StoredProcedure);
+ }
+}
+
+public sealed class AnalyticsData
+{
+ public IReadOnlyList MetricPoints { get; init; } = [];
+ public IReadOnlyList RevisionSummary { get; init; } = [];
+ public IReadOnlyList RevisionAttentionScenes { get; init; } = [];
+ public IReadOnlyList PurposeSummary { get; init; } = [];
+ public IReadOnlyList SceneGaps { get; init; } = [];
+ public IReadOnlyList ThreadHealth { get; init; } = [];
+ public IReadOnlyList AssetHealth { get; init; } = [];
+ public IReadOnlyList WarningSeverityCounts { get; init; } = [];
+ public IReadOnlyList WarningTypeCounts { get; init; } = [];
+ public IReadOnlyList WarningHotspots { get; init; } = [];
+ public IReadOnlyList PovSummary { get; init; } = [];
+ public IReadOnlyList MultiPovChapters { get; init; } = [];
+ public IReadOnlyList CharacterAppearances { get; init; } = [];
+ public IReadOnlyList BusyScenes { get; init; } = [];
+ public IReadOnlyList LocationUsage { get; init; } = [];
+}
+
+public sealed class AnalyticsRepository(ISqlConnectionFactory connectionFactory) : IAnalyticsRepository
+{
+ public async Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ using var result = await connection.QueryMultipleAsync(
+ "dbo.Analytics_StoryHealth",
+ new
+ {
+ ProjectID = projectId,
+ BookID = bookId,
+ StartChapterNumber = startChapterNumber,
+ EndChapterNumber = endChapterNumber,
+ StartSceneNumber = startSceneNumber,
+ EndSceneNumber = endSceneNumber
+ },
+ commandType: CommandType.StoredProcedure);
+
+ return new AnalyticsData
+ {
+ MetricPoints = (await result.ReadAsync()).ToList(),
+ RevisionSummary = (await result.ReadAsync()).ToList(),
+ RevisionAttentionScenes = (await result.ReadAsync()).ToList(),
+ PurposeSummary = (await result.ReadAsync()).ToList(),
+ SceneGaps = (await result.ReadAsync()).ToList(),
+ ThreadHealth = (await result.ReadAsync()).ToList(),
+ AssetHealth = (await result.ReadAsync()).ToList(),
+ WarningSeverityCounts = (await result.ReadAsync()).ToList(),
+ WarningTypeCounts = (await result.ReadAsync()).ToList(),
+ WarningHotspots = (await result.ReadAsync()).ToList(),
+ PovSummary = (await result.ReadAsync()).ToList(),
+ MultiPovChapters = (await result.ReadAsync()).ToList(),
+ CharacterAppearances = (await result.ReadAsync()).ToList(),
+ BusyScenes = (await result.ReadAsync()).ToList(),
+ LocationUsage = (await result.ReadAsync()).ToList()
+ };
+ }
+}
+
+public sealed class SceneDependencyRepository(ISqlConnectionFactory connectionFactory) : ISceneDependencyRepository
+{
+ public async Task GetLookupsAsync()
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.SceneDependencyLookup_All", commandType: CommandType.StoredProcedure);
+ return new SceneDependencyLookupData { DependencyTypes = rows.ToList() };
+ }
+
+ public async Task> ListBySceneAsync(int sceneId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync("dbo.SceneDependency_ListByScene", new { SceneID = sceneId }, commandType: CommandType.StoredProcedure);
+ return rows.ToList();
+ }
+
+ public async Task> ListByProjectAsync(int projectId, int? bookId)
+ {
+ using var connection = connectionFactory.CreateConnection();
+ var rows = await connection.QueryAsync