Make location candidates parent-aware

This commit is contained in:
Nick Beckley 2026-08-31 12:54:15 +00:00
parent 0c3f215574
commit 3c016c904e
5 changed files with 692 additions and 78 deletions

View File

@ -48,6 +48,7 @@ var tests = new (string Name, Action Test)[]
("Location contextual names use POV and structural setting", LocationContextualNamesUsePovAndStructuralSetting),
("Location canonical keys merge punctuation and safe plurals", LocationCanonicalKeysMergePunctuationAndSafePlurals),
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
@ -2622,6 +2623,54 @@ static void LocationCandidateConsolidationHandlesGenericReviewVariants()
Assert(!IsMergedLocationPhrase("Fox and Lantern"), "Generic named compounds should not need a production special-case exception.");
}
static void LocationCandidateIdentityIsParentAwareForGenericSubLocations()
{
var maggieBedroom = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Bedroom"));
var bethBedroom = ParentAwareLocationCandidateKey("beths-house", LocationCanonicalKey("Bedroom"));
var unknownBedroom = ParentAwareLocationCandidateKey(null, LocationCanonicalKey("Bedroom"));
var maggieKitchen = ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("Kitchen"));
Assert(maggieBedroom != bethBedroom, "Maggie's house Bedroom and Beth's house Bedroom should remain separate candidates.");
Assert(maggieBedroom == ParentAwareLocationCandidateKey("maggies-house", LocationCanonicalKey("The bedrooms")), "Same parent and same leaf variants should consolidate.");
Assert(unknownBedroom != maggieBedroom, "Unknown-parent Bedroom must not merge into known-parent Bedroom.");
Assert(maggieBedroom != maggieKitchen, "Same parent but different leaf locations should remain separate.");
Assert(ExplicitLocationLeaf("Zoe's bedroom") == "bedroom", "Explicit Zoe bedroom reference should keep Bedroom as leaf.");
Assert(InferExplicitLocationParent("Zoe's bedroom", "bedroom") == "Zoe's home", "Explicit Zoe bedroom reference should infer Zoe's home as parent.");
Assert(InferExplicitLocationParent("the bathroom at Beth's", "bathroom") == "Beth's home", "Bathroom at Beth's should infer Beth's home as parent.");
Assert(InferExplicitLocationParent("Mrs Patterson's kitchen", "kitchen") == "Mrs Patterson's home", "Possessive kitchen should infer an owned parent.");
Assert(InferExplicitLocationParent("bathroom to Maggie's bedroom", "bedroom") is null, "Route text before an owned bedroom should not become part of the owner.");
Assert(ParentFromOwnedLocationLeaf("Maggie's bedroom") == "Maggie's home", "Owned bedroom should expose a parent identity.");
Assert(ExtractParentFromCompositeLocationSetting("Maggie's house - living room and kitchen, late night") == "Maggie's house", "Scene parent should be inherited from a composite structural setting.");
Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; Maggie's home and bathroom") == "Maggie's home", "Owned scene container should beat broader route/location fragments.");
Assert(ExtractParentFromCompositeLocationSetting("Cock Hill Lane; bathroom") is null, "Street-level locations should not become parents for generic rooms.");
var root = PlotLineRoot();
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
Assert(service.Contains("ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key)", StringComparison.Ordinal), "Location consolidation should use parent plus leaf identity.");
Assert(service.Contains("ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex)", StringComparison.Ordinal), "Location preparation should infer parent context before keying.");
Assert(service.Contains("InferSceneParentContext(importedScene, parsed)", StringComparison.Ordinal), "Scene context should be available for inherited generic child locations.");
Assert(service.Contains("EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext)", StringComparison.Ordinal), "Unresolved parents should be preserved as parent candidates.");
Assert(service.Contains("candidate.ProposedParentCandidateKey = parentKey", StringComparison.Ordinal), "Child candidates should retain durable parent-candidate linkage.");
Assert(service.Contains("return await MaterialiseLocationCandidateAsync(parentCandidate", StringComparison.Ordinal), "Child materialisation should create/link unresolved parent candidates first.");
Assert(service.Contains("existingIndex.Find(importName, parentId)", StringComparison.Ordinal), "Existing child matching should include parent location identity.");
Assert(service.Contains("string.Equals(selection, \"__none__\"", StringComparison.Ordinal), "Author clear-parent selection should be respected.");
Assert(service.Contains("ParentLocationID = parentId", StringComparison.Ordinal), "Accepted children should be saved under the resolved parent ID.");
Assert(view.Contains("Proposed parent", StringComparison.Ordinal), "Location review should show proposed parent context.");
Assert(view.Contains("name=\"Locations[@i].ParentSelection\"", StringComparison.Ordinal), "Location review should let the author choose or clear parent context.");
Assert(view.Contains("Use proposed:", StringComparison.Ordinal), "Location review should allow accepting proposed parent context.");
Assert(view.Contains("Not determined", StringComparison.Ordinal), "Location review should allow unresolved parent context.");
Assert(viewModels.Contains("ProposedParentLocationID", StringComparison.Ordinal)
&& viewModels.Contains("ProposedParentCandidateKey", StringComparison.Ordinal)
&& viewModels.Contains("ParentIdentityKey", StringComparison.Ordinal), "Persisted Location candidate payload should include first-class parent identity.");
Assert(persisted.Contains("ParentLocationOptions = parentOptions", StringComparison.Ordinal), "Persisted Location GET should hydrate parent options without rebuilding Scene JSON.");
}
static void LocationReviewUxSupportsLargeCandidateSets()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
@ -2671,6 +2720,51 @@ static string ResolveContextualLocationName(string name, string? genericRoomType
return (string)method!.Invoke(null, [name, genericRoomType, scene])!;
}
static string ParentAwareLocationCandidateKey(string? parentIdentityKey, string leafIdentityKey)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ParentAwareCandidateKey",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ParentAwareCandidateKey was not found.");
return (string)method!.Invoke(null, [parentIdentityKey, leafIdentityKey])!;
}
static string? ExplicitLocationLeaf(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ExplicitLeaf",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ExplicitLeaf was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string? InferExplicitLocationParent(string name, string leafName)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"InferExplicitParent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "InferExplicitParent was not found.");
return (string?)method!.Invoke(null, [name, leafName]);
}
static string? ParentFromOwnedLocationLeaf(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ParentFromOwnedLeaf",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ParentFromOwnedLeaf was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string? ExtractParentFromCompositeLocationSetting(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(
"ExtractParentFromCompositeSetting",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
Assert(method is not null, "ExtractParentFromCompositeSetting was not found.");
return (string?)method!.Invoke(null, [name]);
}
static string LocationCanonicalKey(string name)
{
var method = typeof(StoryIntelligenceLocationImportService).GetMethod(

View File

@ -42,6 +42,11 @@ public sealed class StoryIntelligenceLocationImportService(
"landing", "doorway", "pew", "altar", "floor", "entrance", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "garden", "staff room", "lift");
private static readonly HashSet<string> ParentableGenericLocations = CreateSet(
"room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs",
"landing", "office", "bedroom", "garage", "seating area", "waiting room", "interview room", "garden",
"staff room", "living room", "sitting room");
private static readonly HashSet<string> RepeatableGenericSettings = CreateSet(
"kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "office", "bedroom", "garage",
"seating area", "waiting room", "interview room", "staff room", "car park", "parking lot", "tunnel");
@ -63,6 +68,8 @@ public sealed class StoryIntelligenceLocationImportService(
private static readonly Regex NamedRoadPattern = new(@"\b(street|road|lane|avenue|drive|way|close|crescent|square|place)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ControlCharacters = new(@"[\u0000-\u001F\u007F]+", RegexOptions.Compiled);
private static readonly Regex PossessiveControlCharacter = new(@"(?<=\p{L})[\u0000-\u001F\u007F]+(?=s\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex PossessiveGenericLocation = new(@"^(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex GenericAtOwnedLocation = new(@"^(?:the\s+|a\s+|an\s+)?(?<leaf>bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\s+(?:at|in|inside|within)\s+(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)(?:['’]s)?(?:\s+(?:house|home|flat|apartment))?$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex LeadingContextPhrase = new(@"^(?:inside|outside|at|in|within|near|around|beside|by)\s+(?:the\s+|a\s+|an\s+)?", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex TemporalSuffix = new(@",\s*(?:(?:early|late|later|same|next|following)\s+)?(?:morning|afternoon|evening|night|day|hours?|dawn|dusk|midday|midnight|christmas|boxing day|weekend|weekday|summer|winter|spring|autumn|fall|today|tonight|later)\b.*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
@ -84,21 +91,28 @@ public sealed class StoryIntelligenceLocationImportService(
var data = await BuildCandidateDataAsync(batch);
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
var parentOptions = ToParentOptions(await locations.ListByProjectAsync(batch.ProjectID));
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = data.HasCommittedScenes,
CanImport = visibleCandidates.Count > 0,
AlreadyLinkedCount = data.AlreadyLinkedCount,
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
ParentLocationOptions = parentOptions,
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
{
Key = candidate.Key,
LocationName = candidate.DisplayName,
ImportName = candidate.DisplayName,
LeafLocationName = candidate.LeafName,
ImportName = candidate.LeafName,
Category = candidate.Category,
AppearsInScenes = candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count(),
Confidence = ConfidenceBand(candidate.Confidence),
ParentLocationHint = candidate.ParentLocationHint,
ParentIdentityKey = candidate.ParentIdentityKey,
ProposedParentLocationID = candidate.ProposedParentLocationID,
ProposedParentLocationName = candidate.ProposedParentLocationName,
ProposedParentCandidateKey = candidate.ProposedParentCandidateKey,
ExampleFirstAppearance = candidate.FirstAppearanceNote,
ExampleContext = candidate.Description,
ExistingLocationID = candidate.ExistingLocationID,
@ -150,6 +164,109 @@ public sealed class StoryIntelligenceLocationImportService(
var resolvedNames = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var resolvedCandidateIds = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var candidateByKey = data.Candidates.ToDictionary(candidate => candidate.Key, StringComparer.OrdinalIgnoreCase);
var materialising = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
async Task<int?> ResolveParentIdAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice)
{
var selection = Clean(choice?.ParentSelection);
if (string.Equals(selection, "__none__", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (int.TryParse(selection, out var selectedParentId) && selectedParentId > 0)
{
return selectedParentId;
}
if (choice?.ParentLocationID is > 0)
{
return choice.ParentLocationID;
}
if (candidate.ProposedParentLocationID.HasValue)
{
return candidate.ProposedParentLocationID;
}
if (!string.IsNullOrWhiteSpace(candidate.ProposedParentCandidateKey)
&& candidateByKey.TryGetValue(candidate.ProposedParentCandidateKey, out var parentCandidate))
{
return await MaterialiseLocationCandidateAsync(parentCandidate, choices.GetValueOrDefault(parentCandidate.Key), isDependency: true);
}
return null;
}
async Task<int?> MaterialiseLocationCandidateAsync(LocationCandidate candidate, StoryIntelligenceLocationImportChoiceForm? choice, bool isDependency)
{
if (resolvedCandidateIds.TryGetValue(candidate.Key, out var resolvedId))
{
return resolvedId;
}
if (!materialising.Add(candidate.Key))
{
return null;
}
try
{
var action = choice?.Action ?? StoryIntelligenceLocationImportActions.CreateNew;
if (string.Equals(action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|| string.Equals(action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var parentId = await ResolveParentIdAsync(candidate, choice);
var requestedName = Clean(choice?.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName;
var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingLocationID
?? existingIndex.Find(importName, parentId)?.LocationID
?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID
: existingIndex.Find(importName, parentId)?.LocationID;
var locationId = matchedExistingId;
if (!locationId.HasValue)
{
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = batch.ProjectID,
ParentLocationID = parentId,
LocationName = importName,
LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate),
Description = BuildLocationDescription(candidate),
DetectionPriority = 50
});
if (!isDependency)
{
created++;
}
}
else if (!isDependency)
{
linkedExisting++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value);
resolvedCandidateIds[candidate.Key] = locationId.Value;
if (!isDependency || choice is not null)
{
AddDecision(batch, candidate.Key, action, importName, locationId.Value, true);
}
return locationId;
}
finally
{
materialising.Remove(candidate.Key);
}
}
foreach (var candidate in data.Candidates)
{
@ -170,39 +287,7 @@ public sealed class StoryIntelligenceLocationImportService(
continue;
}
var requestedName = Clean(choice.ImportName);
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
var linkExisting = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
var matchedExistingId = linkExisting
? candidate.ExistingLocationID
?? existingIndex.Find(importName)?.LocationID
?? existingIndex.Find(candidate.DisplayName)?.LocationID
: null;
var locationId = matchedExistingId;
if (!locationId.HasValue)
{
var parentId = ResolveParentLocationId(candidate.ParentLocationHint, existingIndex);
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = batch.ProjectID,
ParentLocationID = parentId,
LocationName = importName,
LocationTypeID = MatchLocationType(lookupData.LocationTypes, candidate),
Description = BuildLocationDescription(candidate),
DetectionPriority = 50
});
created++;
}
else
{
linkedExisting++;
}
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
AddResolvedName(resolvedNames, importName, locationId.Value);
resolvedCandidateIds[candidate.Key] = locationId.Value;
AddDecision(batch, candidate.Key, choice.Action, importName, locationId.Value, true);
await MaterialiseLocationCandidateAsync(candidate, choice, isDependency: false);
}
foreach (var candidate in data.Candidates)
@ -351,6 +436,7 @@ public sealed class StoryIntelligenceLocationImportService(
}
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
LinkParentCandidates(groups);
var candidates = groups.Values
.Where(candidate => candidate.Appearances.Any(appearance => !appearance.AlreadyLinked))
.Where(candidate => IsVisibleLocationCandidate(candidate))
@ -367,31 +453,32 @@ public sealed class StoryIntelligenceLocationImportService(
Scene importedScene,
SceneIntelligenceScene parsed)
{
var sceneParentContext = InferSceneParentContext(importedScene, parsed);
var structuralSetting = Clean(importedScene.StructuralSetting);
if (!string.IsNullOrWhiteSpace(structuralSetting))
{
AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, true, false, true, 0.95m, "Structural setting from Core Import.");
AddLocation(groups, existingIndex, importedScene, parsed, structuralSetting, "named", null, null, null, true, false, true, 0.95m, "Structural setting from Core Import.");
}
var setting = parsed.Setting;
var settingName = ResolveContextualLocationName(FirstConfigured(setting?.LocationName, setting?.GenericRoomType), setting?.GenericRoomType, importedScene);
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, true, false, true, setting?.Confidence, "Scene setting");
AddLocation(groups, existingIndex, importedScene, parsed, settingName, setting?.LocationType, setting?.GenericRoomType, setting?.ParentLocationHint, sceneParentContext, true, false, true, setting?.Confidence, "Scene setting");
foreach (var location in parsed.Locations ?? [])
{
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(location.Name, location.GenericRoomType, importedScene), location.LocationType, location.GenericRoomType, location.ParentLocationHint, sceneParentContext, location.PresentInScene == true, location.MentionedOnly == true, false, location.Confidence, location.Notes);
}
foreach (var observation in parsed.Observations ?? [])
{
if (IsLocationEntity(observation.SubjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.SubjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
}
if (IsLocationEntity(observation.ObjectEntityType))
{
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
AddLocation(groups, existingIndex, importedScene, parsed, ResolveContextualLocationName(observation.ObjectName, null, importedScene), null, null, null, sceneParentContext, IsPresentPredicate(observation.Predicate), !IsPresentPredicate(observation.Predicate), false, observation.Confidence, observation.Description);
}
}
}
@ -405,6 +492,7 @@ public sealed class StoryIntelligenceLocationImportService(
string? locationType,
string? genericRoomType,
string? parentLocationHint,
LocationParentContext? sceneParentContext,
bool presentInScene,
bool mentionedOnly,
bool isSceneSetting,
@ -417,20 +505,36 @@ public sealed class StoryIntelligenceLocationImportService(
return;
}
var identity = ResolveLocationIdentity(cleanName, existingIndex);
var identity = ResolveLocationIdentity(cleanName, null, existingIndex);
if (identity is null || !IsLocationNameCandidate(identity.DisplayName))
{
return;
}
var key = ResolveCandidateKey(groups, identity.Key, identity.DisplayName);
var parentContext = ResolveParentContext(cleanName, identity.DisplayName, genericRoomType, parentLocationHint, sceneParentContext, existingIndex);
EnsureParentCandidate(groups, existingIndex, importedScene, parsed, parentContext);
identity = ResolveLocationIdentity(parentContext.LeafName, parentContext.ProposedParentLocationID, existingIndex);
if (identity is null || !IsLocationNameCandidate(identity.DisplayName))
{
return;
}
var key = ParentAwareCandidateKey(parentContext.ParentIdentityKey, identity.Key);
if (!groups.TryGetValue(key, out var candidate))
{
var match = identity.ExistingMatch;
var match = parentContext.ParentIdentityKey is not null && !parentContext.ProposedParentLocationID.HasValue
? null
: identity.ExistingMatch;
candidate = new LocationCandidate(key, identity.DisplayName, match?.LocationID, match?.LocationName)
{
LeafName = identity.DisplayName,
LeafIdentityKey = identity.Key,
Category = Categorise(identity.DisplayName, locationType, genericRoomType),
ParentLocationHint = Clean(parentLocationHint)
ParentIdentityKey = parentContext.ParentIdentityKey,
ParentLocationHint = parentContext.DisplayHint,
ProposedParentLocationID = parentContext.ProposedParentLocationID,
ProposedParentLocationName = parentContext.ProposedParentLocationName,
ProposedParentCandidateKey = parentContext.ProposedParentCandidateKey
};
groups[key] = candidate;
}
@ -450,7 +554,10 @@ public sealed class StoryIntelligenceLocationImportService(
candidate.Confidence = Max(candidate.Confidence, confidence);
candidate.Description ??= Clean(notes);
candidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, identity.DisplayName);
candidate.ParentLocationHint ??= Clean(parentLocationHint);
candidate.ParentLocationHint ??= parentContext.DisplayHint;
candidate.ProposedParentLocationID ??= parentContext.ProposedParentLocationID;
candidate.ProposedParentLocationName ??= parentContext.ProposedParentLocationName;
candidate.ProposedParentCandidateKey ??= parentContext.ProposedParentCandidateKey;
var appearance = new LocationAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
@ -471,16 +578,358 @@ public sealed class StoryIntelligenceLocationImportService(
}
}
private static void EnsureParentCandidate(
Dictionary<string, LocationCandidate> groups,
LocationIndex existingIndex,
Scene importedScene,
SceneIntelligenceScene parsed,
LocationParentContext parentContext)
{
if (parentContext.ProposedParentLocationID.HasValue
|| string.IsNullOrWhiteSpace(parentContext.ProposedParentLocationName)
|| !IsStoryLocation(parentContext.ProposedParentLocationName))
{
return;
}
var parentIdentity = ResolveLocationIdentity(parentContext.ProposedParentLocationName, null, existingIndex);
if (parentIdentity is null)
{
return;
}
var parentKey = ParentAwareCandidateKey(null, parentIdentity.Key);
if (!groups.TryGetValue(parentKey, out var parentCandidate))
{
parentCandidate = new LocationCandidate(parentKey, parentIdentity.DisplayName, parentIdentity.ExistingMatch?.LocationID, parentIdentity.ExistingMatch?.LocationName)
{
LeafName = parentIdentity.DisplayName,
LeafIdentityKey = parentIdentity.Key,
Category = Categorise(parentIdentity.DisplayName, "named", null)
};
groups[parentKey] = parentCandidate;
}
parentCandidate.Confidence = Max(parentCandidate.Confidence, 0.9m);
parentCandidate.Description ??= "Parent location inferred from child location context.";
parentCandidate.FirstAppearanceNote ??= BuildFirstAppearance(importedScene, parsed, parentIdentity.DisplayName);
var appearance = new LocationAppearanceImport(
importedScene.SceneID,
importedScene.SceneNumber,
importedScene.SceneTitle,
BuildSceneReference(importedScene),
parentIdentity.DisplayName,
true,
false,
true,
"Parent inferred from child location context.",
0.9m,
importedScene.PrimaryLocationID.HasValue && parentCandidate.ExistingLocationID == importedScene.PrimaryLocationID);
if (!parentCandidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID
&& string.Equals(existing.ObservedName, appearance.ObservedName, StringComparison.OrdinalIgnoreCase)))
{
parentCandidate.Appearances.Add(appearance);
}
}
private static void LinkParentCandidates(Dictionary<string, LocationCandidate> groups)
{
foreach (var candidate in groups.Values)
{
if (candidate.ProposedParentLocationID.HasValue || string.IsNullOrWhiteSpace(candidate.ParentIdentityKey))
{
continue;
}
var parentKey = ParentAwareCandidateKey(null, candidate.ParentIdentityKey);
if (groups.ContainsKey(parentKey))
{
candidate.ProposedParentCandidateKey = parentKey;
}
}
}
private static LocationParentContext ResolveParentContext(
string observedName,
string identityName,
string? genericRoomType,
string? parentLocationHint,
LocationParentContext? sceneParentContext,
LocationIndex existingIndex)
{
var leafName = ResolveLeafLocationName(observedName, identityName, genericRoomType);
var explicitParent = InferExplicitParent(observedName, leafName);
var parentName = explicitParent
?? UsefulParentName(parentLocationHint)
?? (IsParentableGenericLocation(leafName) ? sceneParentContext?.ProposedParentLocationName : null);
var displayHint = FirstConfigured(parentName, parentLocationHint);
if (string.IsNullOrWhiteSpace(parentName))
{
return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint));
}
var parentIdentity = ResolveLocationIdentity(parentName, null, existingIndex);
if (parentIdentity is null)
{
return new LocationParentContext(leafName, null, null, null, null, Clean(displayHint));
}
return new LocationParentContext(
leafName,
parentIdentity.Key,
parentIdentity.ExistingMatch?.LocationID,
parentIdentity.ExistingMatch?.LocationName ?? parentIdentity.DisplayName,
null,
Clean(displayHint));
}
private static LocationParentContext? InferSceneParentContext(Scene scene, SceneIntelligenceScene parsed)
{
var settingParent = UsefulParentName(parsed.Setting?.ParentLocationHint);
if (!string.IsNullOrWhiteSpace(settingParent))
{
return new LocationParentContext(settingParent, CanonicalLocationKey(settingParent), null, settingParent, null, settingParent);
}
var structural = CleanLocationName(scene.StructuralSetting);
var structuralParent = ExtractParentFromCompositeSetting(structural)
?? ParentFromOwnedLeaf(structural)
?? (IsUsableParentName(structural) ? structural : null);
if (!string.IsNullOrWhiteSpace(structuralParent))
{
return new LocationParentContext(structuralParent, CanonicalLocationKey(structuralParent), null, structuralParent, null, structuralParent);
}
var settingName = CleanLocationName(parsed.Setting?.LocationName);
var settingNameParent = ExtractParentFromCompositeSetting(settingName)
?? ParentFromOwnedLeaf(settingName)
?? (IsUsableParentName(settingName) ? settingName : null);
if (!string.IsNullOrWhiteSpace(settingNameParent))
{
return new LocationParentContext(settingNameParent, CanonicalLocationKey(settingNameParent), null, settingNameParent, null, settingNameParent);
}
var primary = Clean(scene.PrimaryLocationName ?? scene.PrimaryLocationPath);
return IsUsableParentName(primary)
? new LocationParentContext(primary, CanonicalLocationKey(primary), null, primary, null, primary)
: null;
}
private static string ResolveLeafLocationName(string observedName, string identityName, string? genericRoomType)
{
var explicitLeaf = ExplicitLeaf(observedName);
if (!string.IsNullOrWhiteSpace(explicitLeaf))
{
return explicitLeaf;
}
var genericLeaf = CleanLocationName(genericRoomType);
if (IsParentableGenericLocation(genericLeaf))
{
return genericLeaf;
}
if (IsParentableGenericLocation(identityName))
{
return identityName;
}
return identityName;
}
private static string? ExplicitLeaf(string observedName)
{
var clean = CleanLocationName(observedName);
var possessive = PossessiveGenericLocation.Match(clean);
if (possessive.Success)
{
return CleanLocationName(possessive.Groups["leaf"].Value);
}
var genericAt = GenericAtOwnedLocation.Match(clean);
return genericAt.Success ? CleanLocationName(genericAt.Groups["leaf"].Value) : null;
}
private static string? InferExplicitParent(string observedName, string leafName)
{
var clean = CleanLocationName(observedName);
var possessive = PossessiveGenericLocation.Match(clean);
if (possessive.Success)
{
var owner = CleanLocationName(possessive.Groups["owner"].Value);
return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null;
}
var genericAt = GenericAtOwnedLocation.Match(clean);
if (genericAt.Success)
{
var owner = CleanLocationName(genericAt.Groups["owner"].Value);
return IsCleanOwnerName(owner) ? ParentNameFromOwner(owner) : null;
}
return null;
}
private static string? ParentFromOwnedLeaf(string value)
{
var clean = CleanLocationName(value);
var matches = PossessiveGenericLocation.Matches(StripLeadingContextPhrases(clean))
.Cast<Match>()
.ToList();
if (matches.Count == 0)
{
matches = Regex.Matches(clean, @"(?<owner>[\p{L}\d][\p{L}\d\s.'’-]{1,80}?)['’]s\s+(?<leaf>bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing|staff room|waiting room|interview room)\b", RegexOptions.IgnoreCase)
.Cast<Match>()
.ToList();
}
foreach (var match in matches.AsEnumerable().Reverse())
{
var owner = CleanLocationName(match.Groups["owner"].Value);
if (!IsCleanOwnerName(owner))
{
continue;
}
return ParentNameFromOwner(owner);
}
return null;
}
private static string? ParentNameFromOwner(string owner)
{
var clean = CleanLocationName(owner);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
var normalised = Normalise(clean);
if (normalised.EndsWith(" house", StringComparison.OrdinalIgnoreCase)
|| normalised.EndsWith(" home", StringComparison.OrdinalIgnoreCase)
|| normalised.EndsWith(" flat", StringComparison.OrdinalIgnoreCase)
|| normalised.EndsWith(" apartment", StringComparison.OrdinalIgnoreCase))
{
return clean;
}
return $"{clean}'s home";
}
private static string? ExtractParentFromCompositeSetting(string? value)
{
var clean = CleanLocationName(value);
if (string.IsNullOrWhiteSpace(clean))
{
return null;
}
var ownedContainer = Regex.Match(clean, @"(?<parent>[\p{L}\d][\p{L}\d\s.'’-]{1,80}['’]s\s+(?:house|home|flat|apartment))\s+(?:and|with)\s+(?:bedroom|bathroom|kitchen|garden|hallway|corridor|living room|sitting room|office|garage|room|stairs|landing)\b", RegexOptions.IgnoreCase);
if (ownedContainer.Success)
{
var parent = CleanLocationName(ownedContainer.Groups["parent"].Value);
if (IsUsableParentName(parent))
{
return parent;
}
}
var parts = Regex.Split(clean, @"\s*(?:--|-|—|;|,|\bthen\b|\bto\b)\s*", RegexOptions.IgnoreCase)
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(CleanLocationName)
.ToList();
foreach (var part in parts)
{
var ownedParent = ParentFromOwnedLeaf(part);
if (IsUsableParentName(ownedParent))
{
return ownedParent;
}
}
foreach (var part in parts)
{
if (IsUsableParentName(part))
{
return part;
}
}
return null;
}
private static string? UsefulParentName(string? parentHint)
{
var clean = CleanLocationName(parentHint);
if (Regex.IsMatch(clean, @"(?:--|-|—|;|,|\bthen\b|\bto\b)", RegexOptions.IgnoreCase))
{
return ExtractParentFromCompositeSetting(clean);
}
return IsUsableParentName(clean) ? clean : null;
}
private static bool IsUsableParentName(string? value)
{
var clean = CleanLocationName(value);
return !string.IsNullOrWhiteSpace(clean)
&& IsStoryLocation(clean)
&& !IsParentableGenericLocation(clean)
&& !ContainsParentableGenericLocationWord(clean)
&& !IsGenericComposite(clean)
&& !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through)\b", RegexOptions.IgnoreCase)
&& !Regex.IsMatch(clean, @"\b(?:road|lane|street|avenue|drive|way|close|court|terrace|square)\b$", RegexOptions.IgnoreCase)
&& !ContainsThrowawayFragment(clean);
}
private static bool IsCleanOwnerName(string? value)
{
var clean = CleanLocationName(value);
return !string.IsNullOrWhiteSpace(clean)
&& !Regex.IsMatch(clean, @"\b(?:and|or|to|from|between|through|beneath|outside|inside)\b", RegexOptions.IgnoreCase);
}
private static bool ContainsParentableGenericLocationWord(string value)
{
var normalised = Normalise(value);
return ParentableGenericLocations.Any(location => normalised.Equals(location, StringComparison.OrdinalIgnoreCase)
|| normalised.Contains($" {location}", StringComparison.OrdinalIgnoreCase)
|| normalised.Contains($"{location} ", StringComparison.OrdinalIgnoreCase));
}
private static bool IsParentableGenericLocation(string? name)
{
var clean = Normalise(name);
return ParentableGenericLocations.Contains(clean)
|| ParentableGenericLocations.Contains(Normalise(StoryIntelligenceEntityTextNormaliser.StripLeadingArticle(name ?? string.Empty)));
}
private static string ParentAwareCandidateKey(string? parentIdentityKey, string leafIdentityKey)
=> $"{(string.IsNullOrWhiteSpace(parentIdentityKey) ? "unknown" : parentIdentityKey)}|{leafIdentityKey}";
private static IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ToParentOptions(IEnumerable<LocationItem> locations)
=> locations
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath
})
.ToList();
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
{
var index = new LocationIndex();
foreach (var location in await locations.ListByProjectAsync(projectId))
{
index.Add(location.LocationName, location.LocationID, location.LocationName);
index.Add(location.LocationPath, location.LocationID, location.LocationName);
index.Add(location.LocationName, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath);
index.Add(location.LocationPath, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath);
foreach (var alias in await locations.ListAliasesAsync(location.LocationID))
{
index.Add(alias.Alias, location.LocationID, location.LocationName);
index.Add(alias.Alias, location.LocationID, location.LocationName, location.ParentLocationID, location.ParentLocationName, location.LocationPath);
}
}
@ -866,7 +1315,7 @@ public sealed class StoryIntelligenceLocationImportService(
return StoryIntelligenceEntityTextNormaliser.SingulariseSafeFinalWord(clean, SingularisableLocationWords);
}
private static LocationIdentity? ResolveLocationIdentity(string name, LocationIndex existingIndex)
private static LocationIdentity? ResolveLocationIdentity(string name, int? parentLocationId, LocationIndex existingIndex)
{
var clean = CleanLocationName(name);
if (string.IsNullOrWhiteSpace(clean))
@ -874,14 +1323,14 @@ public sealed class StoryIntelligenceLocationImportService(
return null;
}
var existingMatch = existingIndex.Find(clean);
var existingMatch = existingIndex.Find(clean, parentLocationId);
if (existingMatch is not null)
{
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
}
var identityName = NormaliseLocationIdentityName(clean);
existingMatch = existingIndex.Find(identityName);
existingMatch = existingIndex.Find(identityName, parentLocationId);
if (existingMatch is not null)
{
return new LocationIdentity(CanonicalLocationKey(existingMatch.LocationName), existingMatch.LocationName, existingMatch);
@ -946,27 +1395,6 @@ public sealed class StoryIntelligenceLocationImportService(
return Regex.Replace(clean, @"\s+and\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
}
private static string ResolveCandidateKey(
IReadOnlyDictionary<string, LocationCandidate> groups,
string canonicalKey,
string displayName)
{
if (groups.ContainsKey(canonicalKey))
{
return canonicalKey;
}
if (!IsGenericLocation(displayName))
{
return canonicalKey;
}
var contextual = groups.Keys
.Where(key => key.EndsWith($" {canonicalKey}", StringComparison.OrdinalIgnoreCase))
.ToList();
return contextual.Count == 1 ? contextual[0] : canonicalKey;
}
private static string StripLowValueContextSuffix(string value)
{
var clean = Clean(value);
@ -1132,30 +1560,54 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed class LocationIndex
{
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LocationMatch> byParentAndName = new(StringComparer.OrdinalIgnoreCase);
public void Add(string? name, int locationId, string locationName)
public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath)
{
var key = CanonicalLocationKey(name ?? string.Empty);
if (!string.IsNullOrWhiteSpace(key))
{
byName.TryAdd(key, new LocationMatch(locationId, locationName));
var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath);
byName.TryAdd(key, match);
byParentAndName.TryAdd(ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
}
}
public LocationMatch? Find(string name)
=> byName.TryGetValue(CanonicalLocationKey(name), out var match) ? match : null;
public LocationMatch? Find(string name, int? parentLocationId = null)
{
var key = CanonicalLocationKey(name);
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
if (parentLocationId.HasValue)
{
return byParentAndName.TryGetValue(ParentAwareCandidateKey(parentLocationId.Value.ToString(), key), out var parentMatch)
? parentMatch
: null;
}
return byName.TryGetValue(key, out var match) ? match : null;
}
}
private sealed record LocationMatch(int LocationID, string LocationName);
private sealed record LocationMatch(int LocationID, string LocationName, int? ParentLocationID, string? ParentLocationName, string? LocationPath);
private sealed class LocationCandidate(string key, string displayName, int? existingLocationId, string? existingLocationName)
{
public string Key { get; } = key;
public string DisplayName { get; set; } = displayName;
public string LeafName { get; set; } = displayName;
public string LeafIdentityKey { get; set; } = key;
public int? ExistingLocationID { get; } = existingLocationId;
public string? ExistingLocationName { get; } = existingLocationName;
public string Category { get; set; } = "Ambiguous location";
public string? ParentIdentityKey { get; set; }
public string? ParentLocationHint { get; set; }
public int? ProposedParentLocationID { get; set; }
public string? ProposedParentLocationName { get; set; }
public string? ProposedParentCandidateKey { get; set; }
public HashSet<string> Aliases { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<LocationAppearanceImport> Appearances { get; } = [];
public decimal? Confidence { get; set; }
@ -1178,6 +1630,14 @@ public sealed class StoryIntelligenceLocationImportService(
private sealed record LocationIdentity(string Key, string DisplayName, LocationMatch? ExistingMatch);
private sealed record LocationParentContext(
string LeafName,
string? ParentIdentityKey,
int? ProposedParentLocationID,
string? ProposedParentLocationName,
string? ProposedParentCandidateKey,
string? DisplayHint);
private sealed record LocationCandidateData(
bool HasCommittedScenes,
int AlreadyLinkedCount,

View File

@ -31,6 +31,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
IStoryIntelligenceRelationshipImportService relationshipImport,
IStoryIntelligenceKnowledgeImportService knowledgeImport,
ICharacterRepository characters,
ILocationRepository locations,
IStoryIntelligencePipelineStateService pipelineState,
ILogger<StoryIntelligenceReviewCandidateService> logger) : IStoryIntelligenceReviewCandidateService
{
@ -147,6 +148,16 @@ public sealed class StoryIntelligenceReviewCandidateService(
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
{
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
var parentOptions = (await locations.ListByProjectAsync(batch.ProjectID))
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath
})
.ToList();
return new StoryIntelligenceLocationReviewViewModel
{
HasCommittedScenes = true,
@ -157,6 +168,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
PageSize = page.PageSize,
TotalCandidateCount = page.TotalCount,
PendingCandidateCount = page.PendingCount,
ParentLocationOptions = parentOptions,
Candidates = page.Items
};
}

View File

@ -453,18 +453,32 @@ public sealed class StoryIntelligenceLocationReviewViewModel
public int PendingCandidateCount { get; init; }
public bool HasPreviousPage => PageNumber > 1;
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
}
public sealed class StoryIntelligenceLocationParentOptionViewModel
{
public int LocationID { get; init; }
public string LocationName { get; init; } = string.Empty;
public string LocationPath { get; init; } = string.Empty;
public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath;
}
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
{
public string Key { get; init; } = string.Empty;
public string LocationName { get; init; } = string.Empty;
public string LeafLocationName { get; init; } = string.Empty;
public string ImportName { get; init; } = string.Empty;
public string Category { get; init; } = "Ambiguous location";
public int AppearsInScenes { get; init; }
public string Confidence { get; init; } = "Unknown";
public string? ParentLocationHint { get; init; }
public string? ParentIdentityKey { get; init; }
public int? ProposedParentLocationID { get; init; }
public string? ProposedParentLocationName { get; init; }
public string? ProposedParentCandidateKey { get; init; }
public string? ExampleFirstAppearance { get; init; }
public string? ExampleContext { get; init; }
public IReadOnlyList<string> EvidenceSummaries { get; init; } = [];
@ -486,6 +500,8 @@ public sealed class StoryIntelligenceLocationImportChoiceForm
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
public string? ImportName { get; set; }
public string? AliasTargetKey { get; set; }
public int? ParentLocationID { get; set; }
public string? ParentSelection { get; set; }
}
public static class StoryIntelligenceLocationImportActions

View File

@ -53,7 +53,8 @@
{
var candidate = locationCandidates[i];
var status = candidate.IsExistingMatch ? "existing" : "new";
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint });
var proposedParentLabel = candidate.ProposedParentLocationName ?? candidate.ParentLocationHint;
var searchText = string.Join(" ", new[] { candidate.LocationName, candidate.ExistingLocationName, candidate.Category, candidate.ParentLocationHint, candidate.ProposedParentLocationName });
<details class="story-character-card" open data-location-card data-location-status="@status" data-location-search-text="@searchText">
<summary>
<span>
@ -68,7 +69,7 @@
<div class="story-character-card__body" data-location-key="@candidate.Key" data-location-name="@candidate.LocationName">
<dl>
<div><dt>First appearance</dt><dd>@Display(candidate.ExampleFirstAppearance)</dd></div>
<div><dt>Parent hint</dt><dd>@Display(candidate.ParentLocationHint)</dd></div>
<div><dt>Proposed parent</dt><dd>@Display(proposedParentLabel)</dd></div>
<div><dt>Existing match</dt><dd>@(candidate.ExistingLocationName ?? "None")</dd></div>
</dl>
@ -126,6 +127,35 @@
<input id="location-import-name-@i" class="form-control" name="Locations[@i].ImportName" value="@candidate.ImportName" />
</div>
<div data-location-parent-panel>
<label class="form-label" for="location-parent-@i">Parent location</label>
<select id="location-parent-@i" class="form-select" name="Locations[@i].ParentSelection">
@if (!string.IsNullOrWhiteSpace(proposedParentLabel))
{
<option value="__proposed__" selected>Use proposed: @proposedParentLabel</option>
}
@if (string.IsNullOrWhiteSpace(proposedParentLabel))
{
<option value="__none__" selected>Not determined</option>
}
else
{
<option value="__none__">Not determined</option>
}
@foreach (var parent in Model.LocationReview.ParentLocationOptions)
{
@if (candidate.ProposedParentLocationID == parent.LocationID)
{
<option value="@parent.LocationID" selected>@parent.DisplayName</option>
}
else
{
<option value="@parent.LocationID">@parent.DisplayName</option>
}
}
</select>
</div>
<div data-location-alias-panel hidden>
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
@ -208,6 +238,8 @@
const namePanel = card.querySelector("[data-location-import-name-panel]");
const aliasPanel = card.querySelector("[data-location-alias-panel]");
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
const parentPanel = card.querySelector("[data-location-parent-panel]");
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
updateAliasTargets();
};