Phase 16D - Major Character Discovery During Manuscript Import

This commit is contained in:
Nick Beckley 2026-06-27 20:08:19 +01:00
parent 33e8f0857c
commit ac300a94c8
10 changed files with 543 additions and 8 deletions

View File

@ -129,4 +129,18 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
var response = await wordCompanion.ImportManuscriptAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/discover-characters")]
public async Task<IActionResult> DiscoverCharacters(WordCompanionDiscoverCharactersRequest request)
{
var response = await wordCompanion.DiscoverCharacterCandidatesAsync(request);
return response is null ? BadRequest() : Ok(response);
}
[HttpPost("manuscript/create-discovered-characters")]
public async Task<IActionResult> CreateDiscoveredCharacters(WordCompanionCreateDiscoveredCharactersRequest request)
{
var response = await wordCompanion.CreateDiscoveredCharactersAsync(request);
return response is null ? BadRequest() : Ok(response);
}
}

View File

@ -21,6 +21,8 @@ public interface IWordCompanionRepository
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);
Task<bool> HasProjectAccessAsync(int projectId, int userId);
Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request);
}
public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository
@ -303,6 +305,49 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
};
}
public async Task<bool> HasProjectAccessAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.WordCompanion_Project_HasAccess",
new { ProjectID = projectId, UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(int userId, WordCompanionCreateDiscoveredCharactersRequest request)
{
var names = request.CandidateNames
.Select(name => new { Name = name })
.ToList();
using var connection = connectionFactory.CreateConnection();
using var result = await connection.QueryMultipleAsync(
"dbo.WordCompanion_Character_CreateDiscovered",
new
{
UserID = userId,
request.ProjectId,
CandidatesJson = System.Text.Json.JsonSerializer.Serialize(names)
},
commandType: CommandType.StoredProcedure);
var header = await result.ReadSingleOrDefaultAsync<WordCompanionCreateDiscoveredCharactersResponse>();
if (header is null)
{
return null;
}
var characters = (await result.ReadAsync<WordCompanionCreatedCharacterDto>()).ToList();
return new WordCompanionCreateDiscoveredCharactersResponse
{
ProjectId = header.ProjectId,
CreatedCount = header.CreatedCount,
SkippedCount = header.SkippedCount,
Message = header.Message,
Characters = characters
};
}
private static string ToCsv(IEnumerable<int> ids) => string.Join(',', ids.Where(id => id > 0).Distinct());
private sealed class WordCompanionSceneStructureRow

View File

@ -333,3 +333,43 @@ public sealed class WordCompanionManuscriptImportResponse
public int ArchivedScenes { get; init; }
public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; }
}
public sealed class WordCompanionCharacterCandidateDto
{
public string Text { get; init; } = string.Empty;
public int MentionCount { get; init; }
}
public sealed class WordCompanionDiscoverCharactersRequest
{
public int ProjectId { get; set; }
public string? DocumentText { get; set; }
}
public sealed class WordCompanionDiscoverCharactersResponse
{
public int ProjectId { get; init; }
public IReadOnlyList<WordCompanionCharacterCandidateDto> Candidates { get; init; } = [];
}
public sealed class WordCompanionCreateDiscoveredCharactersRequest
{
public int ProjectId { get; set; }
public IReadOnlyList<string> CandidateNames { get; set; } = [];
}
public sealed class WordCompanionCreatedCharacterDto
{
public int CharacterId { get; init; }
public string Name { get; init; } = string.Empty;
public bool Created { get; init; }
}
public sealed class WordCompanionCreateDiscoveredCharactersResponse
{
public int ProjectId { get; init; }
public int CreatedCount { get; init; }
public int SkippedCount { get; init; }
public IReadOnlyList<WordCompanionCreatedCharacterDto> Characters { get; init; } = [];
public string Message { get; init; } = "Characters created.";
}

View File

