From ced6f4cd0e694779b7f4536881ec7ff49172d580 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 27 Jun 2026 19:14:01 +0100 Subject: [PATCH] Phase 16B - Manuscript Binding and Linked Structure Foundation --- PlotLine/Controllers/ChaptersController.cs | 11 +- .../Controllers/WordCompanionController.cs | 14 + PlotLine/Data/Repositories.cs | 73 +++- PlotLine/Models/CoreModels.cs | 22 ++ PlotLine/Models/WordCompanionApiModels.cs | 32 ++ PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 6 + PlotLine/Services/WordCompanionService.cs | 68 +++- ...e16B_ManuscriptBindingLinkedStructures.sql | 350 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 5 + PlotLine/Views/Books/Details.cshtml | 19 + PlotLine/Views/Chapters/Details.cshtml | 8 + PlotLine/Views/Scenes/Edit.cshtml | 1 + PlotLine/Views/Scenes/_SceneInspector.cshtml | 1 + 14 files changed, 608 insertions(+), 3 deletions(-) create mode 100644 PlotLine/Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql diff --git a/PlotLine/Controllers/ChaptersController.cs b/PlotLine/Controllers/ChaptersController.cs index e19859c..0f4bdc8 100644 --- a/PlotLine/Controllers/ChaptersController.cs +++ b/PlotLine/Controllers/ChaptersController.cs @@ -63,7 +63,16 @@ public sealed class ChaptersController(IChapterService chapters) : Controller [ValidateAntiForgeryToken] public async Task Archive(int id, int bookId) { - await chapters.ArchiveAsync(id); + try + { + await chapters.ArchiveAsync(id); + } + catch (InvalidOperationException ex) + { + TempData["ArchiveMessage"] = ex.Message; + return RedirectToAction(nameof(Details), new { id }); + } + TempData["ArchiveMessage"] = "Chapter archived. You can restore it from Archived Items."; return RedirectToAction("Details", "Books", new { id = bookId }); } diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index f7685db..3f7ae0f 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -94,4 +94,18 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion) var response = await wordCompanion.UpdateWordCountAsync(sceneId, request); return response is null ? BadRequest() : Ok(response); } + + [HttpGet("manuscript/book/{bookId:int}")] + public async Task GetManuscriptForBook(int bookId) + { + var response = await wordCompanion.GetManuscriptForBookAsync(bookId); + return response is null ? NotFound() : Ok(response); + } + + [HttpPost("manuscript/link")] + public async Task LinkManuscript(WordCompanionManuscriptLinkRequest request) + { + var response = await wordCompanion.LinkManuscriptAsync(request); + return response is null ? BadRequest() : Ok(response); + } } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 2e8e6e4..1166b2f 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -60,6 +60,15 @@ public interface IBookRepository Task ArchiveAsync(int bookId); } +public interface IManuscriptDocumentRepository +{ + Task GetByBookAsync(int bookId, int userId); + Task GetByGuidAsync(Guid documentGuid, int userId); + Task SaveAsync(int bookId, Guid documentGuid, int userId); + Task UpdateLastSyncAsync(int manuscriptDocumentId, int userId); + Task UpdateLastOpenedAsync(int manuscriptDocumentId, int userId); +} + public interface IChapterRepository { Task> ListByBookAsync(int bookId); @@ -2250,6 +2259,61 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB } } +public sealed class ManuscriptDocumentRepository(ISqlConnectionFactory connectionFactory) : IManuscriptDocumentRepository +{ + public async Task GetByBookAsync(int bookId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.ManuscriptDocument_GetByBook", + new { BookID = bookId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetByGuidAsync(Guid documentGuid, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.ManuscriptDocument_GetByGuid", + new { DocumentGuid = documentGuid, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveAsync(int bookId, Guid documentGuid, int userId) + { + using var connection = connectionFactory.CreateConnection(); + try + { + return await connection.QuerySingleOrDefaultAsync( + "dbo.ManuscriptDocument_Save", + new { BookID = bookId, DocumentGuid = documentGuid, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) when (ex.Number == 51000) + { + throw new InvalidOperationException(ex.Message, ex); + } + } + + public async Task UpdateLastSyncAsync(int manuscriptDocumentId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ManuscriptDocument_UpdateLastSync", + new { ManuscriptDocumentID = manuscriptDocumentId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateLastOpenedAsync(int manuscriptDocumentId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ManuscriptDocument_UpdateLastOpened", + new { ManuscriptDocumentID = manuscriptDocumentId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } +} + public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) : IChapterRepository { public async Task> ListByBookAsync(int bookId) @@ -2277,7 +2341,14 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) : public async Task ArchiveAsync(int chapterId) { using var connection = connectionFactory.CreateConnection(); - await connection.ExecuteAsync("dbo.Chapter_Archive", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); + try + { + await connection.ExecuteAsync("dbo.Chapter_Archive", new { ChapterID = chapterId }, commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) when (ex.Number == 51000) + { + throw new InvalidOperationException(ex.Message, ex); + } } } diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 594877a..7fefa12 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -255,6 +255,20 @@ public sealed class Book : null; } +public sealed class ManuscriptDocumentModel +{ + public int ManuscriptDocumentID { get; set; } + public int BookID { get; set; } + public int ProjectID { get; set; } + public Guid DocumentGuid { get; set; } + public int BindingVersion { get; set; } = 1; + public DateTime CreatedUtc { get; set; } + public DateTime? LastSyncUtc { get; set; } + public DateTime? LastOpenedUtc { get; set; } + public bool IsActive { get; set; } = true; + public int? CreatedByUserID { get; set; } +} + public sealed class CharacterAlias { public int CharacterAliasID { get; set; } @@ -330,6 +344,10 @@ public sealed class Chapter public bool IsArchived { get; set; } public DateTime? ArchivedDate { get; set; } public string? ArchivedReason { get; set; } + public bool IsLinkedToManuscript { get; set; } + public int? ManuscriptDocumentID { get; set; } + public string? ExternalReference { get; set; } + public DateTime? LinkedUtc { get; set; } } public sealed class Scene @@ -374,6 +392,10 @@ public sealed class Scene public bool IsArchived { get; set; } public DateTime? ArchivedDate { get; set; } public string? ArchivedReason { get; set; } + public bool IsLinkedToManuscript { get; set; } + public int? ManuscriptDocumentID { get; set; } + public string? ExternalReference { get; set; } + public DateTime? LinkedUtc { get; set; } public List SelectedPurposeTypeIds { get; set; } = []; public List PurposeLabels { get; set; } = []; public List Metrics { get; set; } = []; diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index b52cab6..365af71 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -217,3 +217,35 @@ public sealed class WordCompanionUpdateWordCountResponse public DateTime? LastWorkedOn { get; init; } public string Message { get; init; } = "Word count updated."; } + +public sealed class WordCompanionManuscriptDocumentDto +{ + public int ManuscriptDocumentId { get; init; } + public int BookId { get; init; } + public int ProjectId { get; init; } + public Guid DocumentGuid { get; init; } + public int BindingVersion { get; init; } + public DateTime CreatedUtc { get; init; } + public DateTime? LastSyncUtc { get; init; } + public DateTime? LastOpenedUtc { get; init; } + public bool IsActive { get; init; } +} + +public sealed class WordCompanionManuscriptBookResponse +{ + public int BookId { get; init; } + public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; } +} + +public sealed class WordCompanionManuscriptLinkRequest +{ + public int BookId { get; set; } + public Guid? DocumentGuid { get; set; } +} + +public sealed class WordCompanionManuscriptLinkResponse +{ + public int BookId { get; init; } + public WordCompanionManuscriptDocumentDto ManuscriptDocument { get; init; } = new(); + public string Message { get; init; } = "Manuscript linked."; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index ca929d9..cca90da 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -96,6 +96,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 92fbf0d..1746b4a 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1122,6 +1122,7 @@ public sealed class BookService( IProjectRepository projects, IBookRepository books, IChapterRepository chapters, + IManuscriptDocumentRepository manuscriptDocuments, IBookCoverService bookCovers, ISubscriptionService subscriptions, IProjectActivityService activity, @@ -1145,6 +1146,9 @@ public sealed class BookService( { Project = project, Book = book, + ManuscriptDocument = currentUser.UserId is int userId + ? await manuscriptDocuments.GetByBookAsync(bookId, userId) + : null, Chapters = await chapters.ListByBookAsync(bookId) }; } @@ -1358,6 +1362,7 @@ public sealed class ChapterService( ChapterTitle = chapter.ChapterTitle, Summary = chapter.Summary, RevisionStatusID = chapter.RevisionStatusID, + IsLinkedToManuscript = chapter.IsLinkedToManuscript, Book = book, Project = project, RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName) @@ -1486,6 +1491,7 @@ public sealed class SceneService( ScenePurposeNotes = scene.ScenePurposeNotes, SceneOutcomeNotes = scene.SceneOutcomeNotes, RevisionStatusID = scene.RevisionStatusID, + IsLinkedToManuscript = scene.IsLinkedToManuscript, PrimaryLocationID = scene.PrimaryLocationID, FloorPlanID = scene.FloorPlanID, InitialFloorPlanFloorID = scene.InitialFloorPlanFloorID, diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index a0b2cdc..3a1f3c1 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -17,9 +17,14 @@ public interface IWordCompanionService Task SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request); Task UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request); Task UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request); + Task GetManuscriptForBookAsync(int bookId); + Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); } -public sealed class WordCompanionService(IWordCompanionRepository repository, ICurrentUserService currentUser) : IWordCompanionService +public sealed class WordCompanionService( + IWordCompanionRepository repository, + IManuscriptDocumentRepository manuscriptDocuments, + ICurrentUserService currentUser) : IWordCompanionService { public async Task ListProjectsAsync() { @@ -153,6 +158,54 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC : repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn); } + public async Task GetManuscriptForBookAsync(int bookId) + { + if (bookId <= 0) + { + return null; + } + + var document = await manuscriptDocuments.GetByBookAsync(bookId, RequireUserId()); + return new WordCompanionManuscriptBookResponse + { + BookId = bookId, + ManuscriptDocument = document is null ? null : ToDto(document) + }; + } + + public async Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request) + { + if (request.BookId <= 0) + { + return null; + } + + var documentGuid = request.DocumentGuid.GetValueOrDefault(); + if (documentGuid == Guid.Empty) + { + documentGuid = Guid.NewGuid(); + } + + ManuscriptDocumentModel? document; + try + { + document = await manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId()); + } + catch (InvalidOperationException) + { + return null; + } + + return document is null + ? null + : new WordCompanionManuscriptLinkResponse + { + BookId = document.BookID, + ManuscriptDocument = ToDto(document), + Message = "Manuscript linked." + }; + } + private async Task ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request) { return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim()); @@ -164,4 +217,17 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC } private static bool HasInvalidIds(IEnumerable ids) => ids.Any(id => id <= 0); + + private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new() + { + ManuscriptDocumentId = document.ManuscriptDocumentID, + BookId = document.BookID, + ProjectId = document.ProjectID, + DocumentGuid = document.DocumentGuid, + BindingVersion = document.BindingVersion, + CreatedUtc = document.CreatedUtc, + LastSyncUtc = document.LastSyncUtc, + LastOpenedUtc = document.LastOpenedUtc, + IsActive = document.IsActive + }; } diff --git a/PlotLine/Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql b/PlotLine/Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql new file mode 100644 index 0000000..66da8f8 --- /dev/null +++ b/PlotLine/Sql/094_Phase16B_ManuscriptBindingLinkedStructures.sql @@ -0,0 +1,350 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ManuscriptDocuments', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ManuscriptDocuments + ( + ManuscriptDocumentID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ManuscriptDocuments PRIMARY KEY, + BookID int NOT NULL, + ProjectID int NOT NULL, + DocumentGuid uniqueidentifier NOT NULL, + BindingVersion int NOT NULL CONSTRAINT DF_ManuscriptDocuments_BindingVersion DEFAULT (1), + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_ManuscriptDocuments_CreatedUtc DEFAULT SYSUTCDATETIME(), + LastSyncUtc datetime2 NULL, + LastOpenedUtc datetime2 NULL, + IsActive bit NOT NULL CONSTRAINT DF_ManuscriptDocuments_IsActive DEFAULT (1), + CreatedByUserID int NULL, + CONSTRAINT FK_ManuscriptDocuments_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID), + CONSTRAINT FK_ManuscriptDocuments_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID), + CONSTRAINT FK_ManuscriptDocuments_AppUser FOREIGN KEY (CreatedByUserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ManuscriptDocuments_ActiveBook' AND object_id = OBJECT_ID(N'dbo.ManuscriptDocuments')) + CREATE UNIQUE INDEX UX_ManuscriptDocuments_ActiveBook ON dbo.ManuscriptDocuments(BookID) WHERE IsActive = 1; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_ManuscriptDocuments_ActiveGuid' AND object_id = OBJECT_ID(N'dbo.ManuscriptDocuments')) + CREATE UNIQUE INDEX UX_ManuscriptDocuments_ActiveGuid ON dbo.ManuscriptDocuments(DocumentGuid) WHERE IsActive = 1; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ManuscriptDocuments_Project' AND object_id = OBJECT_ID(N'dbo.ManuscriptDocuments')) + CREATE INDEX IX_ManuscriptDocuments_Project ON dbo.ManuscriptDocuments(ProjectID, IsActive); +GO + +IF COL_LENGTH(N'dbo.Chapters', N'IsLinkedToManuscript') IS NULL + ALTER TABLE dbo.Chapters ADD IsLinkedToManuscript bit NOT NULL CONSTRAINT DF_Chapters_IsLinkedToManuscript DEFAULT (0); +GO + +IF COL_LENGTH(N'dbo.Chapters', N'ManuscriptDocumentID') IS NULL + ALTER TABLE dbo.Chapters ADD ManuscriptDocumentID int NULL; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'ExternalReference') IS NULL + ALTER TABLE dbo.Chapters ADD ExternalReference nvarchar(100) NULL; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'LinkedUtc') IS NULL + ALTER TABLE dbo.Chapters ADD LinkedUtc datetime2 NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'IsLinkedToManuscript') IS NULL + ALTER TABLE dbo.Scenes ADD IsLinkedToManuscript bit NOT NULL CONSTRAINT DF_Scenes_IsLinkedToManuscript DEFAULT (0); +GO + +IF COL_LENGTH(N'dbo.Scenes', N'ManuscriptDocumentID') IS NULL + ALTER TABLE dbo.Scenes ADD ManuscriptDocumentID int NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'ExternalReference') IS NULL + ALTER TABLE dbo.Scenes ADD ExternalReference nvarchar(100) NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'LinkedUtc') IS NULL + ALTER TABLE dbo.Scenes ADD LinkedUtc datetime2 NULL; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Chapters_ManuscriptDocuments') + ALTER TABLE dbo.Chapters ADD CONSTRAINT FK_Chapters_ManuscriptDocuments FOREIGN KEY (ManuscriptDocumentID) REFERENCES dbo.ManuscriptDocuments(ManuscriptDocumentID); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_Scenes_ManuscriptDocuments') + ALTER TABLE dbo.Scenes ADD CONSTRAINT FK_Scenes_ManuscriptDocuments FOREIGN KEY (ManuscriptDocumentID) REFERENCES dbo.ManuscriptDocuments(ManuscriptDocumentID); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_Chapters_ManuscriptLink' AND object_id = OBJECT_ID(N'dbo.Chapters')) + CREATE INDEX IX_Chapters_ManuscriptLink ON dbo.Chapters(ManuscriptDocumentID, IsLinkedToManuscript, ExternalReference); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_Scenes_ManuscriptLink' AND object_id = OBJECT_ID(N'dbo.Scenes')) + CREATE INDEX IX_Scenes_ManuscriptLink ON dbo.Scenes(ManuscriptDocumentID, IsLinkedToManuscript, ExternalReference); +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_GetByBook + @BookID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT md.ManuscriptDocumentID, md.BookID, md.ProjectID, md.DocumentGuid, md.BindingVersion, + md.CreatedUtc, md.LastSyncUtc, md.LastOpenedUtc, md.IsActive, md.CreatedByUserID + FROM dbo.ManuscriptDocuments md + INNER JOIN dbo.Books b ON b.BookID = md.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE md.BookID = @BookID + AND md.IsActive = 1 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_GetByGuid + @DocumentGuid uniqueidentifier, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT md.ManuscriptDocumentID, md.BookID, md.ProjectID, md.DocumentGuid, md.BindingVersion, + md.CreatedUtc, md.LastSyncUtc, md.LastOpenedUtc, md.IsActive, md.CreatedByUserID + FROM dbo.ManuscriptDocuments md + INNER JOIN dbo.Books b ON b.BookID = md.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE md.DocumentGuid = @DocumentGuid + AND md.IsActive = 1 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_Save + @BookID int, + @DocumentGuid uniqueidentifier, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = b.ProjectID + FROM dbo.Books b + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE b.BookID = @BookID + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; + + IF @ProjectID IS NULL + RETURN; + + IF EXISTS + ( + SELECT 1 + FROM dbo.ManuscriptDocuments + WHERE DocumentGuid = @DocumentGuid + AND IsActive = 1 + AND BookID <> @BookID + ) + THROW 51000, 'This manuscript document is already linked to another book.', 1; + + DECLARE @ManuscriptDocumentID int; + SELECT @ManuscriptDocumentID = ManuscriptDocumentID + FROM dbo.ManuscriptDocuments + WHERE BookID = @BookID + AND IsActive = 1; + + IF @ManuscriptDocumentID IS NULL + BEGIN + INSERT dbo.ManuscriptDocuments (BookID, ProjectID, DocumentGuid, CreatedByUserID) + VALUES (@BookID, @ProjectID, @DocumentGuid, @UserID); + + SET @ManuscriptDocumentID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.ManuscriptDocuments + SET DocumentGuid = @DocumentGuid, + ProjectID = @ProjectID + WHERE ManuscriptDocumentID = @ManuscriptDocumentID; + END; + + EXEC dbo.ManuscriptDocument_GetByBook @BookID = @BookID, @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UpdateLastSync + @ManuscriptDocumentID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE md + SET LastSyncUtc = SYSUTCDATETIME() + FROM dbo.ManuscriptDocuments md + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = md.ProjectID + WHERE md.ManuscriptDocumentID = @ManuscriptDocumentID + AND md.IsActive = 1 + AND pua.UserID = @UserID + AND pua.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ManuscriptDocument_UpdateLastOpened + @ManuscriptDocumentID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE md + SET LastOpenedUtc = SYSUTCDATETIME() + FROM dbo.ManuscriptDocuments md + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = md.ProjectID + WHERE md.ManuscriptDocumentID = @ManuscriptDocumentID + AND md.IsActive = 1 + AND pua.UserID = @UserID + AND pua.IsActive = 1; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.ArchivedDate, c.ArchivedReason, + c.IsLinkedToManuscript, c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + WHERE c.BookID = @BookID AND c.IsArchived = 0 + ORDER BY c.SortOrder, c.ChapterNumber, c.ChapterTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Get + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.ArchivedDate, c.ArchivedReason, + c.IsLinkedToManuscript, c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_ListByChapter + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, + s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder, + s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, + s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, + tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID, + rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived, + s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, + s.ExternalReference, s.LinkedUtc + FROM dbo.Scenes s + INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID + LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID + INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID + WHERE s.ChapterID = @ChapterID AND s.IsArchived = 0 + ORDER BY s.SortOrder, s.SceneNumber, s.SceneTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_Get + @SceneID int +AS +BEGIN + SET NOCOUNT ON; + EXEC dbo.SceneMetric_EnsureDefaults @SceneID; + SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.POVCharacterID, s.PrimaryLocationID, + s.FloorPlanID, s.InitialFloorPlanFloorID, + location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath, + s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID, + du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes, + s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived, + s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, s.ExternalReference, s.LinkedUtc + FROM dbo.Scenes s + INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID + INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID + LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID + LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID + WHERE s.SceneID = @SceneID AND s.IsArchived = 0; + SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID; + EXEC dbo.SceneMetric_ListByScene @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Archive_Entity + @EntityType nvarchar(80), + @EntityID int, + @ArchivedReason nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF @EntityType = N'Project' UPDATE dbo.Projects SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ProjectID = @EntityID; + ELSE IF @EntityType = N'Book' UPDATE dbo.Books SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE BookID = @EntityID; + ELSE IF @EntityType = N'Chapter' + BEGIN + IF EXISTS (SELECT 1 FROM dbo.Chapters WHERE ChapterID = @EntityID AND IsLinkedToManuscript = 1) + THROW 51000, 'This chapter is linked to a manuscript and cannot be deleted from PlotDirector. Delete the chapter from Microsoft Word to remove it from the manuscript.', 1; + + UPDATE dbo.Chapters SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ChapterID = @EntityID; + END + ELSE IF @EntityType = N'Scene' + BEGIN + DECLARE @ChapterID int; + SELECT @ChapterID = ChapterID FROM dbo.Scenes WHERE SceneID = @EntityID AND IsArchived = 0; + + IF EXISTS (SELECT 1 FROM dbo.Scenes WHERE SceneID = @EntityID AND IsLinkedToManuscript = 1) + THROW 51000, 'This scene is linked to a manuscript and cannot be deleted from PlotDirector. Delete the scene from Microsoft Word to remove it from the manuscript.', 1; + + IF @ChapterID IS NULL + RETURN; + + IF (SELECT COUNT(*) FROM dbo.Scenes WHERE ChapterID = @ChapterID AND IsArchived = 0) <= 1 + THROW 51000, 'A chapter must contain at least one scene.', 1; + + UPDATE dbo.Scenes SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @EntityID; + END + ELSE IF @EntityType = N'PlotLine' UPDATE dbo.PlotLines SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE PlotLineID = @EntityID; + ELSE IF @EntityType = N'PlotThread' UPDATE dbo.PlotThreads SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE PlotThreadID = @EntityID; + ELSE IF @EntityType = N'StoryAsset' UPDATE dbo.StoryAssets SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE StoryAssetID = @EntityID; + ELSE IF @EntityType = N'Character' UPDATE dbo.Characters SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE CharacterID = @EntityID; + ELSE IF @EntityType = N'Location' UPDATE dbo.Locations SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE LocationID = @EntityID; + ELSE IF @EntityType = N'Scenario' UPDATE dbo.Scenarios SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE ScenarioID = @EntityID; + ELSE IF @EntityType = N'Relationship' UPDATE dbo.CharacterRelationships SET IsArchived = 1, ArchivedDate = SYSUTCDATETIME(), ArchivedReason = @ArchivedReason, UpdatedDate = SYSUTCDATETIME() WHERE CharacterRelationshipID = @EntityID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Archive @ChapterID int AS BEGIN SET NOCOUNT ON; EXEC dbo.Archive_Entity N'Chapter', @ChapterID, NULL; END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_Archive @SceneID int AS BEGIN SET NOCOUNT ON; EXEC dbo.Archive_Entity N'Scene', @SceneID, NULL; END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index d96b01a..2407a52 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -232,6 +232,7 @@ public sealed class BookDetailViewModel { public Project Project { get; set; } = new(); public Book Book { get; set; } = new(); + public ManuscriptDocumentModel? ManuscriptDocument { get; set; } public IReadOnlyList Chapters { get; set; } = []; } @@ -252,6 +253,8 @@ public sealed class ChapterEditViewModel [Display(Name = "Revision status")] public int RevisionStatusID { get; set; } + public bool IsLinkedToManuscript { get; set; } + public Book? Book { get; set; } public Project? Project { get; set; } public IReadOnlyList RevisionStatuses { get; set; } = []; @@ -311,6 +314,8 @@ public sealed class SceneEditViewModel [Display(Name = "Revision status")] public int RevisionStatusID { get; set; } + public bool IsLinkedToManuscript { get; set; } + [Display(Name = "Primary location")] public int? PrimaryLocationID { get; set; } diff --git a/PlotLine/Views/Books/Details.cshtml b/PlotLine/Views/Books/Details.cshtml index 312dfd3..8777ff2 100644 --- a/PlotLine/Views/Books/Details.cshtml +++ b/PlotLine/Views/Books/Details.cshtml @@ -12,6 +12,11 @@ +@if (TempData["ArchiveMessage"] is string archiveMessage) +{ +
@archiveMessage
+} +

