Phase 16C - Word Companion First-Run Binding and Manuscript Import

This commit is contained in:
Nick Beckley 2026-06-27 19:41:44 +01:00
parent ced6f4cd0e
commit 33e8f0857c
10 changed files with 1418 additions and 10 deletions

View File

@ -20,6 +20,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("projects/{projectId:int}/books")]
public async Task<IActionResult> CreateBook(int projectId, WordCompanionCreateBookRequest request)
{
var response = await wordCompanion.CreateBookAsync(projectId, request);
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("books/{bookId:int}/structure")]
public async Task<IActionResult> GetBookStructure(int bookId)
{
@ -108,4 +115,18 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
var response = await wordCompanion.LinkManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/analyse")]
public async Task<IActionResult> AnalyseManuscript(WordCompanionManuscriptAnalyseRequest request)
{
var response = await wordCompanion.AnalyseManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/import")]
public async Task<IActionResult> ImportManuscript(WordCompanionManuscriptImportRequest request)
{
var response = await wordCompanion.ImportManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
}

View File

@ -8,6 +8,7 @@ public interface IWordCompanionRepository
{
Task<IReadOnlyList<WordCompanionProjectDto>> ListProjectsAsync(int userId);
Task<IReadOnlyList<WordCompanionBookDto>> ListBooksAsync(int projectId, int userId);
Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, int userId, WordCompanionCreateBookRequest request);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId, int userId);
Task<IReadOnlyList<WordCompanionChapterDto>> ListChaptersAsync(int bookId, int userId);
Task<WordCompanionResolveSceneResponse> ResolveSceneAsync(int bookId, int userId, string chapterTitle, string sceneTitle);
@ -18,6 +19,8 @@ public interface IWordCompanionRepository
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
}
public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository
@ -42,6 +45,15 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
return rows.ToList();
}
public async Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, int userId, WordCompanionCreateBookRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionCreateBookResponse>(
"dbo.WordCompanion_Book_Create",
new { ProjectID = projectId, UserID = userId, request.Title },
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId, int userId)
{
using var connection = connectionFactory.CreateConnection();
@ -221,6 +233,76 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionManuscriptAnalyseResponse>(
"dbo.WordCompanion_Manuscript_Analyse",
new
{
UserID = userId,
request.ProjectId,
request.BookId,
DocumentGuid = request.DocumentGuid,
request.ChapterCount,
request.SceneCount,
request.WordCount
},
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request)
{
var chapters = request.Chapters.Select(chapter => new
{
chapter.Title,
chapter.SortOrder,
chapter.WordCount,
Scenes = chapter.Scenes.Select(scene => new
{
scene.Title,
scene.SortOrder,
scene.WordCount
})
});
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.WordCompanion_Manuscript_Import",
new
{
UserID = userId,
request.ProjectId,
request.BookId,
DocumentGuid = request.DocumentGuid.GetValueOrDefault(),
request.BindingVersion,
request.ReplacePlanned,
StructureJson = System.Text.Json.JsonSerializer.Serialize(chapters)
},
commandType: CommandType.StoredProcedure);
var response = await result.ReadSingleOrDefaultAsync<WordCompanionManuscriptImportResponse>();
if (response is null)
{
return null;
}
var document = await result.ReadSingleOrDefaultAsync<WordCompanionManuscriptDocumentDto>();
return new WordCompanionManuscriptImportResponse
{
Imported = response.Imported,
RequiresReplaceConfirmation = response.RequiresReplaceConfirmation,
Message = response.Message,
ProjectId = response.ProjectId,
BookId = response.BookId,
ChaptersCreated = response.ChaptersCreated,
ScenesCreated = response.ScenesCreated,
ArchivedChapters = response.ArchivedChapters,
ArchivedScenes = response.ArchivedScenes,
ManuscriptDocument = document
};
}
private static string ToCsv(IEnumerable<int> ids) => string.Join(',', ids.Where(id => id > 0).Distinct());
private sealed class WordCompanionSceneStructureRow

View File

@ -17,6 +17,7 @@ public sealed class WordCompanionBookDto
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public int SortOrder { get; set; }
public bool UsesExplicitScenes { get; set; }
}
public sealed class WordCompanionBooksResponse
@ -249,3 +250,86 @@ public sealed class WordCompanionManuscriptLinkResponse
public WordCompanionManuscriptDocumentDto ManuscriptDocument { get; init; } = new();
public string Message { get; init; } = "Manuscript linked.";
}
public sealed class WordCompanionCreateBookRequest
{
public string? Title { get; set; }
}
public sealed class WordCompanionCreateBookResponse
{
public int BookId { get; init; }
public string Title { get; init; } = string.Empty;
public string? Subtitle { get; init; }
public int SortOrder { get; init; }
public bool UsesExplicitScenes { get; init; }
public string Message { get; init; } = "Book created.";
}
public sealed class WordCompanionManuscriptImportSceneRequest
{
public string? Title { get; set; }
public int? SortOrder { get; set; }
public int WordCount { get; set; }
}
public sealed class WordCompanionManuscriptImportChapterRequest
{
public string? Title { get; set; }
public int? SortOrder { get; set; }
public int WordCount { get; set; }
public IReadOnlyList<WordCompanionManuscriptImportSceneRequest> Scenes { get; set; } = [];
}
public sealed class WordCompanionManuscriptAnalyseRequest
{
public int ProjectId { get; set; }
public int BookId { get; set; }
public Guid? DocumentGuid { get; set; }
public int ChapterCount { get; set; }
public int SceneCount { get; set; }
public int WordCount { get; set; }
}
public sealed class WordCompanionManuscriptAnalyseResponse
{
public int ProjectId { get; init; }
public int BookId { get; init; }
public string ProjectTitle { get; init; } = string.Empty;
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public bool UsesExplicitScenes { get; init; }
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int WordCount { get; init; }
public int ExistingChapterCount { get; init; }
public int ExistingSceneCount { get; init; }
public int LinkedChapterCount { get; init; }
public bool CanImport { get; init; }
public bool RequiresReplaceConfirmation { get; init; }
public string Message { get; init; } = string.Empty;
}
public sealed class WordCompanionManuscriptImportRequest
{
public int ProjectId { get; set; }
public int BookId { get; set; }
public Guid? DocumentGuid { get; set; }
public int BindingVersion { get; set; } = 1;
public bool ReplacePlanned { get; set; }
public IReadOnlyList<WordCompanionManuscriptImportChapterRequest> Chapters { get; set; } = [];
}
public sealed class WordCompanionManuscriptImportResponse
{
public bool Imported { get; init; }
public bool RequiresReplaceConfirmation { get; init; }
public string Message { get; init; } = string.Empty;
public int ProjectId { get; init; }
public int BookId { get; init; }
public int ChaptersCreated { get; init; }
public int ScenesCreated { get; init; }
public int ArchivedChapters { get; init; }
public int ArchivedScenes { get; init; }
public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; }
}