@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using PlotLine.Data;
using PlotLine.Models;
@ -22,6 +23,8 @@ public interface IWordCompanionService
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request);
}
public sealed class WordCompanionService(
@ -29,6 +32,23 @@ public sealed class WordCompanionService(
IManuscriptDocumentRepository manuscriptDocuments,
ICurrentUserService currentUser) : IWordCompanionService
{
private const int CharacterDiscoveryMinimumMentions = 5;
private const int CharacterDiscoverySuggestionLimit = 50;
private static readonly Regex CharacterCandidateRegex = new(
@"\b(?:(?:Mr|Mrs|Ms|Miss|Dr|Rev|Fr|Sir|Lady|Lord|DS|DI|DC|PC)\.?\s+)?[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?(?:\s+[A-Z][a-z]+(?:[-'][A-Z]?[a-z]+)?){0,2}\b",
RegexOptions.Compiled);
private static readonly HashSet<string> CharacterDiscoveryStopWords = new(StringComparer.OrdinalIgnoreCase)
{
"A", "An", "And", "As", "At", "But", "By", "For", "From", "He", "Her", "His", "I", "If", "In", "Into",
"It", "Its", "Of", "On", "Or", "Our", "She", "So", "That", "The", "Their", "Then", "There", "They",
"This", "To", "We", "When", "Where", "Who", "With", "You", "Your",
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
"Christmas", "Easter", "Morning", "Afternoon", "Evening", "Night", "Today", "Tomorrow", "Yesterday",
"Chapter", "Scene", "Part", "Prologue", "Epilogue", "Book", "Volume", "Act",
"Mum", "Dad", "Mother", "Father", "Grandma", "Grandad", "Nan", "Nana", "Granny",
"Yes", "No", "Okay", "Ok", "Hello", "Goodbye"
};
public async Task<WordCompanionProjectsResponse> ListProjectsAsync()
{
var userId = RequireUserId();
@ -264,6 +284,46 @@ public sealed class WordCompanionService(
return repository.ImportManuscriptAsync(RequireUserId(), request);
}
public async Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request)
{
if (request.ProjectId <= 0)
{
return null;
}
var userId = RequireUserId();
if (!await repository.HasProjectAccessAsync(request.ProjectId, userId))
{
return null;
}
var candidates = DiscoverCharacterCandidates(request.DocumentText ?? string.Empty);
return new WordCompanionDiscoverCharactersResponse
{
ProjectId = request.ProjectId,
Candidates = candidates
};
}
public Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request)
{
if (request.ProjectId <= 0 || request.CandidateNames.Count == 0)
{
return Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(null);
}
request.CandidateNames = request.CandidateNames
.Select(NormalizeCandidateName)
.Where(name => !string.IsNullOrWhiteSpace(name) && name.Length <= 200)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.ToList();
return request.CandidateNames.Count == 0
? Task.FromResult<WordCompanionCreateDiscoveredCharactersResponse?>(null)
: repository.CreateDiscoveredCharactersAsync(RequireUserId(), request);
}
private async Task<WordCompanionResolveSceneResponse?> ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request)
{
return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim());
@ -276,6 +336,61 @@ public sealed class WordCompanionService(
private static bool HasInvalidIds(IEnumerable<int> ids) => ids.Any(id => id <= 0);
private static IReadOnlyList<WordCompanionCharacterCandidateDto> DiscoverCharacterCandidates(string manuscriptText)
{
if (string.IsNullOrWhiteSpace(manuscriptText))
{
return [];
}
var mentions = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (Match match in CharacterCandidateRegex.Matches(manuscriptText))
{
var candidate = NormalizeCandidateName(match.Value);
if (!IsLikelyCharacterCandidate(candidate))
{
continue;
}
mentions[candidate] = mentions.TryGetValue(candidate, out var count) ? count + 1 : 1;
}
return mentions
.Where(item => item.Value >= CharacterDiscoveryMinimumMentions)
.OrderByDescending(item => item.Value)
.ThenBy(item => item.Key, StringComparer.OrdinalIgnoreCase)
.Take(CharacterDiscoverySuggestionLimit)
.Select(item => new WordCompanionCharacterCandidateDto { Text = item.Key, MentionCount = item.Value })
.ToList();
}
private static bool IsLikelyCharacterCandidate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > 200)
{
return false;
}
var words = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 0 || words.Any(word => CharacterDiscoveryStopWords.Contains(TrimHonorificPunctuation(word))))
{
return false;
}
var first = TrimHonorificPunctuation(words[0]);
return words.Length > 1 || first.Length > 2;
}
private static string NormalizeCandidateName(string candidate)
{
return Regex.Replace(candidate ?? string.Empty, @"\s+", " ").Trim().Trim(',', '.', ';', ':', '!', '?', '"', '\'');
}
private static string TrimHonorificPunctuation(string word)
{
return (word ?? string.Empty).Trim().TrimEnd('.');
}
private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new()
{
ManuscriptDocumentId = document.ManuscriptDocumentID,

View File

@ -0,0 +1,132 @@
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Project_HasAccess
@ProjectID int,
@UserID int
AS
BEGIN
SET NOCOUNT ON;
SELECT CAST(CASE WHEN 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
)
THEN 1 ELSE 0 END AS bit) AS HasAccess;
END;
GO
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Character_CreateDiscovered
@UserID int,
@ProjectID int,
@CandidatesJson nvarchar(max)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
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 @Candidates table
(
Name nvarchar(200) NOT NULL PRIMARY KEY
);
INSERT @Candidates (Name)
SELECT DISTINCT LEFT(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), 200)
FROM OPENJSON(@CandidatesJson)
WHERE NULLIF(LTRIM(RTRIM(JSON_VALUE(value, '$.Name'))), N'') IS NOT NULL;
IF NOT EXISTS (SELECT 1 FROM @Candidates)
RETURN;
DECLARE @Results table
(
CharacterId int NOT NULL,
Name nvarchar(200) NOT NULL,
Created bit NOT NULL
);
INSERT @Results (CharacterId, Name, Created)
SELECT ch.CharacterID,
c.Name,
CAST(0 AS bit)
FROM @Candidates c
INNER JOIN dbo.Characters ch ON ch.ProjectID = @ProjectID
AND ch.IsArchived = 0
AND UPPER(LTRIM(RTRIM(ch.CharacterName))) = UPPER(c.Name);
DECLARE @Missing table
(
Name nvarchar(200) NOT NULL PRIMARY KEY
);
INSERT @Missing (Name)
SELECT c.Name
FROM @Candidates c
WHERE NOT EXISTS
(
SELECT 1
FROM dbo.Characters ch
WHERE ch.ProjectID = @ProjectID
AND ch.IsArchived = 0
AND UPPER(LTRIM(RTRIM(ch.CharacterName))) = UPPER(c.Name)
);
DECLARE @Inserted table
(
CharacterID int NOT NULL,
CharacterName nvarchar(200) NOT NULL
);
INSERT dbo.Characters (ProjectID, CharacterName, ShortName, CharacterImportance, ShowInQuickAddBar, DefaultDescription)
OUTPUT inserted.CharacterID, inserted.CharacterName INTO @Inserted (CharacterID, CharacterName)
SELECT @ProjectID,
m.Name,
NULL,
8,
1,
N'Created from Word Companion manuscript import.'
FROM @Missing m
ORDER BY m.Name;
INSERT @Results (CharacterId, Name, Created)
SELECT CharacterID,
CharacterName,
CAST(1 AS bit)
FROM @Inserted;
SELECT @ProjectID AS ProjectId,
SUM(CASE WHEN Created = 1 THEN 1 ELSE 0 END) AS CreatedCount,
SUM(CASE WHEN Created = 0 THEN 1 ELSE 0 END) AS SkippedCount,
CASE
WHEN SUM(CASE WHEN Created = 1 THEN 1 ELSE 0 END) = 0 THEN N'No new characters were created.'
ELSE N'Characters created.'
END AS Message
FROM @Results;
SELECT CharacterId,
Name,
Created
FROM @Results
ORDER BY Created DESC, Name;
END;
GO