@Model.Project.ProjectName

@@ -87,6 +92,18 @@ @(Model.Book.PublicationDate?.ToString("dd MMM yyyy") ?? "Not set") publication
+
+ @if (Model.ManuscriptDocument is null) + { + No manuscript linked + } + else + { + Manuscript linked + Last synced: @(Model.ManuscriptDocument.LastSyncUtc?.ToString("dd MMM yyyy HH:mm") ?? "Never") + } + Manuscript Status +
@if (!string.IsNullOrWhiteSpace(Model.Book.ShortDescription)) {
@@ -111,6 +128,7 @@ Chapter Title Status + Manuscript Summary @@ -122,6 +140,7 @@ @chapter.ChapterNumber @chapter.ChapterTitle @chapter.RevisionStatusName + @(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") @chapter.Summary Edit diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index ba219a3..70b2a2e 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -13,6 +13,11 @@ +@if (TempData["ArchiveMessage"] is string archiveMessage) +{ +
@archiveMessage
+} +

@@ -20,6 +25,7 @@ / @Model.Book.BookTitle

Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle

+ @(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") @if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary)) {

@Model.Chapter.Summary

@@ -60,6 +66,7 @@ Scene Title Status + Manuscript Time Summary Order @@ -73,6 +80,7 @@ @scene.SceneNumber @scene.SceneTitle @scene.RevisionStatusName + @(scene.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") @scene.TimeLabel @scene.Summary diff --git a/PlotLine/Views/Scenes/Edit.cshtml b/PlotLine/Views/Scenes/Edit.cshtml index 3c600c7..41f276b 100644 --- a/PlotLine/Views/Scenes/Edit.cshtml +++ b/PlotLine/Views/Scenes/Edit.cshtml @@ -30,6 +30,7 @@

@Model.Book?.BookTitle / Chapter @Model.Chapter?.ChapterNumber

@ViewData["Title"]

+ @(Model.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")
@if (Model.SceneID > 0) diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 9ee5760..76b4b88 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -46,6 +46,7 @@
@revisionStatus + @(Model.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") @timeLabel @totalWarningCount warning@(totalWarningCount == 1 ? "" : "s") @Model.SceneCharacters.Count character@(Model.SceneCharacters.Count == 1 ? "" : "s")