View File

@ -7,6 +7,7 @@ public interface IWordCompanionService
{
Task<WordCompanionProjectsResponse> ListProjectsAsync();
Task<WordCompanionBooksResponse?> ListBooksAsync(int projectId);
Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request);
Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId);
Task<WordCompanionChaptersResponse?> ListChaptersAsync(int bookId);
Task<WordCompanionResolveSceneResponse?> ResolveSceneAsync(int bookId, WordCompanionResolveSceneRequest request);
@ -19,6 +20,8 @@ public interface IWordCompanionService
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
}
public sealed class WordCompanionService(
@ -43,6 +46,17 @@ public sealed class WordCompanionService(
return new WordCompanionBooksResponse { Books = books };
}
public Task<WordCompanionCreateBookResponse?> CreateBookAsync(int projectId, WordCompanionCreateBookRequest request)
{
if (projectId <= 0 || string.IsNullOrWhiteSpace(request.Title))
{
return Task.FromResult<WordCompanionCreateBookResponse?>(null);
}
request.Title = request.Title.Trim();
return repository.CreateBookAsync(projectId, RequireUserId(), request);
}
public Task<WordCompanionBookStructureResponse?> GetBookStructureAsync(int bookId)
{
return bookId <= 0 ? Task.FromResult<WordCompanionBookStructureResponse?>(null) : repository.GetBookStructureAsync(bookId, RequireUserId());
@ -206,6 +220,50 @@ public sealed class WordCompanionService(
};
}
public Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request)
{
if (request.ProjectId <= 0
|| request.BookId <= 0
|| request.ChapterCount < 0
|| request.SceneCount < 0
|| request.WordCount < 0)
{
return Task.FromResult<WordCompanionManuscriptAnalyseResponse?>(null);
}
return repository.AnalyseManuscriptAsync(RequireUserId(), request);
}
public Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request)
{
if (request.ProjectId <= 0
|| request.BookId <= 0
|| request.BindingVersion <= 0
|| request.Chapters.Count == 0
|| request.Chapters.Any(chapter =>
string.IsNullOrWhiteSpace(chapter.Title)
|| chapter.SortOrder is <= 0
|| chapter.WordCount < 0
|| chapter.Scenes.Count == 0
|| chapter.Scenes.Any(scene => string.IsNullOrWhiteSpace(scene.Title) || scene.SortOrder is <= 0 || scene.WordCount < 0)))
{
return Task.FromResult<WordCompanionManuscriptImportResponse?>(null);
}
request.DocumentGuid = request.DocumentGuid.GetValueOrDefault() == Guid.Empty ? Guid.NewGuid() : request.DocumentGuid;
foreach (var chapter in request.Chapters)
{
chapter.Title = chapter.Title?.Trim();
foreach (var scene in chapter.Scenes)
{
scene.Title = scene.Title?.Trim();
}
}
return repository.ImportManuscriptAsync(RequireUserId(), request);
}
private async Task<WordCompanionResolveSceneResponse?> ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request)
{
return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim());

View File