View File

@ -71,6 +71,17 @@
</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>
<section class="word-companion-character-discovery" data-first-run-character-section hidden aria-label="Likely Major Characters">
<div class="word-companion-section-header">
<span>Likely Major Characters</span>
</div>
<p class="word-companion-message" data-first-run-character-status></p>
<div class="word-companion-character-candidates" data-first-run-character-candidates></div>
<div class="word-companion-actions" data-first-run-character-actions hidden>
<button type="button" class="word-companion-primary-action" data-first-run-create-characters>Create Selected Characters</button>
<button type="button" data-first-run-skip-characters>Skip Character Creation</button>
</div>
</section>
<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>

View File

@ -399,6 +399,50 @@ body {
text-align: right;
}
.word-companion-character-discovery {
display: grid;
gap: 8px;
border-top: 1px solid var(--companion-line);
padding-top: 9px;
}
.word-companion-character-candidates {
display: grid;
gap: 5px;
max-height: 190px;
overflow-y: auto;
}
.word-companion-character-candidate {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 7px;
min-height: 31px;
border: 1px solid rgba(111, 75, 45, 0.18);
border-radius: 6px;
padding: 5px 7px;
background: #fffdf8;
}
.word-companion-character-candidate input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--companion-accent);
}
.word-companion-character-candidate strong {
font-size: 0.9rem;
}
.word-companion-character-candidate em {
color: var(--companion-muted);
font-size: 0.78rem;
font-style: normal;
font-weight: 800;
}
.word-companion-connection .word-companion-message {
grid-column: auto;
}

