Phase 16D - Major Character Discovery During Manuscript Import
This commit is contained in:
parent
33e8f0857c
commit
ac300a94c8
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.";
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
132
PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql
Normal file
132
PlotLine/Sql/096_Phase16D_WordCompanionCharacterDiscovery.sql
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
2
PlotLine/wwwroot/css/word-companion.min.css
vendored
File diff suppressed because one or more lines are too long
@ -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
Loading…
x
Reference in New Issue
Block a user