Add core import basic location review stage
This commit is contained in:
parent
bb62762b78
commit
1cf0174466
@ -115,6 +115,11 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
|
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
|
||||||
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
|
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
|
||||||
("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters),
|
("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 character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific),
|
||||||
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
|
("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
|
||||||
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
|
("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("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(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.Contains("SaveSceneManuscriptSourcesAsync", StringComparison.Ordinal), "Structural import should persist scene source text before Word sync.");
|
||||||
Assert(onboarding.IndexOf("await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result)", StringComparison.Ordinal)
|
var saveSceneSourceIndex = 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 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("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.");
|
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("<h2>Chapters</h2>", StringComparison.Ordinal), "Review Chapters section heading should not mention scenes.");
|
Assert(scanReview.Contains("<h2>Chapters</h2>", 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(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(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("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("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("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.");
|
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<SceneManuscriptSourceRecord>
|
||||||
|
{
|
||||||
|
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<SceneManuscriptSourceRecord>
|
||||||
|
{
|
||||||
|
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<SceneManuscriptSourceRecord>
|
||||||
|
{
|
||||||
|
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<SceneManuscriptSourceRecord>
|
||||||
|
{
|
||||||
|
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()
|
static void CoreImportCharacterEvidenceIsCandidateSpecific()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
|||||||
@ -74,11 +74,75 @@ public sealed class OnboardingController(
|
|||||||
return RedirectToAction(nameof(Index));
|
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["ScanReviewStep"] = "characters";
|
||||||
ViewData["CharacterReviewPage"] = Math.Max(1, page);
|
ViewData["CharacterReviewPage"] = Math.Max(1, page);
|
||||||
return View("ScanReview", model);
|
return View("ScanReview", model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("scan-locations")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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")]
|
[HttpGet("build-complete")]
|
||||||
public async Task<IActionResult> BuildComplete(Guid previewId)
|
public async Task<IActionResult> BuildComplete(Guid previewId)
|
||||||
{
|
{
|
||||||
@ -684,6 +748,11 @@ public sealed class OnboardingController(
|
|||||||
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
|
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
|
||||||
return RedirectToAction(nameof(Index));
|
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
|
TempData["OnboardingReviewMessage"] = readyToImport
|
||||||
@ -694,7 +763,7 @@ public sealed class OnboardingController(
|
|||||||
return readyToImport
|
return readyToImport
|
||||||
? RedirectToAction(nameof(Index))
|
? RedirectToAction(nameof(Index))
|
||||||
: continueCharacterReview || backCharacterReview ? RedirectToAction(nameof(ScanCharacters), new { previewId = form.PreviewID, page = nextCharacterPage })
|
: 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 });
|
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex)
|
catch (InvalidOperationException ex)
|
||||||
|
|||||||
160
PlotLine/Data/CoreImportBasicLocationReviewRepository.cs
Normal file
160
PlotLine/Data/CoreImportBasicLocationReviewRepository.cs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
using System.Data;
|
||||||
|
using Dapper;
|
||||||
|
using PlotLine.Models;
|
||||||
|
|
||||||
|
namespace PlotLine.Data;
|
||||||
|
|
||||||
|
public interface ICoreImportBasicLocationReviewRepository
|
||||||
|
{
|
||||||
|
Task<CoreImportBasicLocationReview?> GetByPreviewAsync(int userId, Guid previewId);
|
||||||
|
Task<CoreImportBasicLocationReview> CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId);
|
||||||
|
Task<CoreImportBasicLocationCandidate> 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<CoreImportBasicLocationCandidate> Candidates, IReadOnlyList<CoreImportBasicLocationEvidence> 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<CoreImportBasicLocationReview?> GetByPreviewAsync(int userId, Guid previewId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleOrDefaultAsync<CoreImportBasicLocationReview>(
|
||||||
|
"dbo.CoreImportBasicLocationReview_GetByPreview",
|
||||||
|
new { UserID = userId, PreviewID = previewId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CoreImportBasicLocationReview> CreateAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<CoreImportBasicLocationReview>(
|
||||||
|
"dbo.CoreImportBasicLocationReview_Create",
|
||||||
|
new { UserID = userId, PreviewID = previewId, ProjectID = projectId, BookID = bookId, BuildID = buildId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CoreImportBasicLocationCandidate> SaveCandidateAsync(int reviewId, CoreImportBasicLocationCandidate candidate)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
return await connection.QuerySingleAsync<CoreImportBasicLocationCandidate>(
|
||||||
|
"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<CoreImportBasicLocationCandidate> Candidates, IReadOnlyList<CoreImportBasicLocationEvidence> 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<CoreImportBasicLocationCandidate>()).ToList();
|
||||||
|
var evidence = (await rows.ReadAsync<CoreImportBasicLocationEvidence>()).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -265,6 +265,7 @@ public interface ILocationRepository
|
|||||||
Task<LocationItem?> GetAsync(int locationId);
|
Task<LocationItem?> GetAsync(int locationId);
|
||||||
Task<int> SaveAsync(LocationItem location);
|
Task<int> SaveAsync(LocationItem location);
|
||||||
Task<IReadOnlyList<LocationAlias>> ListAliasesAsync(int locationId);
|
Task<IReadOnlyList<LocationAlias>> ListAliasesAsync(int locationId);
|
||||||
|
Task<IReadOnlyList<LocationAlias>> ListAliasesByProjectAsync(int projectId);
|
||||||
Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder);
|
Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder);
|
||||||
Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder);
|
Task UpdateAliasAsync(int locationAliasId, string alias, int? sortOrder);
|
||||||
Task DeleteAliasAsync(int locationAliasId);
|
Task DeleteAliasAsync(int locationAliasId);
|
||||||
@ -539,6 +540,16 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LocationAlias>> ListAliasesByProjectAsync(int projectId)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
var rows = await connection.QueryAsync<LocationAlias>(
|
||||||
|
"dbo.LocationAlias_ListByProject",
|
||||||
|
new { ProjectID = projectId },
|
||||||
|
commandType: CommandType.StoredProcedure);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder)
|
public async Task<int> AddAliasAsync(int locationId, string alias, int? sortOrder)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -148,6 +148,92 @@ public sealed class ManuscriptScanCharacterEvidenceScene
|
|||||||
public string? StructuralSummary { get; init; }
|
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<CoreImportBasicLocationEvidence> 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<CoreImportBasicLocationCandidate> Candidates { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanState
|
public sealed class ManuscriptScanState
|
||||||
{
|
{
|
||||||
public Guid? ScanID { get; init; }
|
public Guid? ScanID { get; init; }
|
||||||
|
|||||||
@ -146,6 +146,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
builder.Services.AddScoped<IWordCompanionRepository, WordCompanionRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
builder.Services.AddScoped<IOnboardingRepository, OnboardingRepository>();
|
||||||
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
builder.Services.AddScoped<IOnboardingBuildRepository, OnboardingBuildRepository>();
|
||||||
|
builder.Services.AddScoped<ICoreImportBasicLocationReviewRepository, CoreImportBasicLocationReviewRepository>();
|
||||||
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
|
builder.Services.AddScoped<IManuscriptTextStatisticsRepository, ManuscriptTextStatisticsRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceRepository, StoryIntelligenceRepository>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
builder.Services.AddScoped<IStoryIntelligenceResultRepository, StoryIntelligenceResultRepository>();
|
||||||
@ -222,6 +223,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
|
builder.Services.AddScoped<IManuscriptStructureAnalysisService, ManuscriptStructureAnalysisService>();
|
||||||
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
|
builder.Services.AddScoped<IManuscriptTextStatisticsService, ManuscriptTextStatisticsService>();
|
||||||
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
|
builder.Services.AddScoped<ICoreImportCharacterMappingService, CoreImportCharacterMappingService>();
|
||||||
|
builder.Services.AddScoped<ICoreImportBasicLocationService, CoreImportBasicLocationService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
builder.Services.AddScoped<IStoryIntelligenceResultPersistenceService, StoryIntelligenceResultPersistenceService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
builder.Services.AddScoped<IStoryIntelligenceExistingChapterQueueService, StoryIntelligenceExistingChapterQueueService>();
|
||||||
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
builder.Services.AddScoped<IStoryIntelligenceFullChapterTestService, StoryIntelligenceFullChapterTestService>();
|
||||||
|
|||||||
291
PlotLine/Services/CoreImportBasicLocationDetector.cs
Normal file
291
PlotLine/Services/CoreImportBasicLocationDetector.cs
Normal file
@ -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<string> 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<string> DirectionalStarts = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"behind", "beside", "inside", "outside", "near", "under", "upstairs", "downstairs"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static CoreImportBasicLocationDiscoveryResult Discover(
|
||||||
|
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
|
||||||
|
IReadOnlyList<CoreImportExistingLocationName> 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<LocationObservation>();
|
||||||
|
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<LocationObservation> ExtractObservations(
|
||||||
|
string text,
|
||||||
|
SceneManuscriptSourceRecord scene,
|
||||||
|
int chapterNumber,
|
||||||
|
int sceneNumber)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(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<LocationObservation> observations,
|
||||||
|
IReadOnlyDictionary<string, int> 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 = @"(?<parent>(?: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<Regex> DetectionRegexes()
|
||||||
|
=>
|
||||||
|
[
|
||||||
|
AddressRegex(),
|
||||||
|
PossessiveResidenceRegex(),
|
||||||
|
NamedRoadRegex(),
|
||||||
|
NamedVenueRegex(),
|
||||||
|
ThePlaceRegex()
|
||||||
|
];
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\b(?<name>\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(?<name>[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(?<name>(?: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(?<name>(?: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(?<name>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);
|
||||||
|
}
|
||||||
310
PlotLine/Services/CoreImportBasicLocationService.cs
Normal file
310
PlotLine/Services/CoreImportBasicLocationService.cs
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using PlotLine.Data;
|
||||||
|
using PlotLine.Models;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public interface ICoreImportBasicLocationService
|
||||||
|
{
|
||||||
|
Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList<SceneManuscriptSourceRecord> sceneSources);
|
||||||
|
Task<CoreImportBasicLocationReviewViewModel?> GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle);
|
||||||
|
Task<bool> IsCompleteAsync(int userId, Guid previewId);
|
||||||
|
Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form);
|
||||||
|
Task CompleteAsync(int userId, Guid previewId);
|
||||||
|
Task SkipAsync(int userId, Guid previewId);
|
||||||
|
Task<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CoreImportBasicLocationService(
|
||||||
|
ICoreImportBasicLocationReviewRepository reviews,
|
||||||
|
ILocationRepository locations) : ICoreImportBasicLocationService
|
||||||
|
{
|
||||||
|
public async Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(
|
||||||
|
int userId,
|
||||||
|
Guid previewId,
|
||||||
|
int projectId,
|
||||||
|
int bookId,
|
||||||
|
int? buildId,
|
||||||
|
IReadOnlyList<SceneManuscriptSourceRecord> 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<CoreImportBasicLocationReviewViewModel?> 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<bool> 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<string, int>(StringComparer.Ordinal);
|
||||||
|
var resolving = new HashSet<string>(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<int?> 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<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId)
|
||||||
|
{
|
||||||
|
var review = await reviews.GetByPreviewAsync(userId, previewId);
|
||||||
|
if (review is null || !IsComplete(review.Status))
|
||||||
|
{
|
||||||
|
return new HashSet<string>(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<string> 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<string> ReadKnownNames(CoreImportBasicLocationCandidate candidate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<string>>(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();
|
||||||
|
}
|
||||||
@ -12,6 +12,12 @@ public interface IOnboardingService
|
|||||||
Task<OnboardingWizardViewModel> GetWizardAsync();
|
Task<OnboardingWizardViewModel> GetWizardAsync();
|
||||||
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
||||||
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
||||||
|
Task<CoreImportBasicLocationReviewViewModel?> GetBasicLocationReviewAsync(Guid previewId);
|
||||||
|
Task SaveBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form);
|
||||||
|
Task CompleteBasicLocationReviewAsync(CoreImportBasicLocationReviewForm form);
|
||||||
|
Task SkipBasicLocationReviewAsync(Guid previewId);
|
||||||
|
Task<bool> IsBasicLocationReviewCompleteAsync(Guid previewId);
|
||||||
|
Task EnsureCharacterCandidatesAfterLocationReviewAsync(Guid previewId);
|
||||||
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
|
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
|
||||||
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null);
|
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null);
|
||||||
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
|
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
|
||||||
@ -45,6 +51,7 @@ public sealed class OnboardingService(
|
|||||||
IManuscriptStructureAnalysisService structureAnalysis,
|
IManuscriptStructureAnalysisService structureAnalysis,
|
||||||
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||||
|
ICoreImportBasicLocationService coreImportBasicLocations,
|
||||||
ICurrentUserService currentUser) : IOnboardingService
|
ICurrentUserService currentUser) : IOnboardingService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> 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));
|
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CoreImportBasicLocationReviewViewModel?> 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<bool> 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<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null)
|
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, bool generateSceneTitles = true, Func<OnboardingBuildProgress, Task>? progress = null)
|
||||||
{
|
{
|
||||||
var context = await GetScanReviewContextAsync(previewId);
|
var context = await GetScanReviewContextAsync(previewId);
|
||||||
@ -290,7 +364,9 @@ public sealed class OnboardingService(
|
|||||||
preview.PreviewID,
|
preview.PreviewID,
|
||||||
repairedSceneSources
|
repairedSceneSources
|
||||||
? "Scene source text recovered. Updating Word markers."
|
? "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,
|
90,
|
||||||
progress,
|
progress,
|
||||||
resumed);
|
resumed);
|
||||||
@ -363,10 +439,27 @@ public sealed class OnboardingService(
|
|||||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||||
await ApplyStructuralPovAsync(preview, structuralScenes, result);
|
await ApplyStructuralPovAsync(preview, structuralScenes, result);
|
||||||
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
||||||
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources);
|
var locationReview = await coreImportBasicLocations.EnsurePreparedAsync(
|
||||||
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
|
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 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,8 +750,11 @@ public sealed class OnboardingService(
|
|||||||
OnboardingManuscriptBuildResult existingBuild)
|
OnboardingManuscriptBuildResult existingBuild)
|
||||||
{
|
{
|
||||||
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
||||||
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources);
|
await coreImportBasicLocations.EnsurePreparedAsync(userId, preview.PreviewID, preview.ProjectID, preview.BookID, existingBuild.BuildID, sceneSources);
|
||||||
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
|
if (await coreImportBasicLocations.IsCompleteAsync(userId, preview.PreviewID))
|
||||||
|
{
|
||||||
|
await PrepareCharacterCandidatesAfterLocationReviewAsync(userId, preview, sceneSources);
|
||||||
|
}
|
||||||
|
|
||||||
return new OnboardingManuscriptBuildResult
|
return new OnboardingManuscriptBuildResult
|
||||||
{
|
{
|
||||||
@ -686,6 +782,16 @@ public sealed class OnboardingService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PrepareCharacterCandidatesAfterLocationReviewAsync(
|
||||||
|
int userId,
|
||||||
|
ManuscriptScanPreview preview,
|
||||||
|
IReadOnlyList<SceneManuscriptSourceRecord> 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<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
|
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
@ -709,6 +815,10 @@ public sealed class OnboardingService(
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
|
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 candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
|
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
|
||||||
|
|||||||
@ -724,8 +724,13 @@ public sealed class WordCompanionService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
|
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
|
||||||
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
|
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
|
||||||
|
IEnumerable<string>? excludedNames = null)
|
||||||
{
|
{
|
||||||
|
var excluded = (excludedNames ?? [])
|
||||||
|
.Select(CoreImportBasicLocationDetector.StableKey)
|
||||||
|
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
var orderedScenes = sourceScenes
|
var orderedScenes = sourceScenes
|
||||||
.OrderBy(scene => scene.ChapterID)
|
.OrderBy(scene => scene.ChapterID)
|
||||||
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
|
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
|
||||||
@ -750,6 +755,7 @@ public sealed class WordCompanionService(
|
|||||||
|
|
||||||
return DiscoverCharacterCandidates(manuscriptText)
|
return DiscoverCharacterCandidates(manuscriptText)
|
||||||
.Where(IsAutoSelectedScanCharacterCandidate)
|
.Where(IsAutoSelectedScanCharacterCandidate)
|
||||||
|
.Where(candidate => !excluded.Contains(CoreImportBasicLocationDetector.StableKey(candidate.Text)))
|
||||||
.Select((candidate, index) =>
|
.Select((candidate, index) =>
|
||||||
{
|
{
|
||||||
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);
|
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);
|
||||||
|
|||||||
375
PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql
Normal file
375
PlotLine/Sql/181_Phase27H_CoreImportBasicLocationReview.sql
Normal file
@ -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
|
||||||
@ -948,6 +948,72 @@ public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
|||||||
public string CharacterName { get; init; } = string.Empty;
|
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<CoreImportBasicLocationCandidateViewModel> Candidates { get; init; } = [];
|
||||||
|
public IReadOnlyList<CoreImportLocationOptionViewModel> 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<string> 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<CoreImportBasicLocationEvidenceViewModel> 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<CoreImportBasicLocationReviewFormItem> 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 sealed class ManuscriptScanReviewForm
|
||||||
{
|
{
|
||||||
public Guid PreviewID { get; set; }
|
public Guid PreviewID { get; set; }
|
||||||
|
|||||||
@ -116,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
|
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanLocations" asp-route-previewId="@Model.PreviewID">Review locations</a>
|
||||||
<button class="btn btn-outline-primary"
|
<button class="btn btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
data-onboarding-build-start
|
data-onboarding-build-start
|
||||||
|
|||||||
129
PlotLine/Views/Onboarding/ScanLocations.cshtml
Normal file
129
PlotLine/Views/Onboarding/ScanLocations.cshtml
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
@model PlotLine.ViewModels.CoreImportBasicLocationReviewViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Review Locations";
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="onboarding-shell">
|
||||||
|
<div class="onboarding-header">
|
||||||
|
<p class="eyebrow">Core import</p>
|
||||||
|
<h1>Review locations</h1>
|
||||||
|
<p class="lede">@Model.DocumentTitle - @Model.SelectedProjectName - @Model.SelectedBookTitle</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (TempData["OnboardingReviewMessage"] is string message)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">@message</div>
|
||||||
|
}
|
||||||
|
@if (TempData["ArchiveError"] is string error)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="onboarding-panel">
|
||||||
|
<div class="review-summary-grid">
|
||||||
|
<div>
|
||||||
|
<span class="metric-label">Raw observations</span>
|
||||||
|
<strong>@Model.RawObservationCount</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="metric-label">Location candidates</span>
|
||||||
|
<strong>@Model.CandidateCount</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form asp-controller="Onboarding" asp-action="SaveBasicLocations" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" name="PreviewID" value="@Model.PreviewID" />
|
||||||
|
|
||||||
|
<div class="location-review-list">
|
||||||
|
@for (var i = 0; i < Model.Candidates.Count; i++)
|
||||||
|
{
|
||||||
|
var candidate = Model.Candidates[i];
|
||||||
|
<article class="location-review-row">
|
||||||
|
<input type="hidden" name="Locations[@i].StableKey" value="@candidate.StableKey" />
|
||||||
|
<div class="location-review-main">
|
||||||
|
<h2>@candidate.ProposedName</h2>
|
||||||
|
<p>@candidate.ObservationCount observation@(candidate.ObservationCount == 1 ? "" : "s") - first seen in Chapter @candidate.FirstChapterNumber, Scene @candidate.FirstSceneNumber</p>
|
||||||
|
@if (candidate.KnownNames.Count > 1)
|
||||||
|
{
|
||||||
|
<p>Known names: @string.Join(", ", candidate.KnownNames)</p>
|
||||||
|
}
|
||||||
|
<div class="location-evidence">
|
||||||
|
@foreach (var evidence in candidate.Evidence.Take(3))
|
||||||
|
{
|
||||||
|
<blockquote><strong>@evidence.Label:</strong> @evidence.EvidenceText</blockquote>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-review-controls">
|
||||||
|
<label>
|
||||||
|
Decision
|
||||||
|
<select name="Locations[@i].Action">
|
||||||
|
<option value="Create" selected="@(candidate.Action == "Create" ? "selected" : null)">Create location</option>
|
||||||
|
<option value="SameLocation" selected="@(candidate.Action == "SameLocation" ? "selected" : null)">Same location as</option>
|
||||||
|
<option value="Ignore" selected="@(candidate.Action == "Ignore" ? "selected" : null)">Ignore</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Preferred name
|
||||||
|
<input type="text" name="Locations[@i].PreferredName" value="@candidate.PreferredName" maxlength="200" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Parent location
|
||||||
|
<select name="Locations[@i].ParentLocationID">
|
||||||
|
<option value="">No canonical parent</option>
|
||||||
|
@foreach (var option in Model.ExistingLocationOptions)
|
||||||
|
{
|
||||||
|
<option value="@option.LocationID" selected="@(candidate.ParentLocationID == option.LocationID ? "selected" : null)">@option.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Parent candidate
|
||||||
|
<select name="Locations[@i].ParentCandidateKey">
|
||||||
|
<option value="">No pending parent</option>
|
||||||
|
@foreach (var option in Model.Candidates.Where(item => item.StableKey != candidate.StableKey))
|
||||||
|
{
|
||||||
|
<option value="@option.StableKey" selected="@(candidate.ParentCandidateKey == option.StableKey ? "selected" : null)">@option.ProposedName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Same location as existing
|
||||||
|
<select name="Locations[@i].SameAsLocationID">
|
||||||
|
<option value="">No existing location</option>
|
||||||
|
@foreach (var option in Model.ExistingLocationOptions)
|
||||||
|
{
|
||||||
|
<option value="@option.LocationID" selected="@(candidate.SameAsLocationID == option.LocationID ? "selected" : null)">@option.Label</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Same location as candidate
|
||||||
|
<select name="Locations[@i].SameAsCandidateKey">
|
||||||
|
<option value="">No pending candidate</option>
|
||||||
|
@foreach (var option in Model.Candidates.Where(item => item.StableKey != candidate.StableKey))
|
||||||
|
{
|
||||||
|
<option value="@option.StableKey" selected="@(candidate.SameAsCandidateKey == option.StableKey ? "selected" : null)">@option.ProposedName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-secondary" type="submit" name="intent" value="save">Save choices</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="submit" name="intent" value="skip">Skip locations</button>
|
||||||
|
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save locations and review characters</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
Loading…
x
Reference in New Issue
Block a user