Phase 20F Build the approved manuscript structure into a real PlotDirector project.

This commit is contained in:
Nick Beckley 2026-07-03 23:19:51 +01:00
parent f78e1ac8fc
commit 16ea855a3e
13 changed files with 742 additions and 3 deletions

View File

@ -24,6 +24,13 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
return model is null ? NotFound() : View(model);
}
[HttpGet("build-complete")]
public async Task<IActionResult> BuildComplete(Guid previewId)
{
var model = await onboarding.GetBuildResultAsync(previewId);
return model is null ? NotFound() : View(model);
}
[HttpPost("scan-review")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")

View File

@ -0,0 +1,99 @@
using System.Data;
using System.Text.Json;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IOnboardingBuildRepository
{
Task<OnboardingManuscriptBuildResult?> BuildAsync(int userId, OnboardingManuscriptBuildRequest request);
}
public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFactory) : IOnboardingBuildRepository
{
public async Task<OnboardingManuscriptBuildResult?> BuildAsync(int userId, OnboardingManuscriptBuildRequest request)
{
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.Onboarding_ManuscriptBuild_Create",
new
{
UserID = userId,
request.PreviewID,
request.ProjectID,
request.BookID,
request.DocumentGuid,
BindingVersion = 1,
ChaptersJson = JsonSerializer.Serialize(request.Chapters),
ScenesJson = JsonSerializer.Serialize(request.Scenes),
CharactersJson = JsonSerializer.Serialize(request.Characters)
},
commandType: CommandType.StoredProcedure);
var header = await result.ReadSingleOrDefaultAsync<OnboardingManuscriptBuildResult>();
if (header is null)
{
return null;
}
var chapters = (await result.ReadAsync<OnboardingBuildChapterMap>()).ToList();
var scenes = (await result.ReadAsync<OnboardingBuildSceneMap>()).ToList();
var characters = (await result.ReadAsync<OnboardingBuildCharacterMap>()).ToList();
return new OnboardingManuscriptBuildResult
{
BuildID = header.BuildID,
PreviewID = header.PreviewID,
ProjectID = header.ProjectID,
BookID = header.BookID,
ManuscriptDocumentID = header.ManuscriptDocumentID,
Status = header.Status,
MarkerStatus = header.MarkerStatus,
ChaptersCreated = header.ChaptersCreated,
ScenesCreated = header.ScenesCreated,
CharactersCreated = header.CharactersCreated,
CharactersReused = header.CharactersReused,
SceneAppearancesCreated = header.SceneAppearancesCreated,
AlreadyBuilt = header.AlreadyBuilt,
Message = header.Message,
ChapterMappings = chapters,
SceneMappings = scenes,
CharacterMappings = characters
};
}
}
public sealed class OnboardingManuscriptBuildRequest
{
public Guid PreviewID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public Guid DocumentGuid { get; init; }
public IReadOnlyList<OnboardingBuildChapterRequest> Chapters { get; init; } = [];
public IReadOnlyList<OnboardingBuildSceneRequest> Scenes { get; init; } = [];
public IReadOnlyList<OnboardingBuildCharacterRequest> Characters { get; init; } = [];
}
public sealed class OnboardingBuildChapterRequest
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public int SortOrder { get; init; }
public int WordCount { get; init; }
}
public sealed class OnboardingBuildSceneRequest
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public int SortOrder { get; init; }
public int WordCount { get; init; }
}
public sealed class OnboardingBuildCharacterRequest
{
public string TemporaryCharacterKey { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public int? ExistingCharacterID { get; init; }
}

View File

@ -11,7 +11,8 @@ namespace PlotLine.Hubs;
public sealed class WordCompanionFollowHub(
IWordCompanionPresenceService presence,
IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore) : Hub
IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService) : Hub
{
public override async Task OnConnectedAsync()
{
@ -126,6 +127,35 @@ public sealed class WordCompanionFollowHub(
return scanState;
}
public async Task<OnboardingManuscriptBuildResult> StartOnboardingProjectBuild(Guid previewId)
{
var userId = RequireUserId();
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Running",
Message = "Creating chapters...",
PercentComplete = 0,
PreviewID = previewId
});
var result = await onboardingService.BuildApprovedStructureAsync(
previewId,
progress => Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress));
if (result is null)
{
throw new HubException("The reviewed scan could not be found.");
}
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
if (!string.IsNullOrWhiteSpace(companionConnectionId))
{
await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result);
}
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildCompleted", result);
return result;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var status = await presence.DisconnectAsync(Context.ConnectionId);

