diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index d1447d0..bcf1de0 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -115,6 +115,11 @@ var tests = new (string Name, Action Test)[] ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), ("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters), + ("Core import basic location detector is conservative", CoreImportBasicLocationDetectorIsConservative), + ("Core import basic location detector consolidates and skips existing matches", CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches), + ("Core import basic location detector supports pending parent candidates", CoreImportBasicLocationDetectorSupportsPendingParentCandidates), + ("Core import character discovery protects accepted locations", CoreImportCharacterDiscoveryProtectsAcceptedLocations), + ("Core import gates characters behind basic location review", CoreImportGatesCharactersBehindBasicLocationReview), ("Core import character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), @@ -3696,8 +3701,11 @@ static void StructuralImportPersistsCanonicalSceneSourceText() Assert(migration.Contains("SceneID int NOT NULL CONSTRAINT PK_SceneManuscriptSources PRIMARY KEY", StringComparison.Ordinal), "Scene source text should be keyed by SceneID."); Assert(migration.Contains("IX_SceneManuscriptSources_BookChapter", StringComparison.Ordinal), "Scene source text should be indexed for book/chapter retrieval."); Assert(onboarding.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync."); - Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal) - < onboarding.IndexOf("await PublishBuildProgress(userId, preview.PreviewID, \"Updating Word document with scene markers", StringComparison.Ordinal), "Scene source text should be persisted before marker sync can complete."); + var saveSceneSourceIndex = onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal); + var locationReviewIndex = onboarding.IndexOf("coreImportBasicLocations.EnsurePreparedAsync", saveSceneSourceIndex, StringComparison.Ordinal); + var markerProgressIndex = onboarding.IndexOf("Updating Word document with scene markers", locationReviewIndex, StringComparison.Ordinal); + Assert(saveSceneSourceIndex >= 0 && saveSceneSourceIndex < locationReviewIndex, "Scene source text should be persisted before basic location review."); + Assert(locationReviewIndex >= 0 && locationReviewIndex < markerProgressIndex, "Basic location review should be prepared before marker sync can complete."); Assert(analyzer.Contains("string.Join(Environment.NewLine + Environment.NewLine", StringComparison.Ordinal), "Scene source text should preserve paragraph separation."); Assert(analyzer.Contains("item.IncludeInSourceText", StringComparison.Ordinal), "Scene source text should exclude non-prose structural paragraphs such as hidden markers/separators."); } @@ -3749,13 +3757,97 @@ static void CoreImportScanStageDefersScenesAndCharacters() Assert(scanReview.Contains("

Chapters

", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes."); Assert(index.Contains("data-onboarding-scan-scenes", StringComparison.Ordinal), "Scan page should display the Scenes counter."); Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state."); - Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist."); + Assert(onboarding.Contains("EnsurePreparedAsync(", StringComparison.Ordinal), "Core import should prepare basic location review after canonical scene sources exist."); + Assert(onboarding.Contains("PrepareCharacterCandidatesAfterLocationReviewAsync", StringComparison.Ordinal), "Character candidates should be generated only after basic location review is complete or skipped."); Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit."); Assert(wordService.Contains("var evidenceScenes = CandidateSceneEvidence", StringComparison.Ordinal), "Post-scene character candidates should calculate candidate-specific canonical scene evidence."); Assert(wordService.Contains("EvidenceScenes = evidenceScenes", StringComparison.Ordinal), "Post-scene character candidates should carry candidate-specific canonical scene evidence."); Assert(!wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should not dump every canonical SceneID into every candidate reason."); } +static void CoreImportBasicLocationDetectorIsConservative() +{ + var sources = new List + { + new() + { + SceneID = 101, + ChapterID = 1, + SourceText = "Maggie sat in the bedroom, then walked into the kitchen. In 1983 Rubery Hill changed. Later she met Zoe at The Black Horse Inn on Station Road." + } + }; + + var result = CoreImportBasicLocationDetector.Discover(sources, []); + var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList(); + + Assert(names.Contains("The Black Horse Inn"), "Named pub/inn should be detected."); + Assert(names.Contains("Station Road"), "Named road should be detected."); + Assert(!names.Contains("1983 Rubery Hill"), "Year-prefixed phrases should not become locations."); + Assert(!names.Any(name => string.Equals(name, "bedroom", StringComparison.OrdinalIgnoreCase)), "Generic bedrooms should be excluded."); + Assert(!names.Any(name => string.Equals(name, "kitchen", StringComparison.OrdinalIgnoreCase)), "Generic kitchens should be excluded."); +} + +static void CoreImportBasicLocationDetectorConsolidatesAndSkipsExistingMatches() +{ + var sources = new List + { + new() { SceneID = 101, ChapterID = 1, SourceText = "They crossed Station Road and stopped outside Maggie's flat." }, + new() { SceneID = 102, ChapterID = 1, SourceText = "Maggie's flat looked over Station Road." }, + new() { SceneID = 201, ChapterID = 2, SourceText = "They returned to Station Road before dawn." } + }; + + var result = CoreImportBasicLocationDetector.Discover(sources, [new CoreImportExistingLocationName(55, "Station Road")]); + var names = result.Candidates.Select(candidate => candidate.ProposedName).ToList(); + + Assert(!names.Contains("Station Road"), "Exact existing canonical location matches should not become pointless review rows."); + var flat = result.Candidates.Single(candidate => candidate.ProposedName == "Maggie's flat"); + Assert(flat.ObservationCount == 2, "Repeated residence observations should consolidate into one candidate."); +} + +static void CoreImportBasicLocationDetectorSupportsPendingParentCandidates() +{ + var sources = new List + { + new() { SceneID = 101, ChapterID = 1, SourceText = "Rose Cottage on Station Road had a yellow door. They later crossed Station Road." } + }; + + var result = CoreImportBasicLocationDetector.Discover(sources, []); + var cottage = result.Candidates.Single(candidate => candidate.ProposedName == "Rose Cottage"); + + Assert(result.Candidates.Any(candidate => candidate.ProposedName == "Station Road"), "The parent candidate should be discoverable in the same pass."); + Assert(cottage.ParentCandidateKey == CoreImportBasicLocationDetector.StableKey("Station Road"), "Pending parent candidates should be captured for materialisation order."); +} + +static void CoreImportCharacterDiscoveryProtectsAcceptedLocations() +{ + var sources = new List + { + new() { SceneID = 101, ChapterID = 1, SourceText = "Maggie walked along Station Road. Maggie waved at Beth outside The Black Horse Inn." }, + new() { SceneID = 102, ChapterID = 1, SourceText = "Beth told Maggie that Station Road was quiet." } + }; + + var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources, ["Station Road", "The Black Horse Inn"]); + var names = candidates.Select(candidate => candidate.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + + Assert(names.Contains("Maggie"), "Real character names should survive location protection."); + Assert(names.Contains("Beth"), "Other real character names should survive location protection."); + Assert(!names.Contains("Station Road"), "Accepted locations should not appear as character candidates."); + Assert(!names.Contains("The Black Horse Inn"), "Accepted location aliases should not appear as character candidates."); +} + +static void CoreImportGatesCharactersBehindBasicLocationReview() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); + var sql = File.ReadAllText(Path.Combine(root, "Sql/181_Phase27H_CoreImportBasicLocationReview.sql")); + + Assert(onboarding.Contains("Review or skip basic locations before reviewing characters.", StringComparison.Ordinal), "Onboarding service should block character finalisation while basic locations are pending."); + Assert(controller.Contains("RedirectToAction(nameof(ScanLocations)", StringComparison.Ordinal), "Onboarding controller should route pending imports to the location review page."); + Assert(sql.Contains("CoreImportBasicLocationReviews", StringComparison.Ordinal), "Location review state should be persisted in its own core import tables."); + Assert(sql.Contains("CoreImportBasicLocationReview_MarkSkipped", StringComparison.Ordinal), "Basic location review should support explicit skip."); +} + static void CoreImportCharacterEvidenceIsCandidateSpecific() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 0976ff4..4459eba 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -74,11 +74,75 @@ public sealed class OnboardingController( return RedirectToAction(nameof(Index)); } + if (!await onboarding.IsBasicLocationReviewCompleteAsync(model.PreviewID)) + { + TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters."; + return RedirectToAction(nameof(ScanLocations), new { previewId = model.PreviewID }); + } + + await onboarding.EnsureCharacterCandidatesAfterLocationReviewAsync(model.PreviewID); + model = await onboarding.GetScanReviewAsync(model.PreviewID); + if (model is null) + { + return NotFound(); + } + ViewData["ScanReviewStep"] = "characters"; ViewData["CharacterReviewPage"] = Math.Max(1, page); return View("ScanReview", model); } + [HttpGet("scan-locations")] + public async Task ScanLocations(Guid previewId) + { + var model = await onboarding.GetBasicLocationReviewAsync(previewId); + if (model is null) + { + TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing locations."; + await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); + return RedirectToAction(nameof(Index)); + } + + if (model.IsComplete) + { + return RedirectToAction(nameof(ScanCharacters), new { previewId }); + } + + return View(model); + } + + [HttpPost("scan-locations")] + [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] + [ValidateAntiForgeryToken] + public async Task SaveBasicLocations(CoreImportBasicLocationReviewForm form, string intent = "save") + { + try + { + if (string.Equals(intent, "skip", StringComparison.OrdinalIgnoreCase)) + { + await onboarding.SkipBasicLocationReviewAsync(form.PreviewID); + TempData["OnboardingReviewMessage"] = "Basic location review skipped. Next: review detected characters."; + return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }); + } + + if (string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase)) + { + await onboarding.CompleteBasicLocationReviewAsync(form); + TempData["OnboardingReviewMessage"] = "Locations saved. Next: review detected characters."; + return RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }); + } + + await onboarding.SaveBasicLocationReviewAsync(form); + TempData["OnboardingReviewMessage"] = "Location review choices saved."; + return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); + } + catch (InvalidOperationException ex) + { + TempData["ArchiveError"] = ex.Message; + return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); + } + } + [HttpGet("build-complete")] public async Task BuildComplete(Guid previewId) { @@ -684,6 +748,11 @@ public sealed class OnboardingController( await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); return RedirectToAction(nameof(Index)); } + if (!await onboarding.IsBasicLocationReviewCompleteAsync(form.PreviewID)) + { + TempData["OnboardingReviewMessage"] = "Review or skip basic locations before reviewing characters."; + return RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }); + } } TempData["OnboardingReviewMessage"] = readyToImport @@ -694,7 +763,7 @@ public sealed class OnboardingController( return readyToImport ? RedirectToAction(nameof(Index)) : continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage }) - : continueToCharacters ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID }) + : continueToCharacters ? RedirectToAction(nameof(ScanLocations), new { previewId = form.PreviewID }) : RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID }); } catch (InvalidOperationException ex) diff --git a/PlotLine/Data/CoreImportBasicLocationReviewRepository.cs b/PlotLine/Data/CoreImportBasicLocationReviewRepository.cs new file mode 100644 index 0000000..a4ddc17 --- /dev/null +++ b/PlotLine/Data/CoreImportBasicLocationReviewRepository.cs @@ -0,0 +1,160 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface ICoreImportBasicLocationReviewRepository +{ + Task GetByPreviewAsync(int userId, Guid previewId); + Task CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId); + Task SaveCandidateAsync(int reviewId, CoreImportBasicLocationCandidate candidate); + Task DeleteEvidenceAsync(int candidateId); + Task SaveEvidenceAsync(int candidateId, CoreImportBasicLocationEvidence evidence); + Task CompletePreparationAsync(int reviewId, int rawObservationCount, int candidateCount); + Task<(IReadOnlyList Candidates, IReadOnlyList Evidence)> ListCandidatesAsync(int reviewId); + Task SaveDecisionAsync(int reviewId, CoreImportBasicLocationDecision decision); + Task SetResultAsync(int reviewId, string stableKey, int resultLocationId); + Task MarkCompleteAsync(int reviewId); + Task MarkSkippedAsync(int reviewId); +} + +public sealed class CoreImportBasicLocationReviewRepository(ISqlConnectionFactory connectionFactory) : ICoreImportBasicLocationReviewRepository +{ + public async Task GetByPreviewAsync(int userId, Guid previewId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.CoreImportBasicLocationReview_GetByPreview", + new { UserID = userId, PreviewID = previewId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.CoreImportBasicLocationReview_Create", + new { UserID = userId, PreviewID = previewId, ProjectID = projectId, BookID = bookId, BuildID = buildId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveCandidateAsync(int reviewId, CoreImportBasicLocationCandidate candidate) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.CoreImportBasicLocationReview_SaveCandidate", + new + { + CoreImportBasicLocationReviewID = reviewId, + candidate.StableKey, + candidate.ProposedName, + candidate.PreferredName, + candidate.KnownNamesJson, + candidate.ParentLocationID, + candidate.ParentCandidateKey, + candidate.ExistingLocationID, + candidate.FirstChapterNumber, + candidate.FirstSceneNumber, + candidate.FirstOccurrenceIndex, + candidate.ObservationCount, + candidate.SortOrder + }, + commandType: CommandType.StoredProcedure); + } + + public async Task DeleteEvidenceAsync(int candidateId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_DeleteEvidence", + new { CoreImportBasicLocationCandidateID = candidateId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveEvidenceAsync(int candidateId, CoreImportBasicLocationEvidence evidence) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_SaveEvidence", + new + { + CoreImportBasicLocationCandidateID = candidateId, + evidence.SceneID, + evidence.ChapterID, + evidence.ChapterNumber, + evidence.SceneNumber, + evidence.OccurrenceIndex, + evidence.EvidenceText, + evidence.SortOrder + }, + commandType: CommandType.StoredProcedure); + } + + public async Task CompletePreparationAsync(int reviewId, int rawObservationCount, int candidateCount) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_CompletePreparation", + new { CoreImportBasicLocationReviewID = reviewId, RawObservationCount = rawObservationCount, CandidateCount = candidateCount }, + commandType: CommandType.StoredProcedure); + } + + public async Task<(IReadOnlyList Candidates, IReadOnlyList Evidence)> ListCandidatesAsync(int reviewId) + { + using var connection = connectionFactory.CreateConnection(); + using var rows = await connection.QueryMultipleAsync( + "dbo.CoreImportBasicLocationReview_ListCandidates", + new { CoreImportBasicLocationReviewID = reviewId }, + commandType: CommandType.StoredProcedure); + var candidates = (await rows.ReadAsync()).ToList(); + var evidence = (await rows.ReadAsync()).ToList(); + return (candidates, evidence); + } + + public async Task SaveDecisionAsync(int reviewId, CoreImportBasicLocationDecision decision) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_SaveDecision", + new + { + CoreImportBasicLocationReviewID = reviewId, + decision.StableKey, + decision.PreferredName, + decision.Action, + decision.ParentLocationID, + decision.ParentCandidateKey, + decision.SameAsLocationID, + decision.SameAsCandidateKey + }, + commandType: CommandType.StoredProcedure); + } + + public async Task SetResultAsync(int reviewId, string stableKey, int resultLocationId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_SetResult", + new { CoreImportBasicLocationReviewID = reviewId, StableKey = stableKey, ResultLocationID = resultLocationId }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkCompleteAsync(int reviewId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_MarkComplete", + new { CoreImportBasicLocationReviewID = reviewId }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkSkippedAsync(int reviewId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.CoreImportBasicLocationReview_MarkSkipped", + new { CoreImportBasicLocationReviewID = reviewId }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index a2fc8f3..6453a8c 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -265,6 +265,7 @@ public interface ILocationRepository Task GetAsync(int locationId); Task SaveAsync(LocationItem location); Task> ListAliasesAsync(int locationId); + Task> ListAliasesByProjectAsync(int projectId); Task AddAliasAsync(int locationId, string alias, int? sortOrder); Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder); Task DeleteAliasAsync(int locationAliasId); @@ -539,6 +540,16 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) return rows.ToList(); } + public async Task> ListAliasesByProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.LocationAlias_ListByProject", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task AddAliasAsync(int locationId, string alias, int? sortOrder) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index a25ae34..fdec7e7 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -148,6 +148,92 @@ public sealed class ManuscriptScanCharacterEvidenceScene public string? StructuralSummary { get; init; } } +public static class CoreImportBasicLocationReviewStatuses +{ + public const string Pending = "Pending"; + public const string Completed = "Completed"; + public const string Skipped = "Skipped"; +} + +public static class CoreImportBasicLocationCandidateActions +{ + public const string Create = "Create"; + public const string SameLocation = "SameLocation"; + public const string Ignore = "Ignore"; +} + +public sealed class CoreImportBasicLocationReview +{ + public int CoreImportBasicLocationReviewID { get; set; } + public Guid PreviewID { get; set; } + public int UserID { get; set; } + public int ProjectID { get; set; } + public int BookID { get; set; } + public int? BuildID { get; set; } + public string Status { get; set; } = CoreImportBasicLocationReviewStatuses.Pending; + public int RawObservationCount { get; set; } + public int CandidateCount { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public DateTime? CompletedUtc { get; set; } +} + +public sealed class CoreImportBasicLocationCandidate +{ + public int CoreImportBasicLocationCandidateID { get; set; } + public int CoreImportBasicLocationReviewID { get; set; } + public string StableKey { get; set; } = string.Empty; + public string ProposedName { get; set; } = string.Empty; + public string PreferredName { get; set; } = string.Empty; + public string KnownNamesJson { get; set; } = "[]"; + public int? ParentLocationID { get; set; } + public string? ParentCandidateKey { get; set; } + public int? ExistingLocationID { get; set; } + public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create; + public int? SameAsLocationID { get; set; } + public string? SameAsCandidateKey { get; set; } + public int? ResultLocationID { get; set; } + public int FirstChapterNumber { get; set; } + public int FirstSceneNumber { get; set; } + public int FirstOccurrenceIndex { get; set; } + public int ObservationCount { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public IReadOnlyList Evidence { get; set; } = []; +} + +public sealed class CoreImportBasicLocationEvidence +{ + public int CoreImportBasicLocationEvidenceID { get; set; } + public int CoreImportBasicLocationCandidateID { get; set; } + public int SceneID { get; set; } + public int ChapterID { get; set; } + public int ChapterNumber { get; set; } + public int SceneNumber { get; set; } + public int OccurrenceIndex { get; set; } + public string EvidenceText { get; set; } = string.Empty; + public int SortOrder { get; set; } +} + +public sealed class CoreImportBasicLocationDecision +{ + public string StableKey { get; set; } = string.Empty; + public string PreferredName { get; set; } = string.Empty; + public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create; + public int? ParentLocationID { get; set; } + public string? ParentCandidateKey { get; set; } + public int? SameAsLocationID { get; set; } + public string? SameAsCandidateKey { get; set; } +} + +public sealed class CoreImportBasicLocationDiscoveryResult +{ + public int RawObservationCount { get; init; } + public int ExcludedObservationCount { get; init; } + public IReadOnlyList Candidates { get; init; } = []; +} + public sealed class ManuscriptScanState { public Guid? ScanID { get; init; } diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 16ac8d3..65aed3c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -146,6 +146,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -222,6 +223,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreImportBasicLocationDetector.cs b/PlotLine/Services/CoreImportBasicLocationDetector.cs new file mode 100644 index 0000000..28940fb --- /dev/null +++ b/PlotLine/Services/CoreImportBasicLocationDetector.cs @@ -0,0 +1,291 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using PlotLine.Data; +using PlotLine.Models; + +namespace PlotLine.Services; + +public sealed record CoreImportExistingLocationName(int LocationID, string Name); + +public static partial class CoreImportBasicLocationDetector +{ + private static readonly HashSet ExplicitExcludedNames = new(StringComparer.OrdinalIgnoreCase) + { + "bar area", "bathroom", "bedroom", "corridor", "corner", "doorway", "garden", + "hall", "hallway", "inside", "kitchen", "office", "outside", "room", "stairs", + "toilet", "upstairs", "downstairs", "waiting room", "ward" + }; + + private static readonly HashSet DirectionalStarts = new(StringComparer.OrdinalIgnoreCase) + { + "behind", "beside", "inside", "outside", "near", "under", "upstairs", "downstairs" + }; + + public static CoreImportBasicLocationDiscoveryResult Discover( + IReadOnlyList sourceScenes, + IReadOnlyList existingNames) + { + var existingIndex = existingNames + .Select(item => new { Key = StableKey(item.Name), item.LocationID }) + .Where(item => !string.IsNullOrWhiteSpace(item.Key)) + .GroupBy(item => item.Key, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.First().LocationID, StringComparer.Ordinal); + + var orderedScenes = sourceScenes + .OrderBy(scene => scene.ChapterID) + .ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue) + .ThenBy(scene => scene.SceneID) + .ToList(); + var chapterNumbers = orderedScenes + .GroupBy(scene => scene.ChapterID) + .Select((group, index) => new { group.Key, ChapterNumber = index + 1 }) + .ToDictionary(item => item.Key, item => item.ChapterNumber); + var sceneNumbers = orderedScenes + .GroupBy(scene => scene.ChapterID) + .SelectMany(group => group.Select((scene, index) => new { scene.SceneID, SceneNumber = index + 1 })) + .ToDictionary(item => item.SceneID, item => item.SceneNumber); + + var observations = new List(); + var excluded = 0; + foreach (var scene in orderedScenes) + { + if (string.IsNullOrWhiteSpace(scene.SourceText)) + { + continue; + } + + var text = scene.SourceText; + foreach (var observation in ExtractObservations(text, scene, chapterNumbers.GetValueOrDefault(scene.ChapterID), sceneNumbers.GetValueOrDefault(scene.SceneID))) + { + if (IsExcluded(observation.Name)) + { + excluded++; + continue; + } + + observations.Add(observation); + } + } + + var grouped = observations + .GroupBy(item => StableKey(item.Name), StringComparer.Ordinal) + .Where(group => !string.IsNullOrWhiteSpace(group.Key)) + .Select(group => BuildCandidate(group.Key, group.ToList(), existingIndex)) + .Where(candidate => !candidate.ExistingLocationID.HasValue) + .OrderBy(candidate => candidate.FirstChapterNumber) + .ThenBy(candidate => candidate.FirstSceneNumber) + .ThenBy(candidate => candidate.FirstOccurrenceIndex) + .Select((candidate, index) => + { + candidate.SortOrder = (index + 1) * 10; + return candidate; + }) + .ToList(); + + var keys = grouped.Select(candidate => candidate.StableKey).ToHashSet(StringComparer.Ordinal); + foreach (var candidate in grouped) + { + if (IsLinearLocation(candidate.ProposedName)) + { + continue; + } + + var parentKey = candidate.Evidence + .Select(evidence => InferParentKey(evidence.EvidenceText, candidate.ProposedName)) + .FirstOrDefault(key => !string.IsNullOrWhiteSpace(key) && keys.Contains(key)); + if (!string.IsNullOrWhiteSpace(parentKey) && !string.Equals(parentKey, candidate.StableKey, StringComparison.Ordinal)) + { + candidate.ParentCandidateKey = parentKey; + } + } + + return new CoreImportBasicLocationDiscoveryResult + { + RawObservationCount = observations.Count, + ExcludedObservationCount = excluded, + Candidates = grouped + }; + } + + public static string StableKey(string? name) + { + var normalized = NormalizeName(name); + if (string.IsNullOrWhiteSpace(normalized)) + { + return string.Empty; + } + + normalized = Regex.Replace(normalized, @"^the\s+", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + normalized = Regex.Replace(normalized, @"[^\p{L}\p{N}]+", "-", RegexOptions.CultureInvariant).Trim('-').ToLowerInvariant(); + return normalized; + } + + public static string NormalizeName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + var cleaned = name.Replace('’', '\'').Replace('–', '-').Replace('—', '-'); + cleaned = Regex.Replace(cleaned, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + cleaned = cleaned.Trim(' ', '.', ',', ';', ':', '!', '?', '"', '\''); + cleaned = Regex.Replace(cleaned, @"^(?:at|in|into|inside|outside|to|from|near|beside|behind|on)\s+", string.Empty, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return cleaned.Trim(); + } + + private static IEnumerable ExtractObservations( + string text, + SceneManuscriptSourceRecord scene, + int chapterNumber, + int sceneNumber) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var regex in DetectionRegexes()) + { + foreach (Match match in regex.Matches(text)) + { + var value = NormalizeName(match.Groups["name"].Success ? match.Groups["name"].Value : match.Value); + var key = $"{match.Index}:{StableKey(value)}"; + if (string.IsNullOrWhiteSpace(value) || !seen.Add(key)) + { + continue; + } + + yield return new LocationObservation( + value, + scene.SceneID, + scene.ChapterID, + chapterNumber, + sceneNumber, + match.Index, + EvidenceWindow(text, match.Index, match.Length)); + } + } + } + + private static CoreImportBasicLocationCandidate BuildCandidate( + string key, + IReadOnlyList observations, + IReadOnlyDictionary existingIndex) + { + var ordered = observations + .OrderBy(item => item.ChapterNumber) + .ThenBy(item => item.SceneNumber) + .ThenBy(item => item.OccurrenceIndex) + .ToList(); + var names = ordered + .Select(item => NormalizeName(item.Name)) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderByDescending(name => ordered.Count(item => string.Equals(NormalizeName(item.Name), name, StringComparison.OrdinalIgnoreCase))) + .ThenBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var preferred = names.FirstOrDefault() ?? ordered[0].Name; + var candidate = new CoreImportBasicLocationCandidate + { + StableKey = key, + ProposedName = preferred, + PreferredName = preferred, + KnownNamesJson = JsonSerializer.Serialize(names), + ExistingLocationID = existingIndex.GetValueOrDefault(key) == 0 ? null : existingIndex[key], + FirstChapterNumber = ordered[0].ChapterNumber, + FirstSceneNumber = ordered[0].SceneNumber, + FirstOccurrenceIndex = ordered[0].OccurrenceIndex, + ObservationCount = ordered.Count, + Evidence = ordered + .Take(5) + .Select((item, index) => new CoreImportBasicLocationEvidence + { + SceneID = item.SceneID, + ChapterID = item.ChapterID, + ChapterNumber = item.ChapterNumber, + SceneNumber = item.SceneNumber, + OccurrenceIndex = item.OccurrenceIndex, + EvidenceText = item.EvidenceText, + SortOrder = (index + 1) * 10 + }) + .ToList() + }; + return candidate; + } + + private static bool IsExcluded(string name) + { + var normalized = NormalizeName(name); + var key = StableKey(normalized).Replace('-', ' '); + if (string.IsNullOrWhiteSpace(key) || ExplicitExcludedNames.Contains(key)) + { + return true; + } + + var first = key.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + if (first is not null && DirectionalStarts.Contains(first)) + { + return true; + } + + if (Regex.IsMatch(normalized, @"^(?:1[5-9]\d{2}|20\d{2}|21\d{2})\s+", RegexOptions.CultureInvariant)) + { + return true; + } + + return key.Length < 3 || Regex.IsMatch(normalized, @"^(?:Chapter|Scene)\s+\d+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private static bool IsLinearLocation(string name) + => Regex.IsMatch(name, @"\b(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills)\b$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static string? InferParentKey(string evidenceText, string childName) + { + var child = Regex.Escape(childName); + var road = @"(?(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))"; + var pattern = $@"{child}.{{0,60}}\b(?:on|in|at)\s+{road}|{road}.{{0,60}}{child}"; + var match = Regex.Match(evidenceText, pattern, RegexOptions.CultureInvariant); + return match.Success ? StableKey(match.Groups["parent"].Value) : null; + } + + private static string EvidenceWindow(string text, int start, int length) + { + var left = Math.Max(0, start - 100); + var right = Math.Min(text.Length, start + length + 140); + var snippet = text[left..right]; + snippet = Regex.Replace(snippet, @"\s+", " ", RegexOptions.CultureInvariant).Trim(); + return snippet.Length <= 360 ? snippet : snippet[..360].Trim(); + } + + private static IReadOnlyList DetectionRegexes() + => + [ + AddressRegex(), + PossessiveResidenceRegex(), + NamedRoadRegex(), + NamedVenueRegex(), + ThePlaceRegex() + ]; + + [GeneratedRegex(@"\b(?\d+\s+(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))\b", RegexOptions.CultureInvariant)] + private static partial Regex AddressRegex(); + + [GeneratedRegex(@"\b(?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,2}['’]s\s+(?:house|home|flat|apartment|cottage))\b", RegexOptions.CultureInvariant)] + private static partial Regex PossessiveResidenceRegex(); + + [GeneratedRegex(@"\b(?(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Road|Street|Lane|Avenue|Drive|Way|Close|Crescent|Square|Place|Hill|Hills))\b", RegexOptions.CultureInvariant)] + private static partial Regex NamedRoadRegex(); + + [GeneratedRegex(@"\b(?(?:The\s+)?[A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+){0,4}\s+(?:Inn|Pub|Hotel|School|Hospital|Station|Park|Church|Cathedral|University|College|Library|Shop|Store|Cafe|Café|Restaurant|Club|Arcade|Centre|Center|House|Cottage|Farm|Hall|Manor|Estate|Gardens|Common|Bridge|Junction|Airport|Museum|Theatre|Theater))\b", RegexOptions.CultureInvariant)] + private static partial Regex NamedVenueRegex(); + + [GeneratedRegex(@"\b(?The\s+[A-Z][\p{L}'-]+(?:ies|s))\b", RegexOptions.CultureInvariant)] + private static partial Regex ThePlaceRegex(); + + private sealed record LocationObservation( + string Name, + int SceneID, + int ChapterID, + int ChapterNumber, + int SceneNumber, + int OccurrenceIndex, + string EvidenceText); +} diff --git a/PlotLine/Services/CoreImportBasicLocationService.cs b/PlotLine/Services/CoreImportBasicLocationService.cs new file mode 100644 index 0000000..6e8c37e --- /dev/null +++ b/PlotLine/Services/CoreImportBasicLocationService.cs @@ -0,0 +1,310 @@ +using System.Text.Json; +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface ICoreImportBasicLocationService +{ + Task EnsurePreparedAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList sceneSources); + Task GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle); + Task IsCompleteAsync(int userId, Guid previewId); + Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form); + Task CompleteAsync(int userId, Guid previewId); + Task SkipAsync(int userId, Guid previewId); + Task> GetProtectedLocationNamesAsync(int userId, Guid previewId); +} + +public sealed class CoreImportBasicLocationService( + ICoreImportBasicLocationReviewRepository reviews, + ILocationRepository locations) : ICoreImportBasicLocationService +{ + public async Task EnsurePreparedAsync( + int userId, + Guid previewId, + int projectId, + int bookId, + int? buildId, + IReadOnlyList sceneSources) + { + var review = await reviews.CreateAsync(userId, previewId, projectId, bookId, buildId); + var existing = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID); + if (existing.Candidates.Count > 0 || IsComplete(review.Status)) + { + return review; + } + + var existingLocations = await locations.ListByProjectAsync(projectId); + var existingAliases = await locations.ListAliasesByProjectAsync(projectId); + var existingNames = existingLocations + .Select(location => new CoreImportExistingLocationName(location.LocationID, location.LocationName)) + .Concat(existingAliases.Select(alias => new CoreImportExistingLocationName(alias.LocationID, alias.Alias))) + .ToList(); + var discovery = CoreImportBasicLocationDetector.Discover(sceneSources, existingNames); + + foreach (var candidate in discovery.Candidates) + { + var saved = await reviews.SaveCandidateAsync(review.CoreImportBasicLocationReviewID, candidate); + await reviews.DeleteEvidenceAsync(saved.CoreImportBasicLocationCandidateID); + foreach (var evidence in candidate.Evidence) + { + await reviews.SaveEvidenceAsync(saved.CoreImportBasicLocationCandidateID, evidence); + } + } + + await reviews.CompletePreparationAsync(review.CoreImportBasicLocationReviewID, discovery.RawObservationCount, discovery.Candidates.Count); + return await reviews.GetByPreviewAsync(userId, previewId); + } + + public async Task GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle) + { + var review = await reviews.GetByPreviewAsync(userId, previewId); + if (review is null) + { + return null; + } + + var (candidates, evidenceRows) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID); + var evidenceLookup = evidenceRows + .GroupBy(evidence => evidence.CoreImportBasicLocationCandidateID) + .ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ToList()); + var existingLocations = await locations.ListByProjectAsync(review.ProjectID); + + return new CoreImportBasicLocationReviewViewModel + { + PreviewID = previewId, + ReviewID = review.CoreImportBasicLocationReviewID, + Status = review.Status, + DocumentTitle = documentTitle, + SelectedProjectName = projectName, + SelectedBookTitle = bookTitle, + RawObservationCount = review.RawObservationCount, + CandidateCount = review.CandidateCount, + ExistingLocationOptions = existingLocations + .OrderBy(location => location.LocationPath) + .ThenBy(location => location.LocationName) + .Select(location => new CoreImportLocationOptionViewModel + { + LocationID = location.LocationID, + LocationName = location.LocationName, + LocationPath = location.LocationPath + }) + .ToList(), + Candidates = candidates.Select(candidate => new CoreImportBasicLocationCandidateViewModel + { + StableKey = candidate.StableKey, + ProposedName = candidate.ProposedName, + PreferredName = candidate.PreferredName, + KnownNames = ReadKnownNames(candidate), + Action = candidate.Action, + ParentLocationID = candidate.ParentLocationID, + ParentCandidateKey = candidate.ParentCandidateKey, + SameAsLocationID = candidate.SameAsLocationID, + SameAsCandidateKey = candidate.SameAsCandidateKey, + ObservationCount = candidate.ObservationCount, + FirstChapterNumber = candidate.FirstChapterNumber, + FirstSceneNumber = candidate.FirstSceneNumber, + Evidence = evidenceLookup.GetValueOrDefault(candidate.CoreImportBasicLocationCandidateID, []) + .Select(evidence => new CoreImportBasicLocationEvidenceViewModel + { + ChapterNumber = evidence.ChapterNumber, + SceneNumber = evidence.SceneNumber, + EvidenceText = evidence.EvidenceText + }) + .ToList() + }).ToList() + }; + } + + public async Task IsCompleteAsync(int userId, Guid previewId) + { + var review = await reviews.GetByPreviewAsync(userId, previewId); + return review is not null && IsComplete(review.Status); + } + + public async Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form) + { + var review = await reviews.GetByPreviewAsync(userId, form.PreviewID) + ?? throw new InvalidOperationException("The basic location review could not be found."); + foreach (var item in form.Locations) + { + var action = NormalizeAction(item.Action); + var decision = new CoreImportBasicLocationDecision + { + StableKey = item.StableKey, + PreferredName = Clean(item.PreferredName), + Action = action, + ParentLocationID = action == CoreImportBasicLocationCandidateActions.Ignore ? null : item.ParentLocationID, + ParentCandidateKey = action == CoreImportBasicLocationCandidateActions.Ignore ? null : CleanOptional(item.ParentCandidateKey), + SameAsLocationID = action == CoreImportBasicLocationCandidateActions.SameLocation ? item.SameAsLocationID : null, + SameAsCandidateKey = action == CoreImportBasicLocationCandidateActions.SameLocation ? CleanOptional(item.SameAsCandidateKey) : null + }; + await reviews.SaveDecisionAsync(review.CoreImportBasicLocationReviewID, decision); + } + } + + public async Task CompleteAsync(int userId, Guid previewId) + { + var review = await reviews.GetByPreviewAsync(userId, previewId) + ?? throw new InvalidOperationException("The basic location review could not be found."); + var (candidates, _) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID); + var lookup = candidates.ToDictionary(candidate => candidate.StableKey, StringComparer.Ordinal); + var resolved = new Dictionary(StringComparer.Ordinal); + var resolving = new HashSet(StringComparer.Ordinal); + + foreach (var candidate in candidates.OrderBy(candidate => candidate.SortOrder)) + { + if (string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + await ResolveCandidateAsync(candidate.StableKey); + } + + await reviews.MarkCompleteAsync(review.CoreImportBasicLocationReviewID); + + async Task ResolveCandidateAsync(string stableKey) + { + if (resolved.TryGetValue(stableKey, out var existingResolved)) + { + return existingResolved; + } + + if (!lookup.TryGetValue(stableKey, out var candidate) + || string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!resolving.Add(stableKey)) + { + throw new InvalidOperationException("Location candidate grouping contains a cycle. Review the 'Same location as' choices and try again."); + } + + try + { + int locationId; + if (candidate.SameAsLocationID.HasValue) + { + locationId = candidate.SameAsLocationID.Value; + } + else if (!string.IsNullOrWhiteSpace(candidate.SameAsCandidateKey) + && !string.Equals(candidate.SameAsCandidateKey, stableKey, StringComparison.Ordinal)) + { + var sameAsLocation = await ResolveCandidateAsync(candidate.SameAsCandidateKey); + if (!sameAsLocation.HasValue) + { + throw new InvalidOperationException($"The location '{candidate.PreferredName}' points at a candidate that will not be created."); + } + + locationId = sameAsLocation.Value; + } + else if (candidate.ExistingLocationID.HasValue) + { + locationId = candidate.ExistingLocationID.Value; + } + else + { + int? parentLocationId = candidate.ParentLocationID; + if (!parentLocationId.HasValue && !string.IsNullOrWhiteSpace(candidate.ParentCandidateKey)) + { + parentLocationId = await ResolveCandidateAsync(candidate.ParentCandidateKey); + } + + locationId = await locations.SaveAsync(new LocationItem + { + ProjectID = review.ProjectID, + ParentLocationID = parentLocationId, + LocationName = Clean(candidate.PreferredName), + ShowInQuickAddBar = false, + ExcludeFromCompanionDetection = false, + DetectionPriority = 50 + }); + } + + await SyncAliasesAsync(locationId, ReadKnownNames(candidate).Append(candidate.PreferredName)); + await reviews.SetResultAsync(review.CoreImportBasicLocationReviewID, stableKey, locationId); + resolved[stableKey] = locationId; + return locationId; + } + finally + { + resolving.Remove(stableKey); + } + } + } + + public async Task SkipAsync(int userId, Guid previewId) + { + var review = await reviews.GetByPreviewAsync(userId, previewId) + ?? throw new InvalidOperationException("The basic location review could not be found."); + await reviews.MarkSkippedAsync(review.CoreImportBasicLocationReviewID); + } + + public async Task> GetProtectedLocationNamesAsync(int userId, Guid previewId) + { + var review = await reviews.GetByPreviewAsync(userId, previewId); + if (review is null || !IsComplete(review.Status)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var projectLocations = await locations.ListByProjectAsync(review.ProjectID); + var aliases = await locations.ListAliasesByProjectAsync(review.ProjectID); + var names = projectLocations + .Select(location => location.LocationName) + .Concat(aliases.Select(alias => alias.Alias)) + .Select(CoreImportBasicLocationDetector.NormalizeName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return names; + } + + private async Task SyncAliasesAsync(int locationId, IEnumerable names) + { + var existing = await locations.ListAliasesAsync(locationId); + var existingNames = existing.Select(alias => alias.Alias).ToHashSet(StringComparer.OrdinalIgnoreCase); + var sortOrder = existing.Select(alias => alias.SortOrder).DefaultIfEmpty(0).Max() + 10; + foreach (var name in names.Select(Clean).Where(name => !string.IsNullOrWhiteSpace(name)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (existingNames.Contains(name)) + { + continue; + } + + await locations.AddAliasAsync(locationId, name, sortOrder); + sortOrder += 10; + } + } + + private static IReadOnlyList ReadKnownNames(CoreImportBasicLocationCandidate candidate) + { + try + { + return JsonSerializer.Deserialize>(candidate.KnownNamesJson) ?? []; + } + catch (JsonException) + { + return [candidate.ProposedName]; + } + } + + private static bool IsComplete(string status) + => string.Equals(status, CoreImportBasicLocationReviewStatuses.Completed, StringComparison.OrdinalIgnoreCase) + || string.Equals(status, CoreImportBasicLocationReviewStatuses.Skipped, StringComparison.OrdinalIgnoreCase); + + private static string NormalizeAction(string? action) + => string.Equals(action, CoreImportBasicLocationCandidateActions.SameLocation, StringComparison.OrdinalIgnoreCase) + ? CoreImportBasicLocationCandidateActions.SameLocation + : string.Equals(action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase) + ? CoreImportBasicLocationCandidateActions.Ignore + : CoreImportBasicLocationCandidateActions.Create; + + private static string Clean(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim(); + + private static string? CleanOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 81e2c5e..6f81c7d 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -12,6 +12,12 @@ public interface IOnboardingService Task GetWizardAsync(); Task GetScanReviewAsync(Guid? previewId = null); Task SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport); + Task GetBasicLocationReviewAsync(Guid previewId); + Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form); + Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form); + Task SkipBasicLocationReviewAsync(Guid previewId); + Task IsBasicLocationReviewCompleteAsync(Guid previewId); + Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId); Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form); Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? progress = null); Task GetBuildResultAsync(Guid previewId); @@ -45,6 +51,7 @@ public sealed class OnboardingService( IManuscriptStructureAnalysisService structureAnalysis, IManuscriptTextStatisticsService manuscriptTextStatistics, ICoreImportCharacterMappingService coreImportCharacterMapping, + ICoreImportBasicLocationService coreImportBasicLocations, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) @@ -231,6 +238,73 @@ public sealed class OnboardingService( return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build)); } + public async Task GetBasicLocationReviewAsync(Guid previewId) + { + var context = await GetScanReviewContextAsync(previewId); + if (context is null) + { + return null; + } + + var (userId, preview, _, project, book, _, contextBuild) = context.Value; + var build = contextBuild + ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); + if (build is null || build.SceneMappings.Count == 0) + { + return null; + } + + var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); + await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, build.BuildID, sceneSources); + return await coreImportBasicLocations.GetReviewAsync(userId, preview.PreviewID, preview.DocumentTitle ?? "Word manuscript", project.ProjectName, BookOptionTitle(book)); + } + + public async Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form) + { + await coreImportBasicLocations.SaveDecisionsAsync(RequireUserId(), form); + } + + public async Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form) + { + var userId = RequireUserId(); + await coreImportBasicLocations.SaveDecisionsAsync(userId, form); + await coreImportBasicLocations.CompleteAsync(userId, form.PreviewID); + await EnsureCharacterCandidatesAfterLocationReviewAsync(form.PreviewID); + } + + public async Task SkipBasicLocationReviewAsync(Guid previewId) + { + var userId = RequireUserId(); + await coreImportBasicLocations.SkipAsync(userId, previewId); + await EnsureCharacterCandidatesAfterLocationReviewAsync(previewId); + } + + public async Task IsBasicLocationReviewCompleteAsync(Guid previewId) + => await coreImportBasicLocations.IsCompleteAsync(RequireUserId(), previewId); + + public async Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId) + { + var context = await GetScanReviewContextAsync(previewId) + ?? throw new InvalidOperationException("The scan review could not be opened. Please scan the manuscript again."); + var (userId, preview, _, _, _, _, contextBuild) = context; + var build = contextBuild + ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); + if (build is null || build.SceneMappings.Count == 0) + { + throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); + } + + if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) + { + throw new InvalidOperationException("Review or skip basic locations before reviewing characters."); + } + + var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); + await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources); + } + public async Task BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func? progress = null) { var context = await GetScanReviewContextAsync(previewId); @@ -290,7 +364,9 @@ public sealed class OnboardingService( preview.PreviewID, repairedSceneSources ? "Scene source text recovered. Updating Word markers." - : "Resuming Word marker sync. No scene detection was rerun.", + : await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID) + ? "Resuming Word marker sync. No scene detection was rerun." + : "Basic location review is ready. Review locations before character candidates.", 90, progress, resumed); @@ -363,10 +439,27 @@ public sealed class OnboardingService( await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); await ApplyStructuralPovAsync(preview, structuralScenes, result); var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); - var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources); - await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); + var locationReview = await coreImportBasicLocations.EnsurePreparedAsync( + userId, + preview.PreviewID, + preview.ProjectID, + preview.BookID, + result.BuildID, + canonicalSceneSources); + if (locationReview is not null + && await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) + { + await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, canonicalSceneSources); + } await scanPreviews.SaveBuildResultAsync(userId, result); - await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress); + await PublishBuildProgress( + userId, + preview.PreviewID, + locationReview is not null && !await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID) + ? "Basic location review is ready. Review locations before character candidates." + : "Updating Word document with scene markers. Keep Word open while the document is saved.", + 90, + progress); return result; } @@ -657,8 +750,11 @@ public sealed class OnboardingService( OnboardingManuscriptBuildResult existingBuild) { var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); - var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources); - await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); + await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, existingBuild.BuildID, sceneSources); + if (await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) + { + await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources); + } return new OnboardingManuscriptBuildResult { @@ -686,6 +782,16 @@ public sealed class OnboardingService( }; } + private async Task PrepareCharacterCandidatesAfterLocationReviewAsync( + int userId, + ManuscriptScanPreview preview, + IReadOnlyList sceneSources) + { + var protectedLocationNames = await coreImportBasicLocations.GetProtectedLocationNamesAsync(userId, preview.PreviewID); + var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources, protectedLocationNames); + await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); + } + public async Task GetBuildResultAsync(Guid previewId) { var userId = RequireUserId(); @@ -709,6 +815,10 @@ public sealed class OnboardingService( { throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); } + if (!await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID)) + { + throw new InvalidOperationException("Review or skip basic locations before reviewing characters."); + } var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal); var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet(); diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index ebb49a6..98baa4c 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -724,8 +724,13 @@ public sealed class WordCompanionService( } public static IReadOnlyList DiscoverCoreImportCharacterCandidates( - IReadOnlyList sourceScenes) + IReadOnlyList sourceScenes, + IEnumerable? excludedNames = null) { + var excluded = (excludedNames ?? []) + .Select(CoreImportBasicLocationDetector.StableKey) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .ToHashSet(StringComparer.Ordinal); var orderedScenes = sourceScenes .OrderBy(scene => scene.ChapterID) .ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue) @@ -750,6 +755,7 @@ public sealed class WordCompanionService( return DiscoverCharacterCandidates(manuscriptText) .Where(IsAutoSelectedScanCharacterCandidate) + .Where(candidate => !excluded.Contains(CoreImportBasicLocationDetector.StableKey(candidate.Text))) .Select((candidate, index) => { var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers); diff --git a/PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql b/PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql new file mode 100644 index 0000000..9449295 --- /dev/null +++ b/PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql @@ -0,0 +1,375 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CoreImportBasicLocationReviews + ( + CoreImportBasicLocationReviewID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationReviews PRIMARY KEY, + PreviewID uniqueidentifier NOT NULL, + UserID int NOT NULL, + ProjectID int NOT NULL, + BookID int NOT NULL, + BuildID int NULL, + Status nvarchar(40) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_Status DEFAULT N'Pending', + RawObservationCount int NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_RawObservationCount DEFAULT 0, + CandidateCount int NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_CandidateCount DEFAULT 0, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationReviews_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CompletedUtc datetime2(0) NULL, + CONSTRAINT FK_CoreImportBasicLocationReviews_Book FOREIGN KEY (BookID) REFERENCES dbo.Books(BookID) + ); +END; +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CoreImportBasicLocationCandidates + ( + CoreImportBasicLocationCandidateID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationCandidates PRIMARY KEY, + CoreImportBasicLocationReviewID int NOT NULL, + StableKey nvarchar(200) NOT NULL, + ProposedName nvarchar(200) NOT NULL, + PreferredName nvarchar(200) NOT NULL, + KnownNamesJson nvarchar(max) NOT NULL, + ParentLocationID int NULL, + ParentCandidateKey nvarchar(200) NULL, + ExistingLocationID int NULL, + Action nvarchar(40) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_Action DEFAULT N'Create', + SameAsLocationID int NULL, + SameAsCandidateKey nvarchar(200) NULL, + ResultLocationID int NULL, + FirstChapterNumber int NOT NULL, + FirstSceneNumber int NOT NULL, + FirstOccurrenceIndex int NOT NULL, + ObservationCount int NOT NULL, + SortOrder int NOT NULL, + CreatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2(0) NOT NULL CONSTRAINT DF_CoreImportBasicLocationCandidates_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_CoreImportBasicLocationCandidates_LocationParent FOREIGN KEY (ParentLocationID) REFERENCES dbo.Locations(LocationID), + CONSTRAINT FK_CoreImportBasicLocationCandidates_ExistingLocation FOREIGN KEY (ExistingLocationID) REFERENCES dbo.Locations(LocationID), + CONSTRAINT FK_CoreImportBasicLocationCandidates_SameAsLocation FOREIGN KEY (SameAsLocationID) REFERENCES dbo.Locations(LocationID), + CONSTRAINT FK_CoreImportBasicLocationCandidates_ResultLocation FOREIGN KEY (ResultLocationID) REFERENCES dbo.Locations(LocationID) + ); +END; +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NULL +BEGIN + CREATE TABLE dbo.CoreImportBasicLocationEvidence + ( + CoreImportBasicLocationEvidenceID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CoreImportBasicLocationEvidence PRIMARY KEY, + CoreImportBasicLocationCandidateID int NOT NULL, + SceneID int NOT NULL, + ChapterID int NOT NULL, + ChapterNumber int NOT NULL, + SceneNumber int NOT NULL, + OccurrenceIndex int NOT NULL, + EvidenceText nvarchar(400) NOT NULL, + SortOrder int NOT NULL, + CONSTRAINT FK_CoreImportBasicLocationEvidence_Candidate FOREIGN KEY (CoreImportBasicLocationCandidateID) + REFERENCES dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationCandidateID) ON DELETE CASCADE + ); +END; +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_CoreImportBasicLocationCandidates_Review') +BEGIN + ALTER TABLE dbo.CoreImportBasicLocationCandidates + ADD CONSTRAINT FK_CoreImportBasicLocationCandidates_Review FOREIGN KEY (CoreImportBasicLocationReviewID) + REFERENCES dbo.CoreImportBasicLocationReviews(CoreImportBasicLocationReviewID) ON DELETE CASCADE; +END; +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NOT NULL + AND OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CoreImportBasicLocationCandidates_Review_Key') + CREATE UNIQUE INDEX UX_CoreImportBasicLocationCandidates_Review_Key + ON dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationReviewID, StableKey); +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationReviews', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_CoreImportBasicLocationReviews_Preview') + CREATE UNIQUE INDEX UX_CoreImportBasicLocationReviews_Preview + ON dbo.CoreImportBasicLocationReviews(PreviewID); +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationCandidates', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CoreImportBasicLocationCandidates_Review_Sort') + CREATE INDEX IX_CoreImportBasicLocationCandidates_Review_Sort + ON dbo.CoreImportBasicLocationCandidates(CoreImportBasicLocationReviewID, SortOrder, CoreImportBasicLocationCandidateID); +GO + +IF OBJECT_ID(N'dbo.CoreImportBasicLocationEvidence', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_CoreImportBasicLocationEvidence_Candidate_Sort') + CREATE INDEX IX_CoreImportBasicLocationEvidence_Candidate_Sort + ON dbo.CoreImportBasicLocationEvidence(CoreImportBasicLocationCandidateID, SortOrder, CoreImportBasicLocationEvidenceID); +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_GetByPreview + @UserID int, + @PreviewID uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + SELECT TOP (1) * + FROM dbo.CoreImportBasicLocationReviews + WHERE UserID = @UserID AND PreviewID = @PreviewID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_Create + @UserID int, + @PreviewID uniqueidentifier, + @ProjectID int, + @BookID int, + @BuildID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF EXISTS (SELECT 1 FROM dbo.CoreImportBasicLocationReviews WHERE PreviewID = @PreviewID AND UserID = @UserID) + BEGIN + UPDATE dbo.CoreImportBasicLocationReviews + SET BuildID = COALESCE(@BuildID, BuildID), + UpdatedUtc = SYSUTCDATETIME() + WHERE PreviewID = @PreviewID AND UserID = @UserID; + END + ELSE + BEGIN + INSERT dbo.CoreImportBasicLocationReviews (PreviewID, UserID, ProjectID, BookID, BuildID) + VALUES (@PreviewID, @UserID, @ProjectID, @BookID, @BuildID); + END + + EXEC dbo.CoreImportBasicLocationReview_GetByPreview @UserID = @UserID, @PreviewID = @PreviewID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveCandidate + @CoreImportBasicLocationReviewID int, + @StableKey nvarchar(200), + @ProposedName nvarchar(200), + @PreferredName nvarchar(200), + @KnownNamesJson nvarchar(max), + @ParentLocationID int = NULL, + @ParentCandidateKey nvarchar(200) = NULL, + @ExistingLocationID int = NULL, + @FirstChapterNumber int, + @FirstSceneNumber int, + @FirstOccurrenceIndex int, + @ObservationCount int, + @SortOrder int +AS +BEGIN + SET NOCOUNT ON; + + IF EXISTS + ( + SELECT 1 + FROM dbo.CoreImportBasicLocationCandidates + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + AND StableKey = @StableKey + ) + BEGIN + UPDATE dbo.CoreImportBasicLocationCandidates + SET ProposedName = @ProposedName, + PreferredName = @PreferredName, + KnownNamesJson = @KnownNamesJson, + ParentLocationID = @ParentLocationID, + ParentCandidateKey = @ParentCandidateKey, + ExistingLocationID = @ExistingLocationID, + FirstChapterNumber = @FirstChapterNumber, + FirstSceneNumber = @FirstSceneNumber, + FirstOccurrenceIndex = @FirstOccurrenceIndex, + ObservationCount = @ObservationCount, + SortOrder = @SortOrder, + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + AND StableKey = @StableKey; + END + ELSE + BEGIN + INSERT dbo.CoreImportBasicLocationCandidates + ( + CoreImportBasicLocationReviewID, StableKey, ProposedName, PreferredName, KnownNamesJson, + ParentLocationID, ParentCandidateKey, ExistingLocationID, + FirstChapterNumber, FirstSceneNumber, FirstOccurrenceIndex, ObservationCount, SortOrder + ) + VALUES + ( + @CoreImportBasicLocationReviewID, @StableKey, @ProposedName, @PreferredName, @KnownNamesJson, + @ParentLocationID, @ParentCandidateKey, @ExistingLocationID, + @FirstChapterNumber, @FirstSceneNumber, @FirstOccurrenceIndex, @ObservationCount, @SortOrder + ); + END + + SELECT TOP (1) * + FROM dbo.CoreImportBasicLocationCandidates + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + AND StableKey = @StableKey; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveEvidence + @CoreImportBasicLocationCandidateID int, + @SceneID int, + @ChapterID int, + @ChapterNumber int, + @SceneNumber int, + @OccurrenceIndex int, + @EvidenceText nvarchar(400), + @SortOrder int +AS +BEGIN + SET NOCOUNT ON; + + INSERT dbo.CoreImportBasicLocationEvidence + ( + CoreImportBasicLocationCandidateID, SceneID, ChapterID, ChapterNumber, + SceneNumber, OccurrenceIndex, EvidenceText, SortOrder + ) + VALUES + ( + @CoreImportBasicLocationCandidateID, @SceneID, @ChapterID, @ChapterNumber, + @SceneNumber, @OccurrenceIndex, @EvidenceText, @SortOrder + ); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_DeleteEvidence + @CoreImportBasicLocationCandidateID int +AS +BEGIN + SET NOCOUNT ON; + + DELETE dbo.CoreImportBasicLocationEvidence + WHERE CoreImportBasicLocationCandidateID = @CoreImportBasicLocationCandidateID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_CompletePreparation + @CoreImportBasicLocationReviewID int, + @RawObservationCount int, + @CandidateCount int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.CoreImportBasicLocationReviews + SET RawObservationCount = @RawObservationCount, + CandidateCount = @CandidateCount, + Status = CASE WHEN @CandidateCount = 0 THEN N'Completed' ELSE Status END, + CompletedUtc = CASE WHEN @CandidateCount = 0 THEN COALESCE(CompletedUtc, SYSUTCDATETIME()) ELSE CompletedUtc END, + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_ListCandidates + @CoreImportBasicLocationReviewID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT * + FROM dbo.CoreImportBasicLocationCandidates + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + ORDER BY SortOrder, CoreImportBasicLocationCandidateID; + + SELECT e.* + FROM dbo.CoreImportBasicLocationEvidence e + INNER JOIN dbo.CoreImportBasicLocationCandidates c + ON c.CoreImportBasicLocationCandidateID = e.CoreImportBasicLocationCandidateID + WHERE c.CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + ORDER BY c.SortOrder, e.SortOrder, e.CoreImportBasicLocationEvidenceID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SaveDecision + @CoreImportBasicLocationReviewID int, + @StableKey nvarchar(200), + @PreferredName nvarchar(200), + @Action nvarchar(40), + @ParentLocationID int = NULL, + @ParentCandidateKey nvarchar(200) = NULL, + @SameAsLocationID int = NULL, + @SameAsCandidateKey nvarchar(200) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.CoreImportBasicLocationCandidates + SET PreferredName = COALESCE(NULLIF(LTRIM(RTRIM(@PreferredName)), N''), PreferredName), + Action = @Action, + ParentLocationID = @ParentLocationID, + ParentCandidateKey = NULLIF(LTRIM(RTRIM(@ParentCandidateKey)), N''), + SameAsLocationID = @SameAsLocationID, + SameAsCandidateKey = NULLIF(LTRIM(RTRIM(@SameAsCandidateKey)), N''), + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + AND StableKey = @StableKey; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_SetResult + @CoreImportBasicLocationReviewID int, + @StableKey nvarchar(200), + @ResultLocationID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.CoreImportBasicLocationCandidates + SET ResultLocationID = @ResultLocationID, + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID + AND StableKey = @StableKey; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_MarkComplete + @CoreImportBasicLocationReviewID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.CoreImportBasicLocationReviews + SET Status = N'Completed', + CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.CoreImportBasicLocationReview_MarkSkipped + @CoreImportBasicLocationReviewID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.CoreImportBasicLocationReviews + SET Status = N'Skipped', + CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), + UpdatedUtc = SYSUTCDATETIME() + WHERE CoreImportBasicLocationReviewID = @CoreImportBasicLocationReviewID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.LocationAlias_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT la.LocationAliasID, la.LocationID, la.Alias, la.SortOrder, la.CreatedUtc + FROM dbo.LocationAliases la + INNER JOIN dbo.Locations l ON l.LocationID = la.LocationID + WHERE l.ProjectID = @ProjectID + AND l.IsArchived = 0 + ORDER BY l.LocationName, la.SortOrder, la.LocationAliasID; +END; +GO diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index fab83fe..a9fa9e3 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -948,6 +948,72 @@ public sealed class ManuscriptScanExistingCharacterOptionViewModel public string CharacterName { get; init; } = string.Empty; } +public sealed class CoreImportBasicLocationReviewViewModel +{ + public Guid PreviewID { get; init; } + public int ReviewID { get; init; } + public string Status { get; init; } = CoreImportBasicLocationReviewStatuses.Pending; + public string DocumentTitle { get; init; } = "Word manuscript"; + public string SelectedProjectName { get; init; } = string.Empty; + public string SelectedBookTitle { get; init; } = string.Empty; + public int RawObservationCount { get; init; } + public int CandidateCount { get; init; } + public IReadOnlyList Candidates { get; init; } = []; + public IReadOnlyList ExistingLocationOptions { get; init; } = []; + public bool IsComplete => string.Equals(Status, CoreImportBasicLocationReviewStatuses.Completed, StringComparison.OrdinalIgnoreCase) + || string.Equals(Status, CoreImportBasicLocationReviewStatuses.Skipped, StringComparison.OrdinalIgnoreCase); +} + +public sealed class CoreImportBasicLocationCandidateViewModel +{ + public string StableKey { get; init; } = string.Empty; + public string ProposedName { get; init; } = string.Empty; + public string PreferredName { get; init; } = string.Empty; + public IReadOnlyList KnownNames { get; init; } = []; + public string Action { get; init; } = CoreImportBasicLocationCandidateActions.Create; + public int? ParentLocationID { get; init; } + public string? ParentCandidateKey { get; init; } + public int? SameAsLocationID { get; init; } + public string? SameAsCandidateKey { get; init; } + public int ObservationCount { get; init; } + public int FirstChapterNumber { get; init; } + public int FirstSceneNumber { get; init; } + public IReadOnlyList Evidence { get; init; } = []; +} + +public sealed class CoreImportBasicLocationEvidenceViewModel +{ + public int ChapterNumber { get; init; } + public int SceneNumber { get; init; } + public string EvidenceText { get; init; } = string.Empty; + public string Label => $"Chapter {ChapterNumber:N0} - Scene {SceneNumber:N0}"; +} + +public sealed class CoreImportLocationOptionViewModel +{ + public int LocationID { get; init; } + public string LocationName { get; init; } = string.Empty; + public string LocationPath { get; init; } = string.Empty; + public string Label => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath; +} + +public sealed class CoreImportBasicLocationReviewForm +{ + public Guid PreviewID { get; set; } + public List Locations { get; set; } = []; +} + +public sealed class CoreImportBasicLocationReviewFormItem +{ + public string StableKey { get; set; } = string.Empty; + public string PreferredName { get; set; } = string.Empty; + public string Action { get; set; } = CoreImportBasicLocationCandidateActions.Create; + public int? ParentLocationID { get; set; } + public string? ParentCandidateKey { get; set; } + public int? SameAsLocationID { get; set; } + public string? SameAsCandidateKey { get; set; } +} + public sealed class ManuscriptScanReviewForm { public Guid PreviewID { get; set; } diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 768e02a..a28e141 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -116,7 +116,7 @@ } else { - Review characters + Review locations + + + + + +