Hide exact known location imports from review
This commit is contained in:
parent
4579476869
commit
e37c761b46
@ -50,6 +50,8 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
|
("Location candidate consolidation handles generic review variants", LocationCandidateConsolidationHandlesGenericReviewVariants),
|
||||||
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
|
("Location candidate identity is parent-aware for generic sub-locations", LocationCandidateIdentityIsParentAwareForGenericSubLocations),
|
||||||
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
|
("Location review UX supports large candidate sets", LocationReviewUxSupportsLargeCandidateSets),
|
||||||
|
("Story Intelligence auto resolves exact known locations before review", StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview),
|
||||||
|
("Story Intelligence location review uses single identity decision", StoryIntelligenceLocationReviewUsesSingleIdentityDecision),
|
||||||
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
("Asset filtering rejects generic objects", AssetFilteringRejectsGenericObjects),
|
||||||
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
("Asset filtering preserves story assets", AssetFilteringPreservesStoryAssets),
|
||||||
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
("Asset canonical keys merge trivial variants", AssetCanonicalKeysMergeTrivialVariants),
|
||||||
@ -2710,6 +2712,60 @@ static void LocationReviewUxSupportsLargeCandidateSets()
|
|||||||
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
|
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceAutoResolvesExactKnownLocationsBeforeReview()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
||||||
|
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||||
|
|
||||||
|
Assert(service.Contains("Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync", StringComparison.Ordinal),
|
||||||
|
"Location import should expose deterministic auto-resolution before persisted review generation.");
|
||||||
|
Assert(candidateService.Contains("await locationImport.ResolveAutomaticMatchesAsync(batch)", StringComparison.Ordinal),
|
||||||
|
"Persisted Location review preparation should resolve exact known identities before saving review rows.");
|
||||||
|
Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal),
|
||||||
|
"Location preferred names and all aliases should feed the identity lookup.");
|
||||||
|
Assert(service.Contains("Dictionary<string, List<LocationMatch>> byName", StringComparison.Ordinal),
|
||||||
|
"Known-location lookup should retain all LocationID owners for an alias key.");
|
||||||
|
Assert(service.Contains("matches.Select(match => match.LocationID).Distinct().Count() == 1", StringComparison.Ordinal),
|
||||||
|
"Known-location lookup should only return a match when there is exactly one owner.");
|
||||||
|
Assert(service.Contains("IsAutoResolvableKnownIdentity", StringComparison.Ordinal),
|
||||||
|
"Exact known locations should be recognised separately from reviewable new or ambiguous locations.");
|
||||||
|
Assert(service.Contains("existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value", StringComparison.Ordinal),
|
||||||
|
"Preferred-name and alias exact matches should auto-resolve only when the canonical lookup returns the same LocationID.");
|
||||||
|
Assert(service.Contains("LinkSceneLocationsAsync(candidate, locationId)", StringComparison.Ordinal),
|
||||||
|
"Auto-resolution should preserve scene/location associations.");
|
||||||
|
Assert(service.Contains("return null;", StringComparison.Ordinal) && service.Contains("if (linkExisting && !locationId.HasValue)", StringComparison.Ordinal),
|
||||||
|
"A same-location decision without a canonical or pending target must not create a duplicate Location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceLocationReviewUsesSingleIdentityDecision()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs"));
|
||||||
|
var candidateService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs"));
|
||||||
|
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml"));
|
||||||
|
|
||||||
|
Assert(model.Contains("SameLocationOptions", StringComparison.Ordinal), "Location review should expose canonical LocationID targets.");
|
||||||
|
Assert(model.Contains("public int? ExistingLocationID { get; set; }", StringComparison.Ordinal), "Same-location decisions should post a canonical LocationID.");
|
||||||
|
Assert(candidateService.Contains("DecisionCanonicalID = choice.ExistingLocationID", StringComparison.Ordinal), "Persisted review decisions should record canonical LocationID targets.");
|
||||||
|
Assert(service.Contains("selectedExisting?.LocationID", StringComparison.Ordinal), "Import should resolve same-location targets by canonical LocationID.");
|
||||||
|
Assert(service.Contains("TryAddAliasAsync(locationId.Value, candidate.DisplayName", StringComparison.Ordinal), "Same-location import should attach the detected name directly to the target LocationID.");
|
||||||
|
Assert(view.Contains("Same location as", StringComparison.Ordinal), "Review should present one clear identity decision.");
|
||||||
|
Assert(view.Contains("Same location as existing", StringComparison.Ordinal), "Review should clearly distinguish canonical Location targets.");
|
||||||
|
Assert(view.Contains("Same location as pending candidate", StringComparison.Ordinal), "Review should preserve pending candidate-to-candidate matching.");
|
||||||
|
Assert(view.Contains("name=\"Locations[@i].ExistingLocationID\"", StringComparison.Ordinal), "Same-location dropdown should submit LocationID values.");
|
||||||
|
Assert(view.Contains("Model.LocationReview.SameLocationOptions", StringComparison.Ordinal), "Selector should list one canonical row per LocationID.");
|
||||||
|
Assert(view.Contains(".OrderBy(target => target.LocationName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
|
||||||
|
"Pending candidate targets should be sorted in natural alphabetical order.");
|
||||||
|
Assert(candidateService.Contains(".OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase)", StringComparison.Ordinal),
|
||||||
|
"Canonical Location identity selectors should be sorted in natural alphabetical order by preferred name.");
|
||||||
|
Assert(File.ReadAllText(Path.Combine(root, "Services/NaturalStringComparer.cs")).Contains("CompareNumberRuns", StringComparison.Ordinal),
|
||||||
|
"Location selector sorting should compare embedded numbers naturally.");
|
||||||
|
Assert(!view.Contains("Link existing location", StringComparison.Ordinal), "Review should not expose the old separate link-existing wording.");
|
||||||
|
Assert(!view.Contains("Alias of another location", StringComparison.Ordinal), "Review should not expose alias-chain wording.");
|
||||||
|
}
|
||||||
|
|
||||||
static bool IsMergedLocationPhrase(string name)
|
static bool IsMergedLocationPhrase(string name)
|
||||||
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
=> InvokePrivateLocationFilter("IsMergedLocationPhrase", name);
|
||||||
|
|
||||||
|
|||||||
80
PlotLine/Services/NaturalStringComparer.cs
Normal file
80
PlotLine/Services/NaturalStringComparer.cs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public sealed class NaturalStringComparer : IComparer<string?>
|
||||||
|
{
|
||||||
|
public static readonly NaturalStringComparer OrdinalIgnoreCase = new();
|
||||||
|
|
||||||
|
private NaturalStringComparer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Compare(string? x, string? y)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(x, y))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y is null)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xIndex = 0;
|
||||||
|
var yIndex = 0;
|
||||||
|
while (xIndex < x.Length && yIndex < y.Length)
|
||||||
|
{
|
||||||
|
if (char.IsDigit(x[xIndex]) && char.IsDigit(y[yIndex]))
|
||||||
|
{
|
||||||
|
var numberCompare = CompareNumberRuns(x, ref xIndex, y, ref yIndex);
|
||||||
|
if (numberCompare != 0)
|
||||||
|
{
|
||||||
|
return numberCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var charCompare = char.ToUpperInvariant(x[xIndex]).CompareTo(char.ToUpperInvariant(y[yIndex]));
|
||||||
|
if (charCompare != 0)
|
||||||
|
{
|
||||||
|
return charCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
xIndex++;
|
||||||
|
yIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Length.CompareTo(y.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareNumberRuns(string x, ref int xIndex, string y, ref int yIndex)
|
||||||
|
{
|
||||||
|
var xStart = xIndex;
|
||||||
|
var yStart = yIndex;
|
||||||
|
while (xIndex < x.Length && char.IsDigit(x[xIndex]))
|
||||||
|
{
|
||||||
|
xIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (yIndex < y.Length && char.IsDigit(y[yIndex]))
|
||||||
|
{
|
||||||
|
yIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xRun = x[xStart..xIndex].TrimStart('0');
|
||||||
|
var yRun = y[yStart..yIndex].TrimStart('0');
|
||||||
|
if (xRun.Length != yRun.Length)
|
||||||
|
{
|
||||||
|
return xRun.Length.CompareTo(yRun.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
var valueCompare = string.Compare(xRun, yRun, StringComparison.Ordinal);
|
||||||
|
return valueCompare != 0 ? valueCompare : (xIndex - xStart).CompareTo(yIndex - yStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,10 +8,13 @@ namespace PlotLine.Services;
|
|||||||
|
|
||||||
public interface IStoryIntelligenceLocationImportService
|
public interface IStoryIntelligenceLocationImportService
|
||||||
{
|
{
|
||||||
|
Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview);
|
Task<StoryIntelligenceImportCommitResult> ImportAsync(OnboardingStoryIntelligenceBatch batch, StoryIntelligenceLocationImportForm form, bool completeReview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record StoryIntelligenceLocationAutoResolutionResult(int CandidatesResolved, int SceneLocationsLinked);
|
||||||
|
|
||||||
public sealed class StoryIntelligenceLocationImportService(
|
public sealed class StoryIntelligenceLocationImportService(
|
||||||
IStoryIntelligenceResultRepository runs,
|
IStoryIntelligenceResultRepository runs,
|
||||||
ILocationRepository locations,
|
ILocationRepository locations,
|
||||||
@ -74,6 +77,40 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
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 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);
|
private static readonly Regex ParentheticalSuffix = new(@"\s*\([^)]*\)\s*$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public async Task<StoryIntelligenceLocationAutoResolutionResult> ResolveAutomaticMatchesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
|
{
|
||||||
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
|
if (!data.HasCommittedScenes)
|
||||||
|
{
|
||||||
|
return new StoryIntelligenceLocationAutoResolutionResult(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingIndex = await BuildLocationIndexAsync(batch.ProjectID);
|
||||||
|
var resolvedCandidates = 0;
|
||||||
|
var sceneLocationsLinked = 0;
|
||||||
|
|
||||||
|
foreach (var candidate in data.Candidates.Where(candidate => IsAutoResolvableKnownIdentity(candidate, existingIndex)))
|
||||||
|
{
|
||||||
|
var locationId = candidate.ExistingLocationID!.Value;
|
||||||
|
sceneLocationsLinked += await LinkSceneLocationsAsync(candidate, locationId);
|
||||||
|
resolvedCandidates++;
|
||||||
|
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.LinkExisting, candidate.DisplayName, locationId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedCandidates > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Automatically resolved Story Intelligence location candidates. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}",
|
||||||
|
batch.BatchID,
|
||||||
|
batch.ProjectID,
|
||||||
|
batch.BookID,
|
||||||
|
resolvedCandidates,
|
||||||
|
sceneLocationsLinked);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryIntelligenceLocationAutoResolutionResult(resolvedCandidates, sceneLocationsLinked);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task<StoryIntelligenceLocationReviewViewModel> BuildReviewAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
var pipeline = await pipelineState.GetForBookAsync(batch.BookID, batch.UserID);
|
||||||
@ -91,7 +128,9 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
var data = await BuildCandidateDataAsync(batch);
|
var data = await BuildCandidateDataAsync(batch);
|
||||||
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
var decidedKeys = batch.LocationDecisions.Select(decision => decision.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
var parentOptions = ToParentOptions(await locations.ListByProjectAsync(batch.ProjectID));
|
var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
|
||||||
|
var parentOptions = ToParentOptions(projectLocations);
|
||||||
|
var identityOptions = await ToIdentityOptionsAsync(projectLocations);
|
||||||
return new StoryIntelligenceLocationReviewViewModel
|
return new StoryIntelligenceLocationReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
@ -99,6 +138,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
AlreadyLinkedCount = data.AlreadyLinkedCount,
|
||||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||||
ParentLocationOptions = parentOptions,
|
ParentLocationOptions = parentOptions,
|
||||||
|
SameLocationOptions = identityOptions,
|
||||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
|
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceLocationReviewCandidateViewModel
|
||||||
{
|
{
|
||||||
Key = candidate.Key,
|
Key = candidate.Key,
|
||||||
@ -223,12 +263,18 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
var requestedName = Clean(choice?.ImportName);
|
var requestedName = Clean(choice?.ImportName);
|
||||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName;
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.LeafName : requestedName;
|
||||||
var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
var linkExisting = string.Equals(action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var selectedExisting = existingIndex.Find(choice?.ExistingLocationID);
|
||||||
var matchedExistingId = linkExisting
|
var matchedExistingId = linkExisting
|
||||||
? candidate.ExistingLocationID
|
? selectedExisting?.LocationID
|
||||||
|
?? candidate.ExistingLocationID
|
||||||
?? existingIndex.Find(importName, parentId)?.LocationID
|
?? existingIndex.Find(importName, parentId)?.LocationID
|
||||||
?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID
|
?? existingIndex.Find(candidate.LeafName, parentId)?.LocationID
|
||||||
: existingIndex.Find(importName, parentId)?.LocationID;
|
: existingIndex.Find(importName, parentId)?.LocationID;
|
||||||
var locationId = matchedExistingId;
|
var locationId = matchedExistingId;
|
||||||
|
if (linkExisting && !locationId.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!locationId.HasValue)
|
if (!locationId.HasValue)
|
||||||
{
|
{
|
||||||
@ -251,6 +297,23 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
linkedExisting++;
|
linkedExisting++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkExisting && locationId.HasValue)
|
||||||
|
{
|
||||||
|
var primaryName = existingIndex.Find(locationId.Value)?.LocationName ?? candidate.ExistingLocationName ?? importName;
|
||||||
|
if (await TryAddAliasAsync(locationId.Value, candidate.DisplayName, primaryName))
|
||||||
|
{
|
||||||
|
aliasesAdded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var alias in candidate.Aliases)
|
||||||
|
{
|
||||||
|
if (await TryAddAliasAsync(locationId.Value, alias, primaryName))
|
||||||
|
{
|
||||||
|
aliasesAdded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
|
AddResolvedName(resolvedNames, candidate.DisplayName, locationId.Value);
|
||||||
AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value);
|
AddResolvedName(resolvedNames, candidate.LeafName, locationId.Value);
|
||||||
AddResolvedName(resolvedNames, importName, locationId.Value);
|
AddResolvedName(resolvedNames, importName, locationId.Value);
|
||||||
@ -282,7 +345,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
if (IsPendingSameLocationChoice(choice))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -293,7 +356,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
foreach (var candidate in data.Candidates)
|
foreach (var candidate in data.Candidates)
|
||||||
{
|
{
|
||||||
if (!choices.TryGetValue(candidate.Key, out var choice)
|
if (!choices.TryGetValue(candidate.Key, out var choice)
|
||||||
|| !string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase))
|
|| !IsPendingSameLocationChoice(choice))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -303,7 +366,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(targetKey, candidate.Key, StringComparison.OrdinalIgnoreCase)
|
||||||
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
|| !choices.TryGetValue(targetKey, out var targetChoice)
|
||||||
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Ignore, StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(targetChoice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
|| IsPendingSameLocationChoice(targetChoice)
|
||||||
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
|| !candidateByKey.TryGetValue(targetKey, out var targetCandidate)
|
||||||
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
|
|| !resolvedCandidateIds.TryGetValue(targetKey, out var targetLocationId))
|
||||||
{
|
{
|
||||||
@ -321,7 +384,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId);
|
AddResolvedName(resolvedNames, candidate.DisplayName, targetLocationId);
|
||||||
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.Alias, candidate.DisplayName, targetLocationId, true);
|
AddDecision(batch, candidate.Key, StoryIntelligenceLocationImportActions.LinkExisting, candidate.DisplayName, targetLocationId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var candidate in data.Candidates)
|
foreach (var candidate in data.Candidates)
|
||||||
@ -332,7 +395,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var locationId = string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
var locationId = IsPendingSameLocationChoice(choice)
|
||||||
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
|
? resolvedCandidateIds.GetValueOrDefault(Clean(choice.AliasTargetKey))
|
||||||
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
|
: resolvedCandidateIds.GetValueOrDefault(candidate.Key);
|
||||||
if (locationId <= 0)
|
if (locationId <= 0)
|
||||||
@ -340,18 +403,7 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked))
|
sceneLocationsUpdated += await LinkSceneLocationsAsync(candidate, locationId);
|
||||||
{
|
|
||||||
var scene = await scenes.GetAsync(appearance.SceneID);
|
|
||||||
if (scene is null || scene.PrimaryLocationID.HasValue)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.PrimaryLocationID = locationId;
|
|
||||||
await scenes.SaveAsync(scene);
|
|
||||||
sceneLocationsUpdated++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
batch.LocationStageComplete = completeReview;
|
batch.LocationStageComplete = completeReview;
|
||||||
@ -920,6 +972,31 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel>> ToIdentityOptionsAsync(IReadOnlyList<LocationItem> projectLocations)
|
||||||
|
{
|
||||||
|
var options = new List<StoryIntelligenceLocationIdentityOptionViewModel>();
|
||||||
|
foreach (var location in projectLocations)
|
||||||
|
{
|
||||||
|
var aliasNames = (await locations.ListAliasesAsync(location.LocationID))
|
||||||
|
.Select(alias => alias.Alias)
|
||||||
|
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
options.Add(new StoryIntelligenceLocationIdentityOptionViewModel
|
||||||
|
{
|
||||||
|
LocationID = location.LocationID,
|
||||||
|
LocationName = location.LocationName,
|
||||||
|
AliasSummary = string.Join(", ", aliasNames.Take(4))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(location => location.LocationID)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
|
private async Task<LocationIndex> BuildLocationIndexAsync(int projectId)
|
||||||
{
|
{
|
||||||
var index = new LocationIndex();
|
var index = new LocationIndex();
|
||||||
@ -936,6 +1013,34 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsAutoResolvableKnownIdentity(LocationCandidate candidate, LocationIndex existingIndex)
|
||||||
|
=> candidate.ExistingLocationID.HasValue
|
||||||
|
&& existingIndex.Find(candidate.DisplayName)?.LocationID == candidate.ExistingLocationID.Value;
|
||||||
|
|
||||||
|
private static bool IsPendingSameLocationChoice(StoryIntelligenceLocationImportChoiceForm choice)
|
||||||
|
=> string.Equals(choice.Action, StoryIntelligenceLocationImportActions.Alias, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (string.Equals(choice.Action, StoryIntelligenceLocationImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.IsNullOrWhiteSpace(choice.AliasTargetKey));
|
||||||
|
|
||||||
|
private async Task<int> LinkSceneLocationsAsync(LocationCandidate candidate, int locationId)
|
||||||
|
{
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var appearance in candidate.Appearances.Where(appearance => appearance.PresentInScene && !appearance.AlreadyLinked))
|
||||||
|
{
|
||||||
|
var scene = await scenes.GetAsync(appearance.SceneID);
|
||||||
|
if (scene is null || scene.PrimaryLocationID.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.PrimaryLocationID = locationId;
|
||||||
|
await scenes.SaveAsync(scene);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> TryAddAliasAsync(int locationId, string alias, string primaryName)
|
private async Task<bool> TryAddAliasAsync(int locationId, string alias, string primaryName)
|
||||||
{
|
{
|
||||||
var cleanAlias = Clean(alias);
|
var cleanAlias = Clean(alias);
|
||||||
@ -1559,8 +1664,9 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
private sealed class LocationIndex
|
private sealed class LocationIndex
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, LocationMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, List<LocationMatch>> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly Dictionary<string, LocationMatch> byParentAndName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, List<LocationMatch>> byParentAndName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<int, LocationMatch> byId = new();
|
||||||
|
|
||||||
public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath)
|
public void Add(string? name, int locationId, string locationName, int? parentLocationId, string? parentLocationName, string? locationPath)
|
||||||
{
|
{
|
||||||
@ -1568,8 +1674,9 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
if (!string.IsNullOrWhiteSpace(key))
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
{
|
{
|
||||||
var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath);
|
var match = new LocationMatch(locationId, locationName, parentLocationId, parentLocationName, locationPath);
|
||||||
byName.TryAdd(key, match);
|
byId.TryAdd(locationId, match);
|
||||||
byParentAndName.TryAdd(ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
|
AddMatch(byName, key, match);
|
||||||
|
AddMatch(byParentAndName, ParentAwareCandidateKey(parentLocationId?.ToString(), key), match);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1583,12 +1690,37 @@ public sealed class StoryIntelligenceLocationImportService(
|
|||||||
|
|
||||||
if (parentLocationId.HasValue)
|
if (parentLocationId.HasValue)
|
||||||
{
|
{
|
||||||
return byParentAndName.TryGetValue(ParentAwareCandidateKey(parentLocationId.Value.ToString(), key), out var parentMatch)
|
return FindUnambiguous(byParentAndName, ParentAwareCandidateKey(parentLocationId.Value.ToString(), key));
|
||||||
? parentMatch
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return byName.TryGetValue(key, out var match) ? match : null;
|
return FindUnambiguous(byName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocationMatch? Find(int? locationId)
|
||||||
|
=> locationId.HasValue && byId.TryGetValue(locationId.Value, out var match) ? match : null;
|
||||||
|
|
||||||
|
private static void AddMatch(Dictionary<string, List<LocationMatch>> index, string key, LocationMatch match)
|
||||||
|
{
|
||||||
|
if (!index.TryGetValue(key, out var matches))
|
||||||
|
{
|
||||||
|
matches = [];
|
||||||
|
index[key] = matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.All(existing => existing.LocationID != match.LocationID))
|
||||||
|
{
|
||||||
|
matches.Add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationMatch? FindUnambiguous(Dictionary<string, List<LocationMatch>> index, string key)
|
||||||
|
{
|
||||||
|
if (!index.TryGetValue(key, out var matches))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.Select(match => match.LocationID).Distinct().Count() == 1 ? matches[0] : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ namespace PlotLine.Services;
|
|||||||
public interface IStoryIntelligenceReviewCandidateService
|
public interface IStoryIntelligenceReviewCandidateService
|
||||||
{
|
{
|
||||||
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
Task PrepareCharacterCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
|
Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch);
|
||||||
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||||
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize);
|
||||||
@ -65,15 +66,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
|
|
||||||
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
public async Task PreparePeerCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
{
|
{
|
||||||
await PersistAsync(
|
await PrepareLocationCandidatesAsync(batch);
|
||||||
batch,
|
|
||||||
StoryIntelligenceReviewModules.Locations,
|
|
||||||
(await locationImport.BuildReviewAsync(batch)).Candidates,
|
|
||||||
candidate => candidate.Key,
|
|
||||||
candidate => candidate.LocationName,
|
|
||||||
candidate => candidate.Category,
|
|
||||||
candidate => candidate.ExistingLocationID,
|
|
||||||
candidate => candidate.EvidenceSummaries.Take(5));
|
|
||||||
|
|
||||||
await PersistAsync(
|
await PersistAsync(
|
||||||
batch,
|
batch,
|
||||||
@ -106,6 +99,31 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
|
candidate => new[] { candidate.FirstScene, candidate.ExampleScene, candidate.Evidence }.Where(value => !string.IsNullOrWhiteSpace(value))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task PrepareLocationCandidatesAsync(OnboardingStoryIntelligenceBatch batch)
|
||||||
|
{
|
||||||
|
var automatic = await locationImport.ResolveAutomaticMatchesAsync(batch);
|
||||||
|
var review = await locationImport.BuildReviewAsync(batch);
|
||||||
|
await PersistAsync(
|
||||||
|
batch,
|
||||||
|
StoryIntelligenceReviewModules.Locations,
|
||||||
|
review.Candidates,
|
||||||
|
candidate => candidate.Key,
|
||||||
|
candidate => candidate.LocationName,
|
||||||
|
candidate => candidate.Category,
|
||||||
|
candidate => candidate.ExistingLocationID,
|
||||||
|
candidate => candidate.EvidenceSummaries.Take(5));
|
||||||
|
if (automatic.CandidatesResolved > 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Persisted Story Intelligence location review omitted automatically resolved identities. BatchID={BatchID} ProjectID={ProjectID} BookID={BookID} CandidatesResolved={CandidatesResolved} SceneLocationsLinked={SceneLocationsLinked}",
|
||||||
|
batch.BatchID,
|
||||||
|
batch.ProjectID,
|
||||||
|
batch.BookID,
|
||||||
|
automatic.CandidatesResolved,
|
||||||
|
automatic.SceneLocationsLinked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
public async Task<StoryIntelligenceCharacterReviewViewModel> GetCharacterReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||||
{
|
{
|
||||||
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
|
var page = await LoadPageAsync<StoryIntelligenceCharacterReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Characters, pageNumber, pageSize);
|
||||||
@ -159,7 +177,8 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
public async Task<StoryIntelligenceLocationReviewViewModel> GetLocationReviewAsync(OnboardingStoryIntelligenceBatch batch, int pageNumber, int pageSize)
|
||||||
{
|
{
|
||||||
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
|
var page = await LoadPageAsync<StoryIntelligenceLocationReviewCandidateViewModel>(batch.BookID, StoryIntelligenceReviewModules.Locations, pageNumber, pageSize);
|
||||||
var parentOptions = (await locations.ListByProjectAsync(batch.ProjectID))
|
var projectLocations = (await locations.ListByProjectAsync(batch.ProjectID)).ToList();
|
||||||
|
var parentOptions = projectLocations
|
||||||
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(location => location.LocationPath, StringComparer.OrdinalIgnoreCase)
|
||||||
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
|
.ThenBy(location => location.LocationName, StringComparer.OrdinalIgnoreCase)
|
||||||
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
|
.Select(location => new StoryIntelligenceLocationParentOptionViewModel
|
||||||
@ -169,6 +188,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
LocationPath = location.LocationPath
|
LocationPath = location.LocationPath
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var sameLocationOptions = await BuildSameLocationOptionsAsync(projectLocations);
|
||||||
return new StoryIntelligenceLocationReviewViewModel
|
return new StoryIntelligenceLocationReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = true,
|
HasCommittedScenes = true,
|
||||||
@ -180,6 +200,7 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
TotalCandidateCount = page.TotalCount,
|
TotalCandidateCount = page.TotalCount,
|
||||||
PendingCandidateCount = page.PendingCount,
|
PendingCandidateCount = page.PendingCount,
|
||||||
ParentLocationOptions = parentOptions,
|
ParentLocationOptions = parentOptions,
|
||||||
|
SameLocationOptions = sameLocationOptions,
|
||||||
Candidates = page.Items
|
Candidates = page.Items
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -277,13 +298,39 @@ public sealed class StoryIntelligenceReviewCandidateService(
|
|||||||
Module = StoryIntelligenceReviewModules.Locations,
|
Module = StoryIntelligenceReviewModules.Locations,
|
||||||
StableKey = choice.Key,
|
StableKey = choice.Key,
|
||||||
DecisionAction = choice.Action,
|
DecisionAction = choice.Action,
|
||||||
DecisionTargetKey = choice.AliasTargetKey
|
DecisionTargetKey = choice.AliasTargetKey,
|
||||||
|
DecisionCanonicalID = choice.ExistingLocationID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
|
return generation?.PendingCount ?? (await candidates.GetCurrentGenerationAsync(bookId, StoryIntelligenceReviewModules.Locations))?.PendingCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel>> BuildSameLocationOptionsAsync(IReadOnlyList<LocationItem> projectLocations)
|
||||||
|
{
|
||||||
|
var options = new List<StoryIntelligenceLocationIdentityOptionViewModel>();
|
||||||
|
foreach (var location in projectLocations)
|
||||||
|
{
|
||||||
|
var aliasNames = (await locations.ListAliasesAsync(location.LocationID))
|
||||||
|
.Select(alias => alias.Alias)
|
||||||
|
.Where(alias => !string.IsNullOrWhiteSpace(alias) && !string.Equals(alias, location.LocationName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(alias => alias, NaturalStringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
options.Add(new StoryIntelligenceLocationIdentityOptionViewModel
|
||||||
|
{
|
||||||
|
LocationID = location.LocationID,
|
||||||
|
LocationName = location.LocationName,
|
||||||
|
AliasSummary = string.Join(", ", aliasNames.Take(4))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
.OrderBy(location => location.LocationName, NaturalStringComparer.OrdinalIgnoreCase)
|
||||||
|
.ThenBy(location => location.LocationID)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
|
public async Task<int> MarkAssetDecisionsAsync(int bookId, IEnumerable<StoryIntelligenceAssetImportChoiceForm> choices)
|
||||||
{
|
{
|
||||||
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
StoryIntelligenceReviewCandidateGeneration? generation = null;
|
||||||
|
|||||||
@ -454,6 +454,7 @@ public sealed class StoryIntelligenceLocationReviewViewModel
|
|||||||
public bool HasPreviousPage => PageNumber > 1;
|
public bool HasPreviousPage => PageNumber > 1;
|
||||||
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
|
public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount;
|
||||||
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
|
||||||
|
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { get; init; } = [];
|
||||||
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
|
public IReadOnlyList<StoryIntelligenceLocationReviewCandidateViewModel> Candidates { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,6 +466,13 @@ public sealed class StoryIntelligenceLocationParentOptionViewModel
|
|||||||
public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath;
|
public string DisplayName => string.IsNullOrWhiteSpace(LocationPath) ? LocationName : LocationPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceLocationIdentityOptionViewModel
|
||||||
|
{
|
||||||
|
public int LocationID { get; init; }
|
||||||
|
public string LocationName { get; init; } = string.Empty;
|
||||||
|
public string? AliasSummary { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
public sealed class StoryIntelligenceLocationReviewCandidateViewModel
|
||||||
{
|
{
|
||||||
public string Key { get; init; } = string.Empty;
|
public string Key { get; init; } = string.Empty;
|
||||||
@ -500,6 +508,7 @@ public sealed class StoryIntelligenceLocationImportChoiceForm
|
|||||||
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
|
public string Action { get; set; } = StoryIntelligenceLocationImportActions.CreateNew;
|
||||||
public string? ImportName { get; set; }
|
public string? ImportName { get; set; }
|
||||||
public string? AliasTargetKey { get; set; }
|
public string? AliasTargetKey { get; set; }
|
||||||
|
public int? ExistingLocationID { get; set; }
|
||||||
public int? ParentLocationID { get; set; }
|
public int? ParentLocationID { get; set; }
|
||||||
public string? ParentSelection { get; set; }
|
public string? ParentSelection { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<div class="onboarding-copy story-review-heading">
|
<div class="onboarding-copy story-review-heading">
|
||||||
<p class="eyebrow">Story Intelligence</p>
|
<p class="eyebrow">Story Intelligence</p>
|
||||||
<h1 id="story-location-title">Review Locations</h1>
|
<h1 id="story-location-title">Review Locations</h1>
|
||||||
<p>Review place suggestions that need a create, link, alias, or ignore decision.</p>
|
<p>Review place suggestions that need a create, same-location, or ignore decision.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
|
||||||
@ -37,7 +37,7 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="story-review-card-actions">
|
<div class="story-review-card-actions">
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Link existing matches</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
|
||||||
@if (hiddenCandidateCount > 0)
|
@if (hiddenCandidateCount > 0)
|
||||||
@ -109,12 +109,8 @@
|
|||||||
Create new location
|
Create new location
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.LinkExisting" data-location-action />
|
||||||
Link existing location
|
Same location as
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Alias" data-location-action />
|
|
||||||
Alias of another location
|
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
|
<input type="radio" name="Locations[@i].Action" value="@StoryIntelligenceLocationImportActions.Ignore" data-location-action />
|
||||||
@ -156,11 +152,23 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-location-alias-panel hidden>
|
<div data-location-same-panel hidden>
|
||||||
<label class="form-label" for="location-alias-target-@i">Alias/variant target</label>
|
<label class="form-label" for="location-same-existing-@i">Same location as existing</label>
|
||||||
|
<select id="location-same-existing-@i" class="form-select" name="Locations[@i].ExistingLocationID" data-location-existing-target>
|
||||||
|
<option value="">Choose existing location...</option>
|
||||||
|
@foreach (var target in Model.LocationReview.SameLocationOptions)
|
||||||
|
{
|
||||||
|
var isSelected = candidate.ExistingLocationID == target.LocationID;
|
||||||
|
<option value="@target.LocationID" selected="@isSelected">@target.LocationName@(string.IsNullOrWhiteSpace(target.AliasSummary) ? string.Empty : $" ({target.AliasSummary})")</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="form-label mt-3" for="location-alias-target-@i">Same location as pending candidate</label>
|
||||||
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
|
<select id="location-alias-target-@i" class="form-select" name="Locations[@i].AliasTargetKey" data-location-alias-target>
|
||||||
<option value="">Choose location...</option>
|
<option value="">Choose pending location...</option>
|
||||||
@foreach (var target in locationCandidates.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase)))
|
@foreach (var target in locationCandidates
|
||||||
|
.Where(target => !string.Equals(target.Key, candidate.Key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(target => target.LocationName, NaturalStringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
|
<option value="@target.Key">@target.LocationName@(target.IsExistingMatch ? $" -> {target.ExistingLocationName}" : string.Empty)</option>
|
||||||
}
|
}
|
||||||
@ -210,7 +218,9 @@
|
|||||||
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
|
const actionFor = (card) => card.querySelector("[data-location-action]:checked")?.value || "@StoryIntelligenceLocationImportActions.CreateNew";
|
||||||
const isCanonicalTarget = (card) => {
|
const isCanonicalTarget = (card) => {
|
||||||
const action = actionFor(card);
|
const action = actionFor(card);
|
||||||
return action !== "@StoryIntelligenceLocationImportActions.Ignore" && action !== "@StoryIntelligenceLocationImportActions.Alias";
|
const pendingTarget = card.querySelector("[data-location-alias-target]")?.value || "";
|
||||||
|
return action !== "@StoryIntelligenceLocationImportActions.Ignore"
|
||||||
|
&& !pendingTarget;
|
||||||
};
|
};
|
||||||
const updateAliasTargets = () => {
|
const updateAliasTargets = () => {
|
||||||
const allCards = cards();
|
const allCards = cards();
|
||||||
@ -236,18 +246,18 @@
|
|||||||
const updateCard = (card) => {
|
const updateCard = (card) => {
|
||||||
const action = actionFor(card);
|
const action = actionFor(card);
|
||||||
const namePanel = card.querySelector("[data-location-import-name-panel]");
|
const namePanel = card.querySelector("[data-location-import-name-panel]");
|
||||||
const aliasPanel = card.querySelector("[data-location-alias-panel]");
|
const samePanel = card.querySelector("[data-location-same-panel]");
|
||||||
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
|
||||||
const parentPanel = card.querySelector("[data-location-parent-panel]");
|
const parentPanel = card.querySelector("[data-location-parent-panel]");
|
||||||
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.Alias";
|
if (parentPanel) parentPanel.hidden = action === "@StoryIntelligenceLocationImportActions.Ignore" || action === "@StoryIntelligenceLocationImportActions.LinkExisting";
|
||||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceLocationImportActions.Alias";
|
if (samePanel) samePanel.hidden = action !== "@StoryIntelligenceLocationImportActions.LinkExisting";
|
||||||
updateAliasTargets();
|
updateAliasTargets();
|
||||||
};
|
};
|
||||||
document.querySelectorAll("[data-location-bulk]").forEach(button => {
|
document.querySelectorAll("[data-location-bulk]").forEach(button => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const action = button.getAttribute("data-location-bulk");
|
const action = button.getAttribute("data-location-bulk");
|
||||||
if (action === "link-existing") cards().forEach(card => {
|
if (action === "link-existing") cards().forEach(card => {
|
||||||
if (card.querySelector(`[data-location-action][value="@StoryIntelligenceLocationImportActions.LinkExisting"]:not(:disabled)`)) {
|
if (card.querySelector("[data-location-existing-target]")?.value) {
|
||||||
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
|
setAction(card, "@StoryIntelligenceLocationImportActions.LinkExisting");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -258,6 +268,25 @@
|
|||||||
form.querySelectorAll("[data-location-action]").forEach(input => {
|
form.querySelectorAll("[data-location-action]").forEach(input => {
|
||||||
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
input.addEventListener("change", () => updateCard(input.closest("[data-location-card]")));
|
||||||
});
|
});
|
||||||
|
form.querySelectorAll("[data-location-existing-target]").forEach(select => {
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
if (select.value) {
|
||||||
|
const card = select.closest("[data-location-card]");
|
||||||
|
const pending = card?.querySelector("[data-location-alias-target]");
|
||||||
|
if (pending) pending.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
form.querySelectorAll("[data-location-alias-target]").forEach(select => {
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
if (select.value) {
|
||||||
|
const card = select.closest("[data-location-card]");
|
||||||
|
const existing = card?.querySelector("[data-location-existing-target]");
|
||||||
|
if (existing) existing.value = "";
|
||||||
|
}
|
||||||
|
cards().forEach(updateCard);
|
||||||
|
});
|
||||||
|
});
|
||||||
cards().forEach(updateCard);
|
cards().forEach(updateCard);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user