View File

@ -153,3 +153,56 @@ public sealed class ManuscriptScanCharacterReviewDecision
public string Category { get; init; } = "PossibleCharacter";
public int? ExistingCharacterID { get; init; }
}
public sealed class OnboardingManuscriptBuildResult
{
public int BuildID { get; init; }
public Guid PreviewID { get; init; }
public int ProjectID { get; init; }
public int BookID { get; init; }
public int? ManuscriptDocumentID { get; init; }
public string Status { get; init; } = "Built";
public string MarkerStatus { get; init; } = "Pending";
public int ChaptersCreated { get; init; }
public int ScenesCreated { get; init; }
public int CharactersCreated { get; init; }
public int CharactersReused { get; init; }
public int SceneAppearancesCreated { get; init; }
public bool AlreadyBuilt { get; init; }
public string Message { get; init; } = string.Empty;
public IReadOnlyList<OnboardingBuildChapterMap> ChapterMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildSceneMap> SceneMappings { get; init; } = [];
public IReadOnlyList<OnboardingBuildCharacterMap> CharacterMappings { get; init; } = [];
public string? MarkerWarning { get; init; }
}
public sealed class OnboardingBuildChapterMap
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterID { get; init; }
}
public sealed class OnboardingBuildSceneMap
{
public string TemporarySceneKey { get; init; } = string.Empty;
public int SceneID { get; init; }
}
public sealed class OnboardingBuildCharacterMap
{
public string TemporaryCharacterKey { get; init; } = string.Empty;
public int CharacterID { get; init; }
public bool Created { get; init; }
}
public sealed class OnboardingBuildProgress
{
public string Status { get; init; } = "Running";
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChaptersCreated { get; init; }
public int ScenesCreated { get; init; }
public int CharactersCreated { get; init; }
public int SceneAppearancesCreated { get; init; }
public Guid? PreviewID { get; init; }
}

View File

@ -132,6 +132,7 @@ public class Program
builder.Services.AddScoped<IImportRepository, ImportRepository>();
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
builder.Services.AddScoped<IProjectAccessRepository, ProjectAccessRepository>();
builder.Services.AddScoped<IProjectCollaborationRepository, ProjectCollaborationRepository>();
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();

View File