@ -0,0 +1,495 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Book_ListByProject
@ProjectID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT b.BookID AS BookId,
b.BookTitle AS Title,
b.Subtitle,
b.SortOrder,
b.UsesExplicitScenes
FROM dbo.Books b
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = b.ProjectID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
WHERE b.ProjectID = @ProjectID
AND pua.UserID = @UserID
AND pua.IsActive = 1
AND p.IsArchived = 0
AND b.IsArchived = 0
ORDER BY b.SortOrder, b.BookNumber, b.BookTitle, b.BookID;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Book_Create
@ProjectID int,
@UserID int,
@Title nvarchar(200)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @TrimmedTitle nvarchar(200) = LTRIM(RTRIM(COALESCE(@Title, N'')));
IF NULLIF(@TrimmedTitle, N'') IS NULL
RETURN;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Projects p
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE p.ProjectID = @ProjectID
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1
)
RETURN;
DECLARE @NextBookNumber int = ISNULL((SELECT MAX(BookNumber) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 1;
DECLARE @NextBookOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Books WHERE ProjectID = @ProjectID), 0) + 10;
DECLARE @BookID int;
INSERT dbo.Books (ProjectID, BookTitle, Status, BookNumber, SortOrder, UsesExplicitScenes)
VALUES (@ProjectID, @TrimmedTitle, N'Planning', @NextBookNumber, @NextBookOrder, 0);
SET @BookID = CAST(SCOPE_IDENTITY() AS int);
SELECT @BookID AS BookId,
@TrimmedTitle AS Title,
CAST(NULL AS nvarchar(200)) AS Subtitle,
@NextBookOrder AS SortOrder,
CAST(0 AS bit) AS UsesExplicitScenes,
N'Book created.' AS Message;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Manuscript_Analyse
@UserID int,
@ProjectID int,
@BookID int,
@DocumentGuid uniqueidentifier = NULL,
@ChapterCount int,
@SceneCount int,
@WordCount int
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ProjectTitle nvarchar(200);
DECLARE @BookTitle nvarchar(200);
DECLARE @BookSubtitle nvarchar(200);
DECLARE @UsesExplicitScenes bit;
SELECT @ProjectTitle = p.ProjectName,
@BookTitle = b.BookTitle,
@BookSubtitle = b.Subtitle,
@UsesExplicitScenes = b.UsesExplicitScenes
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.ProjectID = @ProjectID
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1;
IF @BookTitle IS NULL
RETURN;
DECLARE @ExistingChapterCount int = (SELECT COUNT(*) FROM dbo.Chapters WHERE BookID = @BookID AND IsArchived = 0);
DECLARE @ExistingSceneCount int =
(
SELECT COUNT(*)
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE c.BookID = @BookID
AND c.IsArchived = 0
AND s.IsArchived = 0
);
DECLARE @LinkedChapterCount int =
(
SELECT COUNT(*)
FROM dbo.Chapters
WHERE BookID = @BookID
AND IsArchived = 0
AND IsLinkedToManuscript = 1
);
SELECT @ProjectID AS ProjectId,
@BookID AS BookId,
@ProjectTitle AS ProjectTitle,
@BookTitle AS BookTitle,
@BookSubtitle AS BookSubtitle,
@UsesExplicitScenes AS UsesExplicitScenes,
@ChapterCount AS ChapterCount,
@SceneCount AS SceneCount,
@WordCount AS WordCount,
@ExistingChapterCount AS ExistingChapterCount,
@ExistingSceneCount AS ExistingSceneCount,
@LinkedChapterCount AS LinkedChapterCount,
CAST(CASE WHEN @LinkedChapterCount = 0 AND @ChapterCount > 0 THEN 1 ELSE 0 END AS bit) AS CanImport,
CAST(CASE WHEN @LinkedChapterCount = 0 AND @ExistingChapterCount > 0 THEN 1 ELSE 0 END AS bit) AS RequiresReplaceConfirmation,
CASE
WHEN @LinkedChapterCount > 0 THEN N'This Book already contains a linked manuscript.'
WHEN @ChapterCount = 0 THEN N'No chapters were found. Use Heading 1 for chapter titles.'
WHEN @ExistingChapterCount > 0 THEN N'This Book already contains planned chapters and scenes.'
ELSE N'Ready to import manuscript structure.'
END AS Message;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Manuscript_Import
@UserID int,
@ProjectID int,
@BookID int,
@DocumentGuid uniqueidentifier,
@BindingVersion int = 1,
@ReplacePlanned bit = 0,
@StructureJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @DocumentGuid IS NULL OR @DocumentGuid = '00000000-0000-0000-0000-000000000000'
SET @DocumentGuid = NEWID();
DECLARE @BookTitle nvarchar(200);
DECLARE @AccessProjectID int;
SELECT @AccessProjectID = b.ProjectID,
@BookTitle = b.BookTitle
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.ProjectID = @ProjectID
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1;
IF @BookTitle IS NULL
RETURN;
DECLARE @LinkedChapterCount int =
(
SELECT COUNT(*)
FROM dbo.Chapters
WHERE BookID = @BookID
AND IsArchived = 0
AND IsLinkedToManuscript = 1
);
IF @LinkedChapterCount > 0
BEGIN
SELECT CAST(0 AS bit) AS Imported,
CAST(0 AS bit) AS RequiresReplaceConfirmation,
N'This Book already contains a linked manuscript.' AS Message,
@ProjectID AS ProjectId,
@BookID AS BookId,
0 AS ChaptersCreated,
0 AS ScenesCreated,
0 AS ArchivedChapters,
0 AS ArchivedScenes;
RETURN;
END;
DECLARE @ChapterCount int = (SELECT COUNT(*) FROM dbo.Chapters WHERE BookID = @BookID AND IsArchived = 0);
IF @ChapterCount > 0 AND @ReplacePlanned = 0
BEGIN
SELECT CAST(0 AS bit) AS Imported,
CAST(1 AS bit) AS RequiresReplaceConfirmation,
N'This Book already contains planned chapters and scenes.' AS Message,
@ProjectID AS ProjectId,
@BookID AS BookId,
0 AS ChaptersCreated,
0 AS ScenesCreated,
0 AS ArchivedChapters,
0 AS ArchivedScenes;
RETURN;
END;
DECLARE @Chapters table
(
ImportChapterIndex int NOT NULL PRIMARY KEY,
Title nvarchar(200) NOT NULL,
SortOrder int NOT NULL,
WordCount int NOT NULL
);
DECLARE @Scenes table
(
ImportChapterIndex int NOT NULL,
ImportSceneIndex int NOT NULL,
Title nvarchar(200) NOT NULL,
SortOrder int NOT NULL,
WordCount int NOT NULL,
PRIMARY KEY (ImportChapterIndex, ImportSceneIndex)
);
INSERT @Chapters (ImportChapterIndex, Title, SortOrder, WordCount)
SELECT CONVERT(int, chapter.[key]) + 1,
LEFT(LTRIM(RTRIM(JSON_VALUE(chapter.value, '$.Title'))), 200),
TRY_CONVERT(int, JSON_VALUE(chapter.value, '$.SortOrder')),
COALESCE(TRY_CONVERT(int, JSON_VALUE(chapter.value, '$.WordCount')), 0)
FROM OPENJSON(@StructureJson) chapter
WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(chapter.value, '$.Title'))), N'') IS NOT NULL;
INSERT @Scenes (ImportChapterIndex, ImportSceneIndex, Title, SortOrder, WordCount)
SELECT c.ImportChapterIndex,
CONVERT(int, scene.[key]) + 1,
LEFT(LTRIM(RTRIM(JSON_VALUE(scene.value, '$.Title'))), 200),
TRY_CONVERT(int, JSON_VALUE(scene.value, '$.SortOrder')),
COALESCE(TRY_CONVERT(int, JSON_VALUE(scene.value, '$.WordCount')), 0)
FROM @Chapters c
INNER JOIN OPENJSON(@StructureJson) chapter ON CONVERT(int, chapter.[key]) + 1 = c.ImportChapterIndex
CROSS APPLY OPENJSON(chapter.value, '$.Scenes') scene
WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(scene.value, '$.Title'))), N'') IS NOT NULL;
IF NOT EXISTS (SELECT 1 FROM @Chapters)
RETURN;
IF EXISTS (SELECT 1 FROM @Chapters WHERE SortOrder IS NULL OR SortOrder <= 0 OR WordCount < 0)
RETURN;
IF EXISTS (SELECT 1 FROM @Scenes WHERE SortOrder IS NULL OR SortOrder <= 0 OR WordCount < 0)
RETURN;
IF EXISTS (SELECT 1 FROM @Chapters c WHERE NOT EXISTS (SELECT 1 FROM @Scenes s WHERE s.ImportChapterIndex = c.ImportChapterIndex))
RETURN;
DECLARE @PlannedRevisionStatusID int = (SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder);
DECLARE @DefaultTimeModeID int = (SELECT TOP (1) TimeModeID FROM dbo.TimeModes ORDER BY CASE WHEN TimeModeName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeModeID);
DECLARE @DefaultTimeConfidenceID int = (SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences ORDER BY CASE WHEN TimeConfidenceName = N'Unknown' THEN 0 ELSE 1 END, SortOrder, TimeConfidenceID);
IF @PlannedRevisionStatusID IS NULL OR @DefaultTimeModeID IS NULL OR @DefaultTimeConfidenceID IS NULL
RETURN;
DECLARE @ImportChapterMap table
(
ImportChapterIndex int NOT NULL PRIMARY KEY,
ChapterID int NOT NULL
);
DECLARE @ImportSceneMap table
(
ImportChapterIndex int NOT NULL,
ImportSceneIndex int NOT NULL,
SceneID int NOT NULL,
PRIMARY KEY (ImportChapterIndex, ImportSceneIndex)
);
DECLARE @ArchivedChapters int = 0;
DECLARE @ArchivedScenes int = 0;
DECLARE @ManuscriptDocumentID int;
DECLARE @LinkedUtc datetime2 = SYSUTCDATETIME();
BEGIN TRANSACTION;
IF @ChapterCount > 0
BEGIN
UPDATE s
SET IsArchived = 1,
ArchivedDate = SYSUTCDATETIME(),
ArchivedReason = N'Replaced by Word Companion manuscript import',
UpdatedDate = SYSUTCDATETIME()
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
WHERE c.BookID = @BookID
AND c.IsArchived = 0
AND s.IsArchived = 0
AND c.IsLinkedToManuscript = 0
AND s.IsLinkedToManuscript = 0;
SET @ArchivedScenes = @@ROWCOUNT;
UPDATE dbo.Chapters
SET IsArchived = 1,
ArchivedDate = SYSUTCDATETIME(),
ArchivedReason = N'Replaced by Word Companion manuscript import',
UpdatedDate = SYSUTCDATETIME()
WHERE BookID = @BookID
AND IsArchived = 0
AND IsLinkedToManuscript = 0;
SET @ArchivedChapters = @@ROWCOUNT;
END;
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;
SELECT @ManuscriptDocumentID = ManuscriptDocumentID
FROM dbo.ManuscriptDocuments
WHERE BookID = @BookID
AND IsActive = 1;
IF @ManuscriptDocumentID IS NULL
BEGIN
INSERT dbo.ManuscriptDocuments (BookID, ProjectID, DocumentGuid, BindingVersion, CreatedByUserID, LastOpenedUtc, LastSyncUtc)
VALUES (@BookID, @ProjectID, @DocumentGuid, @BindingVersion, @UserID, @LinkedUtc, @LinkedUtc);
SET @ManuscriptDocumentID = CAST(SCOPE_IDENTITY() AS int);
END
ELSE
BEGIN
UPDATE dbo.ManuscriptDocuments
SET DocumentGuid = @DocumentGuid,
ProjectID = @ProjectID,
BindingVersion = @BindingVersion,
LastOpenedUtc = @LinkedUtc,
LastSyncUtc = @LinkedUtc
WHERE ManuscriptDocumentID = @ManuscriptDocumentID;
END;
DECLARE @ImportChapterIndex int;
DECLARE @ChapterTitle nvarchar(200);
DECLARE @ChapterSortOrder int;
DECLARE @ChapterID int;
DECLARE @ChapterNumber decimal(9,2);
DECLARE chapter_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT ImportChapterIndex, Title, SortOrder
FROM @Chapters
ORDER BY SortOrder, ImportChapterIndex;
OPEN chapter_cursor;
FETCH NEXT FROM chapter_cursor INTO @ImportChapterIndex, @ChapterTitle, @ChapterSortOrder;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @ChapterNumber = @ImportChapterIndex;
INSERT dbo.Chapters
(
BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID,
IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc
)
VALUES
(
@BookID, @ChapterNumber, @ChapterTitle, NULL, @ChapterSortOrder, @PlannedRevisionStatusID,
1, @ManuscriptDocumentID, CONCAT(N'PD_CHAPTER_IMPORT_', @ImportChapterIndex), @LinkedUtc
);
SET @ChapterID = CAST(SCOPE_IDENTITY() AS int);
INSERT @ImportChapterMap (ImportChapterIndex, ChapterID) VALUES (@ImportChapterIndex, @ChapterID);
FETCH NEXT FROM chapter_cursor INTO @ImportChapterIndex, @ChapterTitle, @ChapterSortOrder;
END;
CLOSE chapter_cursor;
DEALLOCATE chapter_cursor;
DECLARE @ImportSceneIndex int;
DECLARE @SceneTitle nvarchar(200);
DECLARE @SceneSortOrder int;
DECLARE @SceneWordCount int;
DECLARE @SceneID int;
DECLARE scene_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT s.ImportChapterIndex, s.ImportSceneIndex, s.Title, s.SortOrder, s.WordCount, m.ChapterID
FROM @Scenes s
INNER JOIN @ImportChapterMap m ON m.ImportChapterIndex = s.ImportChapterIndex
ORDER BY s.ImportChapterIndex, s.SortOrder, s.ImportSceneIndex;
OPEN scene_cursor;
FETCH NEXT FROM scene_cursor INTO @ImportChapterIndex, @ImportSceneIndex, @SceneTitle, @SceneSortOrder, @SceneWordCount, @ChapterID;
WHILE @@FETCH_STATUS = 0
BEGIN
IF @ImportSceneIndex = 1
BEGIN
SELECT TOP (1) @SceneID = SceneID
FROM dbo.Scenes
WHERE ChapterID = @ChapterID
AND IsArchived = 0
ORDER BY SortOrder, SceneID;
UPDATE dbo.Scenes
SET SceneNumber = @ImportSceneIndex,
SceneTitle = @SceneTitle,
SortOrder = @SceneSortOrder,
IsLinkedToManuscript = 1,
ManuscriptDocumentID = @ManuscriptDocumentID,
ExternalReference = CONCAT(N'PD_SCENE_IMPORT_', @ImportChapterIndex, N'_', @ImportSceneIndex),
LinkedUtc = @LinkedUtc,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END
ELSE
BEGIN
INSERT dbo.Scenes
(
ChapterID, SceneNumber, SceneTitle, Summary, SortOrder, TimeModeID, TimeConfidenceID, RevisionStatusID,
IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc
)
VALUES
(
@ChapterID, @ImportSceneIndex, @SceneTitle, NULL, @SceneSortOrder, @DefaultTimeModeID, @DefaultTimeConfidenceID, @PlannedRevisionStatusID,
1, @ManuscriptDocumentID, CONCAT(N'PD_SCENE_IMPORT_', @ImportChapterIndex, N'_', @ImportSceneIndex), @LinkedUtc
);
SET @SceneID = CAST(SCOPE_IDENTITY() AS int);
END;
IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID)
BEGIN
UPDATE dbo.SceneWorkflow
SET ActualWordCount = @SceneWordCount,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END
ELSE
BEGIN
INSERT dbo.SceneWorkflow (SceneID, ActualWordCount)
VALUES (@SceneID, @SceneWordCount);
END;
INSERT @ImportSceneMap (ImportChapterIndex, ImportSceneIndex, SceneID)
VALUES (@ImportChapterIndex, @ImportSceneIndex, @SceneID);
FETCH NEXT FROM scene_cursor INTO @ImportChapterIndex, @ImportSceneIndex, @SceneTitle, @SceneSortOrder, @SceneWordCount, @ChapterID;
END;
CLOSE scene_cursor;
DEALLOCATE scene_cursor;
COMMIT TRANSACTION;
SELECT CAST(1 AS bit) AS Imported,
CAST(0 AS bit) AS RequiresReplaceConfirmation,
N'Manuscript structure imported.' AS Message,
@ProjectID AS ProjectId,
@BookID AS BookId,
(SELECT COUNT(*) FROM @ImportChapterMap) AS ChaptersCreated,
(SELECT COUNT(*) FROM @ImportSceneMap) AS ScenesCreated,
@ArchivedChapters AS ArchivedChapters,
@ArchivedScenes AS ArchivedScenes;
SELECT md.ManuscriptDocumentID AS ManuscriptDocumentId,
md.BookID AS BookId,
md.ProjectID AS ProjectId,
md.DocumentGuid,
md.BindingVersion,
md.CreatedUtc,
md.LastSyncUtc,
md.LastOpenedUtc,
md.IsActive
FROM dbo.ManuscriptDocuments md
WHERE md.ManuscriptDocumentID = @ManuscriptDocumentID;
END;
GO

