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);
|
var response = await wordCompanion.ImportManuscriptAsync(request);
|
||||||
return response is null ? BadRequest() : Ok(response);
|
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<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
|
||||||
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
|
||||||
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest 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
|
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 static string ToCsv(IEnumerable<int> ids) => string.Join(',', ids.Where(id => id > 0).Distinct());
|
||||||
|
|
||||||
private sealed class WordCompanionSceneStructureRow
|
private sealed class WordCompanionSceneStructureRow
|
||||||
|
|||||||
@ -333,3 +333,43 @@ public sealed class WordCompanionManuscriptImportResponse
|
|||||||
public int ArchivedScenes { get; init; }
|
public int ArchivedScenes { get; init; }
|
||||||
public WordCompanionManuscriptDocumentDto? ManuscriptDocument { 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.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
|
|
||||||
@ -22,6 +23,8 @@ public interface IWordCompanionService
|
|||||||
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
|
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
|
||||||
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
|
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
|
||||||
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
|
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(WordCompanionManuscriptImportRequest request);
|
||||||
|
Task<WordCompanionDiscoverCharactersResponse?> DiscoverCharacterCandidatesAsync(WordCompanionDiscoverCharactersRequest request);
|
||||||
|
Task<WordCompanionCreateDiscoveredCharactersResponse?> CreateDiscoveredCharactersAsync(WordCompanionCreateDiscoveredCharactersRequest request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class WordCompanionService(
|
public sealed class WordCompanionService(
|
||||||
@ -29,6 +32,23 @@ public sealed class WordCompanionService(
|
|||||||
IManuscriptDocumentRepository manuscriptDocuments,
|
IManuscriptDocumentRepository manuscriptDocuments,
|
||||||
ICurrentUserService currentUser) : IWordCompanionService
|
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()
|
public async Task<WordCompanionProjectsResponse> ListProjectsAsync()
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
@ -264,6 +284,46 @@ public sealed class WordCompanionService(
|
|||||||
return repository.ImportManuscriptAsync(RequireUserId(), request);
|
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)
|
private async Task<WordCompanionResolveSceneResponse?> ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request)
|
||||||
{
|
{
|
||||||
return await repository.ResolveSceneAsync(bookId, RequireUserId(), request.ChapterTitle!.Trim(), request.SceneTitle!.Trim());
|
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 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()
|
private static WordCompanionManuscriptDocumentDto ToDto(ManuscriptDocumentModel document) => new()
|
||||||
{
|
{
|
||||||
ManuscriptDocumentId = document.ManuscriptDocumentID,
|
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>
|
</div>
|
||||||
<p class="word-companion-message" data-first-run-status>Select a Project and Book to begin.</p>
|
<p class="word-companion-message" data-first-run-status>Select a Project and Book to begin.</p>
|
||||||
<div class="word-companion-import-summary" data-first-run-summary hidden></div>
|
<div class="word-companion-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>
|
<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" class="word-companion-primary-action" data-first-run-import disabled>Import Structure</button>
|
||||||
<button type="button" data-first-run-import-cancel>Cancel</button>
|
<button type="button" data-first-run-import-cancel>Cancel</button>
|
||||||
|
|||||||
@ -399,6 +399,50 @@ body {
|
|||||||
text-align: right;
|
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 {
|
.word-companion-connection .word-companion-message {
|
||||||
grid-column: auto;
|
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 firstRunCancelButton = document.querySelector("[data-first-run-cancel]");
|
||||||
const firstRunStatus = document.querySelector("[data-first-run-status]");
|
const firstRunStatus = document.querySelector("[data-first-run-status]");
|
||||||
const firstRunSummary = document.querySelector("[data-first-run-summary]");
|
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 firstRunImportActions = document.querySelector("[data-first-run-import-actions]");
|
||||||
const firstRunImportButton = document.querySelector("[data-first-run-import]");
|
const firstRunImportButton = document.querySelector("[data-first-run-import]");
|
||||||
const firstRunImportCancelButton = document.querySelector("[data-first-run-import-cancel]");
|
const firstRunImportCancelButton = document.querySelector("[data-first-run-import-cancel]");
|
||||||
@ -173,7 +179,10 @@
|
|||||||
let firstRunDocumentBinding = null;
|
let firstRunDocumentBinding = null;
|
||||||
let firstRunAnalysis = null;
|
let firstRunAnalysis = null;
|
||||||
let firstRunImportStructure = null;
|
let firstRunImportStructure = null;
|
||||||
|
let firstRunCharacterCandidatesModel = [];
|
||||||
|
let firstRunStructureImported = false;
|
||||||
let isFirstRunImporting = false;
|
let isFirstRunImporting = false;
|
||||||
|
let isCreatingFirstRunCharacters = false;
|
||||||
let isApplyingStructureSync = false;
|
let isApplyingStructureSync = false;
|
||||||
let pendingApplyStructureSyncConfirmation = null;
|
let pendingApplyStructureSyncConfirmation = null;
|
||||||
let sceneTrackingMode = "Manual";
|
let sceneTrackingMode = "Manual";
|
||||||
@ -983,6 +992,9 @@
|
|||||||
if (firstRunImportButton) {
|
if (firstRunImportButton) {
|
||||||
firstRunImportButton.disabled = !firstRunAnalysis?.canImport || !firstRunImportStructure || isFirstRunImporting;
|
firstRunImportButton.disabled = !firstRunAnalysis?.canImport || !firstRunImportStructure || isFirstRunImporting;
|
||||||
}
|
}
|
||||||
|
if (firstRunCreateCharactersButton) {
|
||||||
|
firstRunCreateCharactersButton.disabled = isCreatingFirstRunCharacters || selectedFirstRunCharacterNames().length === 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFirstRunMode = (visible) => {
|
const setFirstRunMode = (visible) => {
|
||||||
@ -1039,15 +1051,73 @@
|
|||||||
const resetFirstRunPreview = (message = "Select a Project and Book to begin.") => {
|
const resetFirstRunPreview = (message = "Select a Project and Book to begin.") => {
|
||||||
firstRunAnalysis = null;
|
firstRunAnalysis = null;
|
||||||
firstRunImportStructure = null;
|
firstRunImportStructure = null;
|
||||||
|
firstRunCharacterCandidatesModel = [];
|
||||||
|
firstRunStructureImported = false;
|
||||||
setHidden(firstRunSummary, true);
|
setHidden(firstRunSummary, true);
|
||||||
setHidden(firstRunImportActions, true);
|
setHidden(firstRunImportActions, true);
|
||||||
|
setHidden(firstRunCharacterSection, true);
|
||||||
|
setHidden(firstRunCharacterActions, true);
|
||||||
if (firstRunSummary) {
|
if (firstRunSummary) {
|
||||||
firstRunSummary.innerHTML = "";
|
firstRunSummary.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
if (firstRunCharacterCandidates) {
|
||||||
|
firstRunCharacterCandidates.innerHTML = "";
|
||||||
|
}
|
||||||
|
setText(firstRunCharacterStatus, "");
|
||||||
setFirstRunStatus(message);
|
setFirstRunStatus(message);
|
||||||
updateFirstRunButtons();
|
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) => {
|
const renderFirstRunSummary = (analysis) => {
|
||||||
if (!firstRunSummary) {
|
if (!firstRunSummary) {
|
||||||
return;
|
return;
|
||||||
@ -1267,6 +1337,7 @@
|
|||||||
let chapter = null;
|
let chapter = null;
|
||||||
let scene = null;
|
let scene = null;
|
||||||
let totalWords = 0;
|
let totalWords = 0;
|
||||||
|
const manuscriptText = [];
|
||||||
const separatorTexts = new Set(["***", "###"]);
|
const separatorTexts = new Set(["***", "###"]);
|
||||||
|
|
||||||
const startScene = () => {
|
const startScene = () => {
|
||||||
@ -1288,6 +1359,7 @@
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
manuscriptText.push(text);
|
||||||
|
|
||||||
if (isBuiltInHeading(paragraph, 1)) {
|
if (isBuiltInHeading(paragraph, 1)) {
|
||||||
chapter = {
|
chapter = {
|
||||||
@ -1321,7 +1393,8 @@
|
|||||||
chapters,
|
chapters,
|
||||||
chapterCount: chapters.length,
|
chapterCount: chapters.length,
|
||||||
sceneCount: chapters.reduce((total, item) => total + item.scenes.length, 0),
|
sceneCount: chapters.reduce((total, item) => total + item.scenes.length, 0),
|
||||||
wordCount: totalWords
|
wordCount: totalWords,
|
||||||
|
documentText: manuscriptText.join("\n")
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2210,6 +2283,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
firstRunImportStructure = importStructure;
|
firstRunImportStructure = importStructure;
|
||||||
|
firstRunStructureImported = false;
|
||||||
const analysis = await postJson("/api/word-companion/manuscript/analyse", {
|
const analysis = await postJson("/api/word-companion/manuscript/analyse", {
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
bookId: book.bookId,
|
bookId: book.bookId,
|
||||||
@ -2220,6 +2294,17 @@
|
|||||||
});
|
});
|
||||||
firstRunAnalysis = analysis;
|
firstRunAnalysis = analysis;
|
||||||
renderFirstRunSummary(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({
|
setDiagnostics({
|
||||||
lastScan: "Success",
|
lastScan: "Success",
|
||||||
lastError: "-",
|
lastError: "-",
|
||||||
@ -2328,10 +2413,22 @@
|
|||||||
if (projectSelect) {
|
if (projectSelect) {
|
||||||
projectSelect.value = String(response.projectId);
|
projectSelect.value = String(response.projectId);
|
||||||
}
|
}
|
||||||
|
const discoveredCharacters = firstRunCharacterCandidatesModel;
|
||||||
await loadBooks(response.projectId, response.bookId);
|
await loadBooks(response.projectId, response.bookId);
|
||||||
setFirstRunMode(false);
|
|
||||||
setConnectionStatus("Connected");
|
setConnectionStatus("Connected");
|
||||||
|
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.");
|
setDocumentMessage(response.message || "Manuscript structure imported.");
|
||||||
|
} else {
|
||||||
|
setFirstRunMode(false);
|
||||||
|
setDocumentMessage(response.message || "Manuscript structure imported.");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Unable to import manuscript structure.", error);
|
console.error("Unable to import manuscript structure.", error);
|
||||||
setFirstRunStatus(`Unable to import manuscript: ${errorText(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 () => {
|
const initialiseFirstRunBinding = async () => {
|
||||||
if (!isAuthenticated || !wordHostAvailable) {
|
if (!isAuthenticated || !wordHostAvailable) {
|
||||||
setFirstRunMode(false);
|
setFirstRunMode(false);
|
||||||
@ -3750,6 +3882,8 @@
|
|||||||
|
|
||||||
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
|
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
|
||||||
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
|
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
|
||||||
|
firstRunCreateCharactersButton?.addEventListener("click", createFirstRunCharacters);
|
||||||
|
firstRunSkipCharactersButton?.addEventListener("click", finishFirstRunOnboarding);
|
||||||
firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false));
|
firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false));
|
||||||
firstRunImportCancelButton?.addEventListener("click", () => resetFirstRunPreview("Import cancelled."));
|
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