@ -15,6 +15,9 @@ public interface IManuscriptScanPreviewStore
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId);
Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result);
}
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
@ -22,6 +25,8 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanReviewDecision> reviews = new();
private readonly ConcurrentDictionary<Guid, OnboardingManuscriptBuildResult> buildResults = new();
private readonly ConcurrentDictionary<Guid, OnboardingBuildProgress> buildProgress = new();
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
{
@ -173,6 +178,46 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
}
public Task<OnboardingBuildProgress> BuildProgressAsync(int userId, Guid previewId, string message, int percentComplete, OnboardingManuscriptBuildResult? result = null)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult(new OnboardingBuildProgress { Status = "Failed", Message = "The scan preview could not be found." });
}
var progress = new OnboardingBuildProgress
{
Status = result is null ? "Running" : "Complete",
Message = message,
PercentComplete = percentComplete,
ChaptersCreated = result?.ChaptersCreated ?? 0,
ScenesCreated = result?.ScenesCreated ?? 0,
CharactersCreated = (result?.CharactersCreated ?? 0) + (result?.CharactersReused ?? 0),
SceneAppearancesCreated = result?.SceneAppearancesCreated ?? 0,
PreviewID = previewId
};
buildProgress[previewId] = progress;
return Task.FromResult(progress);
}
public Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview)
&& preview.UserID == userId
&& buildResults.TryGetValue(previewId, out var result)
? result
: null);
public Task<OnboardingManuscriptBuildResult?> SaveBuildResultAsync(int userId, OnboardingManuscriptBuildResult result)
{
if (!previews.TryGetValue(result.PreviewID, out var preview) || preview.UserID != userId)
{
return Task.FromResult<OnboardingManuscriptBuildResult?>(null);
}
buildResults[result.PreviewID] = result;
return Task.FromResult<OnboardingManuscriptBuildResult?>(result);
}
private static ManuscriptScanState ToState(ScanSession session)
=> new()
{

View File

@ -10,6 +10,8 @@ public interface IOnboardingService
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> ShouldShowWordCompanionHeaderAsync();
@ -31,6 +33,7 @@ public sealed class OnboardingService(
IWordCompanionPresenceService companionPresence,
IManuscriptScanPreviewStore scanPreviews,
ICharacterRepository characters,
IOnboardingBuildRepository builds,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -184,6 +187,85 @@ public sealed class OnboardingService(
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
}
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
{
var context = await GetScanReviewContextAsync(previewId);
if (context is null)
{
return null;
}
var (userId, preview, review, _, _, _) = context.Value;
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
throw new InvalidOperationException("Review the scan and choose Save and continue before building the project structure.");
}
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
ValidateScanReview(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
var chapterKeys = chapterDecisions.Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var sceneDecisions = review.Scenes
.Where(scene => scene.Include && chapterKeys.Contains(scene.TemporaryChapterKey))
.OrderBy(scene => chapterDecisions.FindIndex(chapter => chapter.TemporaryChapterKey == scene.TemporaryChapterKey))
.ThenBy(scene => scene.SceneNumberWithinChapter)
.ToList();
var characterDecisions = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.ToList();
await PublishBuildProgress(userId, preview.PreviewID, "Creating scenes...", 35, progress);
var request = new OnboardingManuscriptBuildRequest
{
PreviewID = preview.PreviewID,
ProjectID = preview.ProjectID,
BookID = preview.BookID,
DocumentGuid = Guid.TryParse(preview.CompanionDocumentIdentifier, out var documentGuid) ? documentGuid : Guid.NewGuid(),
Chapters = chapterDecisions.Select((chapter, index) => new OnboardingBuildChapterRequest
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
Title = chapter.Title,
SortOrder = (index + 1) * 10,
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
}).ToList(),
Scenes = sceneDecisions.Select((scene, index) => new OnboardingBuildSceneRequest
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title!,
SortOrder = (index + 1) * 10,
WordCount = preview.Scenes.FirstOrDefault(item => item.TemporarySceneKey == scene.TemporarySceneKey)?.WordCount ?? 0
}).ToList(),
Characters = characterDecisions.Select(character => new OnboardingBuildCharacterRequest
{
TemporaryCharacterKey = character.TemporaryCharacterKey,
Name = character.Name,
ExistingCharacterID = character.ExistingCharacterID
}).ToList()
};
await PublishBuildProgress(userId, preview.PreviewID, "Creating characters...", 60, progress);
var result = await builds.BuildAsync(userId, request)
?? throw new InvalidOperationException("The project structure could not be built. Check the review choices and try again.");
await scanPreviews.SaveBuildResultAsync(userId, result);
await PublishBuildProgress(userId, preview.PreviewID, "Finalising project...", 90, progress);
await PublishBuildProgress(userId, preview.PreviewID, result.Message, 100, progress, result);
return result;
}
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func<OnboardingBuildProgress, Task>? publish, OnboardingManuscriptBuildResult? result = null)
{
var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result);
if (publish is not null)
{
await publish(state);
}
}
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
{
var userId = RequireUserId();

View File

@ -0,0 +1,296 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.OnboardingManuscriptBuilds', N'U') IS NULL
BEGIN
CREATE TABLE dbo.OnboardingManuscriptBuilds
(
OnboardingManuscriptBuildID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_OnboardingManuscriptBuilds PRIMARY KEY,
PreviewID uniqueidentifier NOT NULL,
UserID int NOT NULL,
ProjectID int NOT NULL,
BookID int NOT NULL,
ManuscriptDocumentID int NULL,
Status nvarchar(40) NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_Status DEFAULT N'Built',
MarkerStatus nvarchar(40) NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_MarkerStatus DEFAULT N'Pending',
ChaptersCreated int NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_ChaptersCreated DEFAULT 0,
ScenesCreated int NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_ScenesCreated DEFAULT 0,
CharactersCreated int NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_CharactersCreated DEFAULT 0,
CharactersReused int NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_CharactersReused DEFAULT 0,
SceneAppearancesCreated int NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_SceneAppearancesCreated DEFAULT 0,
CreatedUtc datetime2 NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_CreatedUtc DEFAULT SYSUTCDATETIME(),
UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_OnboardingManuscriptBuilds_UpdatedUtc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_OnboardingManuscriptBuilds_PreviewID UNIQUE (PreviewID),
CONSTRAINT FK_OnboardingManuscriptBuilds_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID),
CONSTRAINT FK_OnboardingManuscriptBuilds_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID),
CONSTRAINT FK_OnboardingManuscriptBuilds_Books FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID),
CONSTRAINT FK_OnboardingManuscriptBuilds_ManuscriptDocuments FOREIGN KEY (ManuscriptDocumentID) REFERENCES dbo.ManuscriptDocuments(ManuscriptDocumentID)
);
END;
GO
IF OBJECT_ID(N'dbo.OnboardingManuscriptBuildChapterMap', N'U') IS NULL
BEGIN
CREATE TABLE dbo.OnboardingManuscriptBuildChapterMap
(
OnboardingManuscriptBuildID int NOT NULL,
TemporaryChapterKey nvarchar(80) NOT NULL,
ChapterID int NOT NULL,
CONSTRAINT PK_OnboardingManuscriptBuildChapterMap PRIMARY KEY (OnboardingManuscriptBuildID, TemporaryChapterKey),
CONSTRAINT FK_OnboardingManuscriptBuildChapterMap_Build FOREIGN KEY (OnboardingManuscriptBuildID) REFERENCES dbo.OnboardingManuscriptBuilds(OnboardingManuscriptBuildID),
CONSTRAINT FK_OnboardingManuscriptBuildChapterMap_Chapters FOREIGN KEY (ChapterID) REFERENCES dbo.Chapters(ChapterID)
);
END;
GO
IF OBJECT_ID(N'dbo.OnboardingManuscriptBuildSceneMap', N'U') IS NULL
BEGIN
CREATE TABLE dbo.OnboardingManuscriptBuildSceneMap
(
OnboardingManuscriptBuildID int NOT NULL,
TemporarySceneKey nvarchar(80) NOT NULL,
SceneID int NOT NULL,
CONSTRAINT PK_OnboardingManuscriptBuildSceneMap PRIMARY KEY (OnboardingManuscriptBuildID, TemporarySceneKey),
CONSTRAINT FK_OnboardingManuscriptBuildSceneMap_Build FOREIGN KEY (OnboardingManuscriptBuildID) REFERENCES dbo.OnboardingManuscriptBuilds(OnboardingManuscriptBuildID),
CONSTRAINT FK_OnboardingManuscriptBuildSceneMap_Scenes FOREIGN KEY (SceneID) REFERENCES dbo.Scenes(SceneID)
);
END;
GO
IF OBJECT_ID(N'dbo.OnboardingManuscriptBuildCharacterMap', N'U') IS NULL
BEGIN
CREATE TABLE dbo.OnboardingManuscriptBuildCharacterMap
(
OnboardingManuscriptBuildID int NOT NULL,
TemporaryCharacterKey nvarchar(80) NOT NULL,
CharacterID int NOT NULL,
Created bit NOT NULL,
CONSTRAINT PK_OnboardingManuscriptBuildCharacterMap PRIMARY KEY (OnboardingManuscriptBuildID, TemporaryCharacterKey),
CONSTRAINT FK_OnboardingManuscriptBuildCharacterMap_Build FOREIGN KEY (OnboardingManuscriptBuildID) REFERENCES dbo.OnboardingManuscriptBuilds(OnboardingManuscriptBuildID),
CONSTRAINT FK_OnboardingManuscriptBuildCharacterMap_Characters FOREIGN KEY (CharacterID) REFERENCES dbo.Characters(CharacterID)
);
END;
GO
CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_Create
@UserID int,
@PreviewID uniqueidentifier,
@ProjectID int,
@BookID int,
@DocumentGuid uniqueidentifier,
@BindingVersion int = 1,
@ChaptersJson nvarchar(max),
@ScenesJson nvarchar(max),
@CharactersJson 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 @ExistingBuildID int;
SELECT @ExistingBuildID = OnboardingManuscriptBuildID
FROM dbo.OnboardingManuscriptBuilds
WHERE PreviewID = @PreviewID AND UserID = @UserID;
IF @ExistingBuildID IS NOT NULL
BEGIN
SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus,
ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated,
CAST(1 AS bit) AS AlreadyBuilt,
N'This manuscript has already been built into PlotDirector.' AS Message
FROM dbo.OnboardingManuscriptBuilds
WHERE OnboardingManuscriptBuildID = @ExistingBuildID;
SELECT TemporaryChapterKey, ChapterID FROM dbo.OnboardingManuscriptBuildChapterMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY TemporaryChapterKey;
SELECT TemporarySceneKey, SceneID FROM dbo.OnboardingManuscriptBuildSceneMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY TemporarySceneKey;
SELECT TemporaryCharacterKey, CharacterID, Created FROM dbo.OnboardingManuscriptBuildCharacterMap WHERE OnboardingManuscriptBuildID = @ExistingBuildID ORDER BY TemporaryCharacterKey;
RETURN;
END;
IF NOT EXISTS
(
SELECT 1
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
)
RETURN;
DECLARE @Chapters table (TemporaryChapterKey nvarchar(80) NOT NULL PRIMARY KEY, Title nvarchar(200) NOT NULL, SortOrder int NOT NULL, WordCount int NOT NULL);
DECLARE @Scenes table (TemporarySceneKey nvarchar(80) NOT NULL PRIMARY KEY, TemporaryChapterKey nvarchar(80) NOT NULL, Title nvarchar(200) NOT NULL, SortOrder int NOT NULL, WordCount int NOT NULL);
DECLARE @Characters table (TemporaryCharacterKey nvarchar(80) NOT NULL PRIMARY KEY, Name nvarchar(200) NOT NULL, ExistingCharacterID int NULL);
INSERT @Chapters (TemporaryChapterKey, Title, SortOrder, WordCount)
SELECT LEFT(JSON_VALUE(value, '$.TemporaryChapterKey'), 80),
LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), 200),
TRY_CONVERT(int, JSON_VALUE(value, '$.SortOrder')),
COALESCE(TRY_CONVERT(int, JSON_VALUE(value, '$.WordCount')), 0)
FROM OPENJSON(@ChaptersJson)
WHERE NULLIF(JSON_VALUE(value, '$.TemporaryChapterKey'), N'') IS NOT NULL
AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), N'') IS NOT NULL;
INSERT @Scenes (TemporarySceneKey, TemporaryChapterKey, Title, SortOrder, WordCount)
SELECT LEFT(JSON_VALUE(value, '$.TemporarySceneKey'), 80),
LEFT(JSON_VALUE(value, '$.TemporaryChapterKey'), 80),
LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), 200),
TRY_CONVERT(int, JSON_VALUE(value, '$.SortOrder')),
COALESCE(TRY_CONVERT(int, JSON_VALUE(value, '$.WordCount')), 0)
FROM OPENJSON(@ScenesJson)
WHERE NULLIF(JSON_VALUE(value, '$.TemporarySceneKey'), N'') IS NOT NULL
AND NULLIF(JSON_VALUE(value, '$.TemporaryChapterKey'), N'') IS NOT NULL
AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Title'))), N'') IS NOT NULL;
INSERT @Characters (TemporaryCharacterKey, Name, ExistingCharacterID)
SELECT LEFT(JSON_VALUE(value, '$.TemporaryCharacterKey'), 80),
LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), 200),
TRY_CONVERT(int, JSON_VALUE(value, '$.ExistingCharacterID'))
FROM OPENJSON(@CharactersJson)
WHERE NULLIF(JSON_VALUE(value, '$.TemporaryCharacterKey'), N'') IS NOT NULL
AND NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), N'') IS NOT NULL;
IF NOT EXISTS (SELECT 1 FROM @Chapters) OR EXISTS (SELECT 1 FROM @Scenes s WHERE NOT EXISTS (SELECT 1 FROM @Chapters c WHERE c.TemporaryChapterKey = s.TemporaryChapterKey))
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;
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);
DECLARE @ChapterMap table (TemporaryChapterKey nvarchar(80) NOT NULL PRIMARY KEY, ChapterID int NOT NULL);
DECLARE @SceneMap table (TemporarySceneKey nvarchar(80) NOT NULL PRIMARY KEY, SceneID int NOT NULL);
DECLARE @CharacterMap table (TemporaryCharacterKey nvarchar(80) NOT NULL PRIMARY KEY, CharacterID int NOT NULL, Created bit NOT NULL);
DECLARE @BuildID int;
DECLARE @ManuscriptDocumentID int;
DECLARE @LinkedUtc datetime2 = SYSUTCDATETIME();
BEGIN TRANSACTION;
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;
INSERT dbo.OnboardingManuscriptBuilds (PreviewID, UserID, ProjectID, BookID, ManuscriptDocumentID)
VALUES (@PreviewID, @UserID, @ProjectID, @BookID, @ManuscriptDocumentID);
SET @BuildID = CAST(SCOPE_IDENTITY() AS int);
DECLARE @TempChapterKey nvarchar(80), @Title nvarchar(200), @SortOrder int, @WordCount int, @ChapterID int, @ChapterNumber decimal(9,2);
DECLARE chapter_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TemporaryChapterKey, Title, SortOrder, WordCount FROM @Chapters ORDER BY SortOrder, TemporaryChapterKey;
OPEN chapter_cursor;
FETCH NEXT FROM chapter_cursor INTO @TempChapterKey, @Title, @SortOrder, @WordCount;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @ChapterNumber = (SELECT COUNT(*) + 1 FROM @ChapterMap);
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, Summary, SortOrder, RevisionStatusID, IsLinkedToManuscript, ManuscriptDocumentID, ExternalReference, LinkedUtc)
VALUES (@BookID, @ChapterNumber, @Title, NULL, @SortOrder, @PlannedRevisionStatusID, 1, @ManuscriptDocumentID, @TempChapterKey, @LinkedUtc);
SET @ChapterID = CAST(SCOPE_IDENTITY() AS int);
INSERT @ChapterMap VALUES (@TempChapterKey, @ChapterID);
INSERT dbo.OnboardingManuscriptBuildChapterMap VALUES (@BuildID, @TempChapterKey, @ChapterID);
FETCH NEXT FROM chapter_cursor INTO @TempChapterKey, @Title, @SortOrder, @WordCount;
END;
CLOSE chapter_cursor; DEALLOCATE chapter_cursor;
DECLARE @TempSceneKey nvarchar(80), @TempSceneChapterKey nvarchar(80), @SceneID int, @SceneNumber decimal(9,2);
DECLARE scene_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT TemporarySceneKey, TemporaryChapterKey, Title, SortOrder, WordCount FROM @Scenes ORDER BY TemporaryChapterKey, SortOrder, TemporarySceneKey;
OPEN scene_cursor;
FETCH NEXT FROM scene_cursor INTO @TempSceneKey, @TempSceneChapterKey, @Title, @SortOrder, @WordCount;
WHILE @@FETCH_STATUS = 0
BEGIN
SELECT @ChapterID = ChapterID FROM @ChapterMap WHERE TemporaryChapterKey = @TempSceneChapterKey;
SET @SceneNumber = (SELECT COUNT(*) + 1 FROM @SceneMap sm INNER JOIN @Scenes s ON s.TemporarySceneKey = sm.TemporarySceneKey WHERE s.TemporaryChapterKey = @TempSceneChapterKey);
IF @SceneNumber = 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 = @SceneNumber, SceneTitle = @Title, SortOrder = @SortOrder, IsLinkedToManuscript = 1,
ManuscriptDocumentID = @ManuscriptDocumentID, ExternalReference = @TempSceneKey, 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, @SceneNumber, @Title, NULL, @SortOrder, @DefaultTimeModeID, @DefaultTimeConfidenceID, @PlannedRevisionStatusID, 1, @ManuscriptDocumentID, @TempSceneKey, @LinkedUtc);
SET @SceneID = CAST(SCOPE_IDENTITY() AS int);
END;
IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID)
UPDATE dbo.SceneWorkflow SET ActualWordCount = @WordCount, UpdatedDate = SYSUTCDATETIME() WHERE SceneID = @SceneID;
ELSE
INSERT dbo.SceneWorkflow (SceneID, ActualWordCount) VALUES (@SceneID, @WordCount);
INSERT @SceneMap VALUES (@TempSceneKey, @SceneID);
INSERT dbo.OnboardingManuscriptBuildSceneMap VALUES (@BuildID, @TempSceneKey, @SceneID);
FETCH NEXT FROM scene_cursor INTO @TempSceneKey, @TempSceneChapterKey, @Title, @SortOrder, @WordCount;
END;
CLOSE scene_cursor; DEALLOCATE scene_cursor;
DECLARE @TempCharacterKey nvarchar(80), @ExistingCharacterID int, @CharacterID int, @Created bit;
DECLARE character_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT TemporaryCharacterKey, Name, ExistingCharacterID FROM @Characters ORDER BY Name, TemporaryCharacterKey;
OPEN character_cursor;
FETCH NEXT FROM character_cursor INTO @TempCharacterKey, @Title, @ExistingCharacterID;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @Created = 0;
SELECT @CharacterID = CharacterID FROM dbo.Characters WHERE CharacterID = @ExistingCharacterID AND ProjectID = @ProjectID AND IsArchived = 0;
IF @CharacterID IS NULL
SELECT TOP (1) @CharacterID = CharacterID FROM dbo.Characters WHERE ProjectID = @ProjectID AND IsArchived = 0 AND UPPER(LTRIM(RTRIM(CharacterName))) = UPPER(@Title);
IF @CharacterID IS NULL
BEGIN
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, CharacterImportance, ShowInQuickAddBar, DefaultDescription)
VALUES (@ProjectID, @Title, NULL, 8, 1, NULL);
SET @CharacterID = CAST(SCOPE_IDENTITY() AS int);
SET @Created = 1;
END;
INSERT @CharacterMap VALUES (@TempCharacterKey, @CharacterID, @Created);
INSERT dbo.OnboardingManuscriptBuildCharacterMap VALUES (@BuildID, @TempCharacterKey, @CharacterID, @Created);
SET @CharacterID = NULL;
FETCH NEXT FROM character_cursor INTO @TempCharacterKey, @Title, @ExistingCharacterID;
END;
CLOSE character_cursor; DEALLOCATE character_cursor;
UPDATE dbo.OnboardingManuscriptBuilds
SET ChaptersCreated = (SELECT COUNT(*) FROM @ChapterMap),
ScenesCreated = (SELECT COUNT(*) FROM @SceneMap),
CharactersCreated = (SELECT COUNT(*) FROM @CharacterMap WHERE Created = 1),
CharactersReused = (SELECT COUNT(*) FROM @CharacterMap WHERE Created = 0),
UpdatedUtc = SYSUTCDATETIME()
WHERE OnboardingManuscriptBuildID = @BuildID;
COMMIT TRANSACTION;
SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus,
ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated,
CAST(0 AS bit) AS AlreadyBuilt,
N'Your project structure has been created.' AS Message
FROM dbo.OnboardingManuscriptBuilds WHERE OnboardingManuscriptBuildID = @BuildID;
SELECT TemporaryChapterKey, ChapterID FROM dbo.OnboardingManuscriptBuildChapterMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporaryChapterKey;
SELECT TemporarySceneKey, SceneID FROM dbo.OnboardingManuscriptBuildSceneMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporarySceneKey;
SELECT TemporaryCharacterKey, CharacterID, Created FROM dbo.OnboardingManuscriptBuildCharacterMap WHERE OnboardingManuscriptBuildID = @BuildID ORDER BY TemporaryCharacterKey;
END;
GO