View File

@ -42,6 +42,41 @@
}
else
{
<section class="word-companion-section word-companion-first-run" data-first-run-wizard hidden aria-label="First-run manuscript import">
<div class="word-companion-section-header">
<span>Bind Manuscript</span>
</div>
<div>
<label for="word-companion-first-run-project">Project</label>
<select id="word-companion-first-run-project" data-first-run-project-select disabled>
<option value="">Loading projects...</option>
</select>
</div>
<div>
<label for="word-companion-first-run-book">Book</label>
<select id="word-companion-first-run-book" data-first-run-book-select disabled>
<option value="">Select a project first</option>
</select>
</div>
<div class="word-companion-inline-create">
<label for="word-companion-first-run-book-title">Create new Book</label>
<div>
<input id="word-companion-first-run-book-title" type="text" maxlength="200" data-first-run-new-book-title autocomplete="off" />
<button type="button" data-first-run-create-book disabled>Create</button>
</div>
</div>
<div class="word-companion-actions">
<button type="button" class="word-companion-primary-action" data-first-run-scan disabled>Scan Manuscript</button>
<button type="button" data-first-run-cancel>Cancel</button>
</div>
<p class="word-companion-message" data-first-run-status>Select a Project and Book to begin.</p>
<div class="word-companion-import-summary" data-first-run-summary hidden></div>
<div class="word-companion-actions" data-first-run-import-actions hidden>
<button type="button" class="word-companion-primary-action" data-first-run-import disabled>Import Structure</button>
<button type="button" data-first-run-import-cancel>Cancel</button>
</div>
</section>
<nav class="word-companion-tabs" aria-label="Companion sections">
<button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
<button type="button" data-tab-button="links" aria-selected="false">Links</button>
@ -432,6 +467,20 @@
</div>
</form>
</dialog>
<dialog class="word-companion-dialog" data-first-run-replace-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Replace Planned Structure?</span>
</div>
<p class="word-companion-dialog-copy">This Book already contains planned chapters and scenes.</p>
<p class="word-companion-dialog-copy">Importing this manuscript will replace the planned structure.</p>
<div class="word-companion-dialog-actions">
<button type="button" data-first-run-replace-confirm>Continue</button>
<button type="button" data-first-run-replace-cancel>Cancel</button>
</div>
</form>
</dialog>
}
</main>
<script src="~/js/word-companion-host.js" asp-append-version="true"></script>

