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