View File

@ -0,0 +1,43 @@
@model OnboardingManuscriptBuildResult
@{
ViewData["Title"] = "Project structure created";
}
<section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<div class="onboarding-copy">
<p class="eyebrow">Build Project</p>
<h1 id="build-complete-title">Your project structure has been created.</h1>
<p>@(Model.AlreadyBuilt ? "This manuscript has already been built into PlotDirector." : "PlotDirector has created the approved chapters, scenes and characters.")</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
{
<p class="text-warning">@Model.MarkerWarning</p>
}
</div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.ChaptersCreated</strong>
</div>
<div>
<span>Scenes</span>
<strong>@Model.ScenesCreated</strong>
</div>
<div>
<span>Characters</span>
<strong>@(Model.CharactersCreated + Model.CharactersReused)</strong>
</div>
<div>
<span>Scene appearances</span>
<strong>@Model.SceneAppearancesCreated</strong>
</div>
</div>
<div class="onboarding-actions">
<a class="btn btn-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Continue to Story Intelligence</a>
</div>
</div>
</section>

View File

@ -315,7 +315,14 @@
</div>
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
<p class="onboarding-scan-next">Next: import into PlotDirector.</p>
<p class="onboarding-scan-next">Next: build project structure.</p>
<button class="btn btn-success"
type="button"
data-onboarding-build-start
data-preview-id="@Model.ScanState.PreviewID">
Build Project
</button>
<p class="onboarding-scan-next" data-onboarding-build-message></p>
}
</section>
</div>