View File

@ -321,7 +321,8 @@ body {
display: block;
}
.word-companion-section select {
.word-companion-section select,
.word-companion-section input[type="text"] {
width: 100%;
min-height: 34px;
border: 1px solid var(--companion-line);
@ -337,6 +338,67 @@ body {
gap: 9px;
}
.word-companion-first-run {
gap: 10px;
}
.word-companion-inline-create {
display: grid;
gap: 5px;
}
.word-companion-inline-create > div {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px;
}
.word-companion-inline-create button {
min-height: 34px;
border: 1px solid var(--companion-accent);
border-radius: 6px;
padding: 6px 9px;
background: var(--companion-accent);
color: #fff9ef;
font: inherit;
font-size: 0.8rem;
font-weight: 800;
}
.word-companion-inline-create button:disabled {
border-color: var(--companion-line);
background: var(--companion-soft);
color: var(--companion-muted);
}
.word-companion-import-summary {
display: grid;
gap: 7px;
border-top: 1px solid var(--companion-line);
padding-top: 9px;
}
.word-companion-import-summary dl {
display: grid;
grid-template-columns: 1fr auto;
gap: 5px 10px;
margin: 0;
}
.word-companion-import-summary dt {
color: var(--companion-muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-import-summary dd {
margin: 0;
color: var(--companion-ink);
font-weight: 800;
text-align: right;
}
.word-companion-connection .word-companion-message {
grid-column: auto;
}

File diff suppressed because one or more lines are too long

View File

@ -4,11 +4,33 @@
bookId: "plotdirector.word.bookId",
activeTab: "plotdirector.word.activeTab"
};
const documentMetadataKeys = {
documentGuid: "plotdirector.word.documentGuid",
bookId: "plotdirector.word.boundBookId",
projectId: "plotdirector.word.boundProjectId",
bindingVersion: "plotdirector.word.bindingVersion"
};
const validTabNames = new Set(["scene", "links", "structure", "diagnostics"]);
const shell = document.querySelector(".word-companion-shell");
const tabsNav = document.querySelector(".word-companion-tabs");
const tabButtons = [...document.querySelectorAll("[data-tab-button]")];
const tabPanels = [...document.querySelectorAll("[data-tab-panel]")];
const firstRunWizard = document.querySelector("[data-first-run-wizard]");
const firstRunProjectSelect = document.querySelector("[data-first-run-project-select]");
const firstRunBookSelect = document.querySelector("[data-first-run-book-select]");
const firstRunNewBookTitle = document.querySelector("[data-first-run-new-book-title]");
const firstRunCreateBookButton = document.querySelector("[data-first-run-create-book]");
const firstRunScanButton = document.querySelector("[data-first-run-scan]");
const firstRunCancelButton = document.querySelector("[data-first-run-cancel]");
const firstRunStatus = document.querySelector("[data-first-run-status]");
const firstRunSummary = document.querySelector("[data-first-run-summary]");
const firstRunImportActions = document.querySelector("[data-first-run-import-actions]");
const firstRunImportButton = document.querySelector("[data-first-run-import]");
const firstRunImportCancelButton = document.querySelector("[data-first-run-import-cancel]");
const firstRunReplaceDialog = document.querySelector("[data-first-run-replace-dialog]");
const firstRunReplaceConfirm = document.querySelector("[data-first-run-replace-confirm]");
const firstRunReplaceCancel = document.querySelector("[data-first-run-replace-cancel]");
const officeStatus = document.querySelector("[data-office-status]");
const connectionStatus = document.querySelector("[data-connection-status]");
const summaryConnection = document.querySelector("[data-summary-connection]");
@ -148,6 +170,10 @@
let selectedLinkChapterId = null;
let pendingCreateChapterConfirmation = null;
let structurePreviewModel = null;
let firstRunDocumentBinding = null;
let firstRunAnalysis = null;
let firstRunImportStructure = null;
let isFirstRunImporting = false;
let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null;
let sceneTrackingMode = "Manual";
@ -933,6 +959,181 @@
return books.find((book) => book.bookId === bookId) || null;
};
const selectedFirstRunProject = () => {
const projectId = Number.parseInt(firstRunProjectSelect?.value || "", 10);
return projects.find((project) => project.projectId === projectId) || null;
};
const selectedFirstRunBook = () => {
const bookId = Number.parseInt(firstRunBookSelect?.value || "", 10);
return books.find((book) => book.bookId === bookId) || null;
};
const setFirstRunStatus = (message) => setText(firstRunStatus, message);
const updateFirstRunButtons = () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (firstRunScanButton) {
firstRunScanButton.disabled = !project || !book || isFirstRunImporting;
}
if (firstRunCreateBookButton) {
firstRunCreateBookButton.disabled = !project || !String(firstRunNewBookTitle?.value || "").trim() || isFirstRunImporting;
}
if (firstRunImportButton) {
firstRunImportButton.disabled = !firstRunAnalysis?.canImport || !firstRunImportStructure || isFirstRunImporting;
}
};
const setFirstRunMode = (visible) => {
setHidden(firstRunWizard, !visible);
setHidden(tabsNav, visible);
for (const panel of tabPanels) {
setHidden(panel, visible ? true : panel.dataset.tabPanel !== "scene" && !panel.classList.contains("is-active"));
}
if (!visible) {
restoreActiveTab();
}
};
const syncFirstRunProjectOptions = () => {
if (!firstRunProjectSelect) {
return;
}
if (projects.length === 0) {
resetSelect(firstRunProjectSelect, "No projects available.");
return;
}
addOptions(firstRunProjectSelect, "Choose a project", projects, "projectId", "title");
const currentProjectId = Number.parseInt(projectSelect?.value || "", 10);
if (Number.isInteger(currentProjectId) && currentProjectId > 0) {
firstRunProjectSelect.value = String(currentProjectId);
}
};
const syncFirstRunBookOptions = (preferredBookId = null) => {
if (!firstRunBookSelect) {
return;
}
if (books.length === 0) {
resetSelect(firstRunBookSelect, "No books available.");
return;
}
addOptions(firstRunBookSelect, "Choose a book", books.map((book) => ({
...book,
displayTitle: bookDisplayTitle(book)
})), "bookId", "displayTitle");
const currentBookId = preferredBookId || Number.parseInt(bookSelect?.value || "", 10);
const validBook = books.find((book) => book.bookId === currentBookId);
if (validBook) {
firstRunBookSelect.value = String(validBook.bookId);
}
updateFirstRunButtons();
};
const resetFirstRunPreview = (message = "Select a Project and Book to begin.") => {
firstRunAnalysis = null;
firstRunImportStructure = null;
setHidden(firstRunSummary, true);
setHidden(firstRunImportActions, true);
if (firstRunSummary) {
firstRunSummary.innerHTML = "";
}
setFirstRunStatus(message);
updateFirstRunButtons();
};
const renderFirstRunSummary = (analysis) => {
if (!firstRunSummary) {
return;
}
firstRunSummary.innerHTML = "";
const title = document.createElement("strong");
title.textContent = "Manuscript Analysis";
firstRunSummary.append(title);
const list = document.createElement("dl");
const rows = [
["Project", analysis.projectTitle],
["Book", bookDisplayTitle({ title: analysis.bookTitle, subtitle: analysis.bookSubtitle })],
["Chapters", Number(analysis.chapterCount || 0).toLocaleString()],
["Scenes", Number(analysis.sceneCount || 0).toLocaleString()],
["Words", Number(analysis.wordCount || 0).toLocaleString()]
];
for (const [label, value] of rows) {
const term = document.createElement("dt");
term.textContent = label;
const description = document.createElement("dd");
description.textContent = value || "-";
list.append(term, description);
}
firstRunSummary.append(list);
setHidden(firstRunSummary, false);
setHidden(firstRunImportActions, false);
setFirstRunStatus(analysis.message || "Preview generated.");
updateFirstRunButtons();
};
const readDocumentBinding = () => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
return null;
}
const documentGuid = String(settings.get(documentMetadataKeys.documentGuid) || "").trim();
const bookId = Number.parseInt(settings.get(documentMetadataKeys.bookId) || "", 10);
const projectId = Number.parseInt(settings.get(documentMetadataKeys.projectId) || "", 10);
const bindingVersion = Number.parseInt(settings.get(documentMetadataKeys.bindingVersion) || "", 10);
if (!documentGuid || !Number.isInteger(bookId) || bookId <= 0 || !Number.isInteger(projectId) || projectId <= 0) {
return null;
}
return {
documentGuid,
bookId,
projectId,
bindingVersion: Number.isInteger(bindingVersion) && bindingVersion > 0 ? bindingVersion : 1
};
};
const writeDocumentBinding = (binding) => new Promise((resolve, reject) => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
reject(new Error("Word document settings are unavailable."));
return;
}
settings.set(documentMetadataKeys.documentGuid, binding.documentGuid);
settings.set(documentMetadataKeys.bookId, String(binding.bookId));
settings.set(documentMetadataKeys.projectId, String(binding.projectId));
settings.set(documentMetadataKeys.bindingVersion, String(binding.bindingVersion || 1));
settings.saveAsync((result) => {
if (result.status === window.Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(result.error || new Error("Unable to save Word document metadata."));
}
});
});
const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) {
return firstRunDocumentBinding.documentGuid;
}
if (window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (char) =>
(Number(char) ^ Math.random() * 16 >> Number(char) / 4).toString(16));
};
const updateSummary = () => {
const project = selectedProject();
const book = selectedBook();
@ -1061,6 +1262,69 @@
};
};
const buildFirstRunImportStructure = (paragraphs, usesExplicitScenes) => {
const chapters = [];
let chapter = null;
let scene = null;
let totalWords = 0;
const separatorTexts = new Set(["***", "###"]);
const startScene = () => {
if (!chapter) {
return null;
}
const nextScene = {
title: `Scene ${chapter.scenes.length + 1}`,
sortOrder: (chapter.scenes.length + 1) * 10,
wordCount: 0
};
chapter.scenes.push(nextScene);
return nextScene;
};
paragraphs.forEach((paragraph) => {
const text = String(paragraph.text || "").trim();
if (!text) {
return;
}
if (isBuiltInHeading(paragraph, 1)) {
chapter = {
title: text,
sortOrder: (chapters.length + 1) * 10,
wordCount: 0,
scenes: []
};
chapters.push(chapter);
scene = startScene();
totalWords += countWords(text);
return;
}
if (!chapter || !scene) {
return;
}
if (usesExplicitScenes && separatorTexts.has(text)) {
scene = startScene();
return;
}
const words = countWords(text);
chapter.wordCount += words;
scene.wordCount += words;
totalWords += words;
});
return {
chapters,
chapterCount: chapters.length,
sceneCount: chapters.reduce((total, item) => total + item.scenes.length, 0),
wordCount: totalWords
};
};
const renderOutline = (structure) => {
if (!documentOutline) {
return;
@ -1918,6 +2182,204 @@
body: JSON.stringify(body)
});
const analyseFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (!project || !book) {
resetFirstRunPreview("Select a Project and Book to begin.");
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
resetFirstRunPreview("Word document access is unavailable.");
return;
}
if (firstRunScanButton) {
firstRunScanButton.disabled = true;
firstRunScanButton.textContent = "Scanning...";
}
setFirstRunStatus("Scanning manuscript structure...");
try {
const importStructure = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text,styleBuiltIn,style");
await context.sync();
return buildFirstRunImportStructure(bodyParagraphs.items, !!book.usesExplicitScenes);
});
firstRunImportStructure = importStructure;
const analysis = await postJson("/api/word-companion/manuscript/analyse", {
projectId: project.projectId,
bookId: book.bookId,
documentGuid: ensureDocumentGuid(),
chapterCount: importStructure.chapterCount,
sceneCount: importStructure.sceneCount,
wordCount: importStructure.wordCount
});
firstRunAnalysis = analysis;
renderFirstRunSummary(analysis);
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: "-",
heading1: String(importStructure.chapterCount),
heading2: "-"
});
} catch (error) {
console.error("Unable to scan manuscript for first-run import.", error);
resetFirstRunPreview(`Unable to scan manuscript: ${errorText(error)}`);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
} finally {
if (firstRunScanButton) {
firstRunScanButton.textContent = "Scan Manuscript";
}
updateFirstRunButtons();
}
};
const requestReplacePlannedConfirmation = () => new Promise((resolve) => {
if (!firstRunReplaceDialog || typeof firstRunReplaceDialog.showModal !== "function") {
resolve(window.confirm("This Book already contains planned chapters and scenes.\n\nImporting this manuscript will replace the planned structure.\n\nContinue?"));
return;
}
const cleanup = (confirmed) => {
firstRunReplaceConfirm?.removeEventListener("click", confirmHandler);
firstRunReplaceCancel?.removeEventListener("click", cancelHandler);
firstRunReplaceDialog?.removeEventListener("cancel", cancelEventHandler);
firstRunReplaceDialog?.close();
resolve(confirmed);
};
const confirmHandler = () => cleanup(true);
const cancelHandler = () => cleanup(false);
const cancelEventHandler = (event) => {
event.preventDefault();
cleanup(false);
};
firstRunReplaceConfirm?.addEventListener("click", confirmHandler);
firstRunReplaceCancel?.addEventListener("click", cancelHandler);
firstRunReplaceDialog?.addEventListener("cancel", cancelEventHandler);
firstRunReplaceDialog.showModal();
});
const importFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (!project || !book || !firstRunImportStructure || !firstRunAnalysis?.canImport) {
updateFirstRunButtons();
return;
}
let replacePlanned = false;
if (firstRunAnalysis.requiresReplaceConfirmation) {
replacePlanned = await requestReplacePlannedConfirmation();
if (!replacePlanned) {
setFirstRunStatus("Import cancelled.");
return;
}
}
isFirstRunImporting = true;
updateFirstRunButtons();
setFirstRunStatus("Importing manuscript structure...");
try {
const documentGuid = ensureDocumentGuid();
const response = await postJson("/api/word-companion/manuscript/import", {
projectId: project.projectId,
bookId: book.bookId,
documentGuid,
bindingVersion: 1,
replacePlanned,
chapters: firstRunImportStructure.chapters
});
if (!response.imported) {
firstRunAnalysis = {
...firstRunAnalysis,
canImport: !response.requiresReplaceConfirmation,
requiresReplaceConfirmation: !!response.requiresReplaceConfirmation,
message: response.message || firstRunAnalysis.message
};
setFirstRunStatus(response.message || "Import was not completed.");
updateFirstRunButtons();
return;
}
const document = response.manuscriptDocument;
await writeDocumentBinding({
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId,
projectId: response.projectId,
bindingVersion: document?.bindingVersion || 1
});
firstRunDocumentBinding = {
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId,
projectId: response.projectId,
bindingVersion: document?.bindingVersion || 1
};
writeStoredId(storageKeys.projectId, response.projectId);
writeStoredId(storageKeys.bookId, response.bookId);
if (projectSelect) {
projectSelect.value = String(response.projectId);
}
await loadBooks(response.projectId, response.bookId);
setFirstRunMode(false);
setConnectionStatus("Connected");
setDocumentMessage(response.message || "Manuscript structure imported.");
} catch (error) {
console.error("Unable to import manuscript structure.", error);
setFirstRunStatus(`Unable to import manuscript: ${errorText(error)}`);
} finally {
isFirstRunImporting = false;
updateFirstRunButtons();
}
};
const initialiseFirstRunBinding = async () => {
if (!isAuthenticated || !wordHostAvailable) {
setFirstRunMode(false);
return;
}
const binding = readDocumentBinding();
if (binding) {
try {
const response = await fetchJson(`/api/word-companion/manuscript/book/${binding.bookId}`);
const document = response?.manuscriptDocument;
if (document && String(document.documentGuid).toLowerCase() === binding.documentGuid.toLowerCase()) {
firstRunDocumentBinding = binding;
writeStoredId(storageKeys.projectId, binding.projectId);
writeStoredId(storageKeys.bookId, binding.bookId);
if (projectSelect) {
projectSelect.value = String(binding.projectId);
}
await loadBooks(binding.projectId, binding.bookId);
setFirstRunMode(false);
setConnectionStatus("Connected");
return;
}
} catch {
// If the saved binding no longer resolves, treat the document as unbound.
}
}
firstRunDocumentBinding = null;
syncFirstRunProjectOptions();
if (selectedFirstRunProject()) {
await loadBooks(selectedFirstRunProject().projectId, null);
} else {
resetSelect(firstRunBookSelect, "Select a project first");
}
resetFirstRunPreview("Select a Project and Book to begin.");
setFirstRunMode(true);
};
const patchJson = (url, body) => fetchJson(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@ -3079,6 +3541,7 @@
if (!projectId) {
books = [];
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunBookSelect, "Select a project first");
setBookMessage("");
writeStoredId(storageKeys.bookId, null);
updateSummary();
@ -3098,9 +3561,11 @@
if (books.length === 0) {
resetSelect(bookSelect, "No books available.");
resetSelect(firstRunBookSelect, "No books available.");
setBookMessage("No books available.");
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("No books available.");
return;
}
@ -3115,25 +3580,33 @@
} else {
writeStoredId(storageKeys.bookId, null);
}
syncFirstRunBookOptions(validBook?.bookId || preferredBookId);
updateSummary();
resetPlotDirectorScene(selectedBook() ? "Not detected" : "Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
} catch {
books = [];
resetSelect(bookSelect, "Unable to load books.");
resetSelect(firstRunBookSelect, "Unable to load books.");
setBookMessage("Unable to load books.");
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview("Unable to load books.");
resetFirstRunPreview("Unable to load books.");
}
};
const loadProjects = async () => {
resetSelect(projectSelect, "Loading projects...");
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunProjectSelect, "Loading projects...");
resetSelect(firstRunBookSelect, "Select a project first");
setProjectMessage("");
setBookMessage("");
@ -3143,35 +3616,45 @@
if (projects.length === 0) {
resetSelect(projectSelect, "No projects available.");
resetSelect(firstRunProjectSelect, "No projects available.");
setProjectMessage("No projects available.");
writeStoredId(storageKeys.projectId, null);
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("No projects available.");
return;
}
addOptions(projectSelect, "Choose a project", projects, "projectId", "title");
syncFirstRunProjectOptions();
const storedProjectId = readStoredId(storageKeys.projectId);
const validProject = projects.find((project) => project.projectId === storedProjectId);
if (validProject && projectSelect) {
projectSelect.value = String(validProject.projectId);
if (firstRunProjectSelect) {
firstRunProjectSelect.value = String(validProject.projectId);
}
writeStoredId(storageKeys.projectId, validProject.projectId);
await loadBooks(validProject.projectId, readStoredId(storageKeys.bookId));
} else {
writeStoredId(storageKeys.projectId, null);
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("Select a Project and Book to begin.");
}
} catch {
projects = [];
books = [];
resetSelect(projectSelect, "Unable to connect to PlotDirector.");
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunProjectSelect, "Unable to connect to PlotDirector.");
resetSelect(firstRunBookSelect, "Select a project first");
setConnectionStatus("Unable to connect to PlotDirector.");
setProjectMessage("Unable to connect to PlotDirector.");
writeStoredId(storageKeys.projectId, null);
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("Unable to connect to PlotDirector.");
}
};
@ -3182,23 +3665,94 @@
projectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(projectSelect.value || "", 10);
if (firstRunProjectSelect && Number.isInteger(projectId) && projectId > 0) {
firstRunProjectSelect.value = String(projectId);
}
writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null);
writeStoredId(storageKeys.bookId, null);
resetPlotDirectorScene("Not detected");
resetStructurePreview();
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
});
bookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(bookSelect.value || "", 10);
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
if (firstRunBookSelect && Number.isInteger(bookId) && bookId > 0) {
firstRunBookSelect.value = String(bookId);
}
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
});
firstRunProjectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(firstRunProjectSelect.value || "", 10);
if (projectSelect && Number.isInteger(projectId) && projectId > 0) {
projectSelect.value = String(projectId);
}
writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null);
writeStoredId(storageKeys.bookId, null);
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
});
firstRunBookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(firstRunBookSelect.value || "", 10);
if (bookSelect && Number.isInteger(bookId) && bookId > 0) {
bookSelect.value = String(bookId);
}
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
updateSummary();
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
});
firstRunNewBookTitle?.addEventListener("input", updateFirstRunButtons);
firstRunCreateBookButton?.addEventListener("click", async () => {
const project = selectedFirstRunProject();
const title = String(firstRunNewBookTitle?.value || "").trim();
if (!project || !title) {
updateFirstRunButtons();
return;
}
firstRunCreateBookButton.disabled = true;
setFirstRunStatus("Creating Book...");
try {
const response = await postJson(`/api/word-companion/projects/${project.projectId}/books`, { title });
if (firstRunNewBookTitle) {
firstRunNewBookTitle.value = "";
}
if (projectSelect) {
projectSelect.value = String(project.projectId);
}
await loadBooks(project.projectId, response.bookId);
if (firstRunBookSelect) {
firstRunBookSelect.value = String(response.bookId);
}
setFirstRunStatus("Ready to scan manuscript structure.");
} catch (error) {
console.error("Unable to create Book from Word Companion.", error);
setFirstRunStatus(`Unable to create Book: ${errorText(error)}`);
} finally {
updateFirstRunButtons();
}
});
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false));
firstRunImportCancelButton?.addEventListener("click", () => resetFirstRunPreview("Import cancelled."));
refreshDocumentButton?.addEventListener("click", refreshDocumentStructure);
analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure);
applyStructureSyncButton?.addEventListener("click", applyStructureSync);
@ -3237,11 +3791,12 @@
});
runDiagnosticsButton?.addEventListener("click", runDiagnostics);
if (isAuthenticated) {
loadProjects();
} else {
const projectsLoadPromise = isAuthenticated ? loadProjects() : Promise.resolve();
if (!isAuthenticated) {
resetSelect(projectSelect, "Please sign in to PlotDirector.");
resetSelect(bookSelect, "Please sign in to PlotDirector.");
resetSelect(firstRunProjectSelect, "Please sign in to PlotDirector.");
resetSelect(firstRunBookSelect, "Please sign in to PlotDirector.");
updateSummary();
}
@ -3259,8 +3814,10 @@
}
updateOfficeReadiness()
.then(() => {
.then(async () => {
setOfficeStatus("Word Companion ready");
await projectsLoadPromise;
await initialiseFirstRunBinding();
if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");

File diff suppressed because one or more lines are too long