File diff suppressed because one or more lines are too long

View File

@ -25,6 +25,12 @@
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 firstRunCharacterSection = document.querySelector("[data-first-run-character-section]");
const firstRunCharacterStatus = document.querySelector("[data-first-run-character-status]");
const firstRunCharacterCandidates = document.querySelector("[data-first-run-character-candidates]");
const firstRunCharacterActions = document.querySelector("[data-first-run-character-actions]");
const firstRunCreateCharactersButton = document.querySelector("[data-first-run-create-characters]");
const firstRunSkipCharactersButton = document.querySelector("[data-first-run-skip-characters]");
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]");
@ -173,7 +179,10 @@
let firstRunDocumentBinding = null;
let firstRunAnalysis = null;
let firstRunImportStructure = null;
let firstRunCharacterCandidatesModel = [];
let firstRunStructureImported = false;
let isFirstRunImporting = false;
let isCreatingFirstRunCharacters = false;
let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null;
let sceneTrackingMode = "Manual";
@ -983,6 +992,9 @@
if (firstRunImportButton) {
firstRunImportButton.disabled = !firstRunAnalysis?.canImport || !firstRunImportStructure || isFirstRunImporting;
}
if (firstRunCreateCharactersButton) {
firstRunCreateCharactersButton.disabled = isCreatingFirstRunCharacters || selectedFirstRunCharacterNames().length === 0;
}
};
const setFirstRunMode = (visible) => {
@ -1039,15 +1051,73 @@
const resetFirstRunPreview = (message = "Select a Project and Book to begin.") => {
firstRunAnalysis = null;
firstRunImportStructure = null;
firstRunCharacterCandidatesModel = [];
firstRunStructureImported = false;
setHidden(firstRunSummary, true);
setHidden(firstRunImportActions, true);
setHidden(firstRunCharacterSection, true);
setHidden(firstRunCharacterActions, true);
if (firstRunSummary) {
firstRunSummary.innerHTML = "";
}
if (firstRunCharacterCandidates) {
firstRunCharacterCandidates.innerHTML = "";
}
setText(firstRunCharacterStatus, "");
setFirstRunStatus(message);
updateFirstRunButtons();
};
const selectedFirstRunCharacterNames = () => {
if (!firstRunCharacterCandidates) {
return [];
}
return [...firstRunCharacterCandidates.querySelectorAll("input[type='checkbox']:checked")]
.map((checkbox) => String(checkbox.value || "").trim())
.filter(Boolean);
};
const renderFirstRunCharacterCandidates = (candidates) => {
firstRunCharacterCandidatesModel = Array.isArray(candidates) ? candidates : [];
if (!firstRunCharacterSection || !firstRunCharacterCandidates) {
return;
}
firstRunCharacterCandidates.innerHTML = "";
setHidden(firstRunCharacterSection, false);
setHidden(firstRunCharacterActions, !firstRunStructureImported || firstRunCharacterCandidatesModel.length === 0);
if (firstRunCharacterCandidatesModel.length === 0) {
setText(firstRunCharacterStatus, "No likely major characters found.");
updateFirstRunButtons();
return;
}
setText(firstRunCharacterStatus, `${firstRunCharacterCandidatesModel.length} likely major characters found.`);
for (const candidate of firstRunCharacterCandidatesModel) {
const label = document.createElement("label");
label.className = "word-companion-character-candidate";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = candidate.text || "";
checkbox.checked = true;
checkbox.addEventListener("change", updateFirstRunButtons);
const name = document.createElement("strong");
name.textContent = candidate.text || "";
const mentions = document.createElement("em");
mentions.textContent = `${Number(candidate.mentionCount || 0).toLocaleString()} mentions`;
label.append(checkbox, name, mentions);
firstRunCharacterCandidates.append(label);
}
updateFirstRunButtons();
};
const renderFirstRunSummary = (analysis) => {
if (!firstRunSummary) {
return;
@ -1267,6 +1337,7 @@
let chapter = null;
let scene = null;
let totalWords = 0;
const manuscriptText = [];
const separatorTexts = new Set(["***", "###"]);
const startScene = () => {
@ -1288,6 +1359,7 @@
if (!text) {
return;
}
manuscriptText.push(text);
if (isBuiltInHeading(paragraph, 1)) {
chapter = {
@ -1321,7 +1393,8 @@
chapters,
chapterCount: chapters.length,
sceneCount: chapters.reduce((total, item) => total + item.scenes.length, 0),
wordCount: totalWords
wordCount: totalWords,
documentText: manuscriptText.join("\n")
};
};
@ -2210,6 +2283,7 @@
});
firstRunImportStructure = importStructure;
firstRunStructureImported = false;
const analysis = await postJson("/api/word-companion/manuscript/analyse", {
projectId: project.projectId,
bookId: book.bookId,
@ -2220,6 +2294,17 @@
});
firstRunAnalysis = analysis;
renderFirstRunSummary(analysis);
try {
const discovery = await postJson("/api/word-companion/manuscript/discover-characters", {
projectId: project.projectId,
documentText: importStructure.documentText || ""
});
renderFirstRunCharacterCandidates(discovery?.candidates || []);
} catch (discoveryError) {
console.error("Unable to discover character candidates.", discoveryError);
renderFirstRunCharacterCandidates([]);
setText(firstRunCharacterStatus, "Character discovery is unavailable.");
}
setDiagnostics({
lastScan: "Success",
lastError: "-",
@ -2328,10 +2413,22 @@
if (projectSelect) {
projectSelect.value = String(response.projectId);
}
const discoveredCharacters = firstRunCharacterCandidatesModel;
await loadBooks(response.projectId, response.bookId);
setFirstRunMode(false);
setConnectionStatus("Connected");
setDocumentMessage(response.message || "Manuscript structure imported.");
firstRunStructureImported = true;
firstRunCharacterCandidatesModel = discoveredCharacters;
if (discoveredCharacters.length > 0) {
setFirstRunMode(true);
renderFirstRunCharacterCandidates(discoveredCharacters);
setHidden(firstRunImportActions, true);
setHidden(firstRunCharacterActions, false);
setFirstRunStatus(response.message || "Manuscript structure imported.");
setDocumentMessage(response.message || "Manuscript structure imported.");
} else {
setFirstRunMode(false);
setDocumentMessage(response.message || "Manuscript structure imported.");
}
} catch (error) {
console.error("Unable to import manuscript structure.", error);
setFirstRunStatus(`Unable to import manuscript: ${errorText(error)}`);
@ -2341,6 +2438,41 @@
}
};
const finishFirstRunOnboarding = () => {
setFirstRunMode(false);
setFirstRunStatus("");
setHidden(firstRunCharacterActions, true);
};
const createFirstRunCharacters = async () => {
const project = selectedFirstRunProject() || selectedProject();
const candidateNames = selectedFirstRunCharacterNames();
if (!project || candidateNames.length === 0) {
updateFirstRunButtons();
return;
}
isCreatingFirstRunCharacters = true;
updateFirstRunButtons();
setText(firstRunCharacterStatus, "Creating selected characters...");
try {
const response = await postJson("/api/word-companion/manuscript/create-discovered-characters", {
projectId: project.projectId,
candidateNames
});
setDocumentMessage(response.message || "Characters created.");
setText(firstRunCharacterStatus, `${response.createdCount || 0} created, ${response.skippedCount || 0} already existed.`);
finishFirstRunOnboarding();
} catch (error) {
console.error("Unable to create discovered characters.", error);
setText(firstRunCharacterStatus, `Unable to create characters: ${errorText(error)}`);
} finally {
isCreatingFirstRunCharacters = false;
updateFirstRunButtons();
}
};
const initialiseFirstRunBinding = async () => {
if (!isAuthenticated || !wordHostAvailable) {
setFirstRunMode(false);
@ -3750,6 +3882,8 @@
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
firstRunCreateCharactersButton?.addEventListener("click", createFirstRunCharacters);
firstRunSkipCharactersButton?.addEventListener("click", finishFirstRunOnboarding);
firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false));
firstRunImportCancelButton?.addEventListener("click", () => resetFirstRunPreview("Import cancelled."));

File diff suppressed because one or more lines are too long