View File

@ -199,6 +199,7 @@
let firstRunAnalysis = null;
let firstRunImportStructure = null;
let firstRunCharacterCandidatesModel = [];
let lastOnboardingScanPreview = null;
let firstRunStructureImported = false;
let firstRunCharacterStepActive = false;
let firstRunCharacterStepActivatedAt = 0;
@ -1254,6 +1255,9 @@
companionPresenceConnection.on("ScanCurrentDocument", (command) => {
void runOnboardingDocumentScan(command);
});
companionPresenceConnection.on("UpdateOnboardingBuildMarkers", (result) => {
void updateOnboardingBuildMarkers(result);
});
companionPresenceConnection.onclose(() => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
@ -2112,6 +2116,7 @@
preview.characterCandidateCount = 0;
}
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
lastOnboardingScanPreview = preview;
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
} catch (error) {
console.error("Unable to complete onboarding scan.", error);
@ -2119,6 +2124,41 @@
}
};
const updateOnboardingBuildMarkers = async (result) => {
if (!lastOnboardingScanPreview || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
const chapterMap = new Map((result?.chapterMappings || result?.ChapterMappings || [])
.map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID]));
const sceneMap = new Map((result?.sceneMappings || result?.SceneMappings || [])
.map((item) => [item.temporarySceneKey || item.TemporarySceneKey, item.sceneID || item.SceneID]));
await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
for (const chapter of lastOnboardingScanPreview.chapters || []) {
const chapterId = chapterMap.get(chapter.temporaryChapterKey);
const index = Number.parseInt(chapter.startPosition, 10);
if (chapterId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
}
}
for (const scene of lastOnboardingScanPreview.scenes || []) {
const sceneId = sceneMap.get(scene.temporarySceneKey);
const index = Number.parseInt(scene.startPosition, 10);
if (sceneId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
}
}
await context.sync();
});
};
const friendlyScanError = (error) => {
const message = errorText(error);
if (/No chapters were detected/i.test(message)) {

File diff suppressed because one or more lines are too long

View File

@ -148,6 +148,19 @@
connection.on("OnboardingScanProgress", applyScanState);
connection.on("OnboardingScanCompleted", applyScanState);
connection.on("OnboardingScanFailed", applyScanState);
connection.on("OnboardingBuildProgress", (state) => {
const message = field(state, "message", "Message") || "Building project structure...";
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
const percent = field(state, "percentComplete", "PercentComplete");
node.textContent = percent === null || percent === undefined ? message : `${message} ${percent}%`;
});
});
connection.on("OnboardingBuildCompleted", (result) => {
const previewId = field(result, "previewID", "PreviewID") || field(result, "previewId", "PreviewId");
if (previewId) {
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(previewId)}`;
}
});
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
@ -172,4 +185,27 @@
}
});
});
document.querySelectorAll("[data-onboarding-build-start]").forEach((button) => {
button.addEventListener("click", async () => {
const previewId = button.dataset.previewId;
if (!previewId) {
return;
}
button.disabled = true;
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Creating chapters...";
});
try {
const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
const returnedPreviewId = field(result, "previewID", "PreviewID") || previewId;
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(returnedPreviewId)}`;
} catch (error) {
button.disabled = false;
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = error?.message || "The project structure could not be built. Check the review and try again.";
});
}
});
});
})();