Phase 16B - Manuscript Binding and Linked Structure Foundation

This commit is contained in:
Nick Beckley 2026-06-27 19:14:01 +01:00
parent 3c48301732
commit ced6f4cd0e
14 changed files with 608 additions and 3 deletions

View File

@ -63,7 +63,16 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}

View File

@ -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<IActionResult> GetManuscriptForBook(int bookId)
{
var response = await wordCompanion.GetManuscriptForBookAsync(bookId);
return response is null ? NotFound() : Ok(response);
}
[HttpPost("manuscript/link")]
public async Task<IActionResult> LinkManuscript(WordCompanionManuscriptLinkRequest request)
{
var response = await wordCompanion.LinkManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
}

View File

@ -60,6 +60,15 @@ public interface IBookRepository
Task ArchiveAsync(int bookId);
}
public interface IManuscriptDocumentRepository
{
Task<ManuscriptDocumentModel?> GetByBookAsync(int bookId, int userId);
Task<ManuscriptDocumentModel?> GetByGuidAsync(Guid documentGuid, int userId);
Task<ManuscriptDocumentModel?> SaveAsync(int bookId, Guid documentGuid, int userId);
Task UpdateLastSyncAsync(int manuscriptDocumentId, int userId);
Task UpdateLastOpenedAsync(int manuscriptDocumentId, int userId);
}
public interface IChapterRepository
{
Task<IReadOnlyList<Chapter>> ListByBookAsync(int bookId);
@ -2250,6 +2259,61 @@ public sealed class BookRepository(ISqlConnectionFactory connectionFactory) : IB
}
}
public sealed class ManuscriptDocumentRepository(ISqlConnectionFactory connectionFactory) : IManuscriptDocumentRepository
{
public async Task<ManuscriptDocumentModel?> GetByBookAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ManuscriptDocumentModel>(
"dbo.ManuscriptDocument_GetByBook",
new { BookID = bookId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<ManuscriptDocumentModel?> GetByGuidAsync(Guid documentGuid, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ManuscriptDocumentModel>(
"dbo.ManuscriptDocument_GetByGuid",
new { DocumentGuid = documentGuid, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<ManuscriptDocumentModel?> SaveAsync(int bookId, Guid documentGuid, int userId)
{
using var connection = connectionFactory.CreateConnection();
try
{
return await connection.QuerySingleOrDefaultAsync<ManuscriptDocumentModel>(
"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<IReadOnlyList<Chapter>> 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);
}
}
}

View File

@ -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<int> SelectedPurposeTypeIds { get; set; } = [];
public List<ScenePurposeLabel> PurposeLabels { get; set; } = [];
public List<SceneMetricValue> Metrics { get; set; } = [];

View File

@ -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.";
}

View File

@ -96,6 +96,7 @@ public class Program
builder.Services.AddScoped<IFeatureRequestRepository, FeatureRequestRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IManuscriptDocumentRepository, ManuscriptDocumentRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
builder.Services.AddScoped<ISceneRepository, SceneRepository>();
builder.Services.AddScoped<ISceneFloorPlanOccupancyRepository, SceneFloorPlanOccupancyRepository>();

View File

@ -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,

View File

@ -17,9 +17,14 @@ public interface IWordCompanionService
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> 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<WordCompanionProjectsResponse> ListProjectsAsync()
{
@ -153,6 +158,54 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
}
public async Task<WordCompanionManuscriptBookResponse?> 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<WordCompanionManuscriptLinkResponse?> 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<WordCompanionResolveSceneResponse?> 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<int> 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
};
}

View File

@ -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

View File

@ -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<Chapter> 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<SelectListItem> 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; }

View File

@ -12,6 +12,11 @@
<partial name="_ProjectSectionNav" model="Model.Project" />
@if (TempData["ArchiveMessage"] is string archiveMessage)
{
<div class="alert alert-info">@archiveMessage</div>
}
<div class="page-heading">
<div>
<p class="eyebrow"><a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a></p>
@ -87,6 +92,18 @@
<strong>@(Model.Book.PublicationDate?.ToString("dd MMM yyyy") ?? "Not set")</strong>
<span class="muted">publication</span>
</div>
<div class="col-md-3">
@if (Model.ManuscriptDocument is null)
{
<strong>No manuscript linked</strong>
}
else
{
<strong>Manuscript linked</strong>
<span class="muted">Last synced: @(Model.ManuscriptDocument.LastSyncUtc?.ToString("dd MMM yyyy HH:mm") ?? "Never")</span>
}
<span class="muted">Manuscript Status</span>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Book.ShortDescription))
{
<div class="col-12">
@ -111,6 +128,7 @@
<th>Chapter</th>
<th>Title</th>
<th>Status</th>
<th>Manuscript</th>
<th>Summary</th>
<th></th>
</tr>
@ -122,6 +140,7 @@
<td class="narrow">@chapter.ChapterNumber</td>
<td><a asp-controller="Chapters" asp-action="Details" asp-route-id="@chapter.ChapterID">@chapter.ChapterTitle</a></td>
<td><span class="status-pill">@chapter.RevisionStatusName</span></td>
<td><span class="status-pill">@(chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
<td>@chapter.Summary</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" asp-controller="Chapters" asp-action="Edit" asp-route-id="@chapter.ChapterID">Edit</a>

View File

@ -13,6 +13,11 @@
<partial name="_ProjectSectionNav" model="Model.Project" />
@if (TempData["ArchiveMessage"] is string archiveMessage)
{
<div class="alert alert-info">@archiveMessage</div>
}
<div class="page-heading">
<div>
<p class="eyebrow">
@ -20,6 +25,7 @@
/ <a asp-controller="Books" asp-action="Details" asp-route-id="@Model.Book.BookID">@Model.Book.BookTitle</a>
</p>
<h1>Chapter @Model.Chapter.ChapterNumber: @Model.Chapter.ChapterTitle <help-icon key="chapters.overview" /></h1>
<span class="status-pill">@(Model.Chapter.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
{
<p class="lead-text">@Model.Chapter.Summary</p>
@ -60,6 +66,7 @@
<th>Scene</th>
<th>Title</th>
<th>Status</th>
<th>Manuscript</th>
<th>Time</th>
<th>Summary</th>
<th>Order</th>
@ -73,6 +80,7 @@
<td class="narrow">@scene.SceneNumber</td>
<td><a asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">@scene.SceneTitle</a></td>
<td><span class="status-pill">@scene.RevisionStatusName</span></td>
<td><span class="status-pill">@(scene.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
<td>@scene.TimeLabel</td>
<td>@scene.Summary</td>
<td>

View File

@ -30,6 +30,7 @@
<div>
<p class="eyebrow">@Model.Book?.BookTitle / Chapter @Model.Chapter?.ChapterNumber</p>
<h1>@ViewData["Title"] <help-icon key="scenes.edit" /></h1>
<span class="status-pill">@(Model.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
</div>
<div class="button-row">
@if (Model.SceneID > 0)

View File

@ -46,6 +46,7 @@
</div>
<div class="overview-chip-grid">
<span class="overview-chip">@revisionStatus</span>
<span class="overview-chip">@(Model.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span>
<span class="overview-chip">@timeLabel</span>
<span class="overview-chip @(totalWarningCount > 0 ? "attention" : "")">@totalWarningCount warning@(totalWarningCount == 1 ? "" : "s")</span>
<span class="overview-chip">@Model.SceneCharacters.Count character@(Model.SceneCharacters.Count == 1 ? "" : "s")</span>