diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index c5a7ec1..56c397e 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -29,7 +29,11 @@ public sealed class StoryIntelligenceLocationImportService( "room", "place", "area", "building", "street", "road", "door", "doorway", "wall", "floor", "pavement", "car", "car park", "parking lot", "stairs", "stairwell", "landing", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "desk", "pew", "altar", "outside", - "inside", "nearby", "somewhere", "home", "house", "flat"); + "inside", "nearby", "somewhere", "home", "house", "flat", "streets", "main road", "dual carriageway", + "flyover", "motorway roundabout", "under the tree", "under-flyover / link road", "sitting room", "ring road"); + + private static readonly HashSet ThrowawayLocationFragments = CreateSet( + "door", "porch", "exterior", "tree", "pavement"); private static readonly HashSet InternalLocationWords = CreateSet( "room", "kitchen", "bathroom", "hallway", "corridor", "lobby", "reception", "stairwell", "stairs", @@ -481,6 +485,16 @@ public sealed class StoryIntelligenceLocationImportService( private static bool IsVisibleLocationCandidate(LocationCandidate candidate) { + if (IsGenericComposite(candidate.DisplayName)) + { + return false; + } + + if (ContainsThrowawayFragment(candidate.DisplayName)) + { + return false; + } + if (IsNamedLocation(candidate.DisplayName)) { return true; @@ -488,12 +502,13 @@ public sealed class StoryIntelligenceLocationImportService( if (!string.IsNullOrWhiteSpace(candidate.ParentLocationHint)) { - return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0; + return candidate.Appearances.Count(appearance => appearance.PresentInScene) > 0 + && (InternalLocationWords.Contains(Normalise(candidate.DisplayName)) || !IsGenericLocation(candidate.DisplayName)); } if (IsGenericLocation(candidate.DisplayName)) { - return candidate.Appearances.Select(appearance => appearance.SceneID).Distinct().Count() >= 2; + return false; } return true; @@ -506,17 +521,52 @@ public sealed class StoryIntelligenceLocationImportService( } private static bool IsGenericLocation(string name) - => GenericLocations.Contains(Normalise(name)) || InternalLocationWords.Contains(Normalise(name)); + { + var normalised = Normalise(name); + var withoutParentheses = Normalise(Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty)); + return GenericLocations.Contains(normalised) + || GenericLocations.Contains(withoutParentheses) + || InternalLocationWords.Contains(normalised) + || InternalLocationWords.Contains(withoutParentheses); + } + + private static bool IsGenericComposite(string name) + { + var value = Normalise(name); + if (!value.Contains('/') && !value.Contains(" / ")) + { + return false; + } + + return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .All(part => GenericLocations.Contains(part) || InternalLocationWords.Contains(part) || NamedRoadPattern.IsMatch(part) == false); + } + + private static bool ContainsThrowawayFragment(string name) + { + var value = Normalise(name); + return ThrowawayLocationFragments.Any(fragment => value.Contains(fragment, StringComparison.OrdinalIgnoreCase)); + } private static bool IsNamedLocation(string name) - => AddressPattern.IsMatch(name) + { + var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); + return AddressPattern.IsMatch(withoutParentheses) || IsNamedRoad(name) - || name.Any(char.IsUpper) - || name.Contains('\'') - || name.Contains('’'); + || withoutParentheses.Any(char.IsUpper) + || withoutParentheses.Contains('\'') + || withoutParentheses.Contains('’'); + } private static bool IsNamedRoad(string name) - => NamedRoadPattern.IsMatch(name) && !GenericLocations.Contains(Normalise(name)); + { + var withoutParentheses = Regex.Replace(name, @"\s*\([^)]*\)\s*$", string.Empty); + return !name.Contains('/') + && withoutParentheses.Any(char.IsUpper) + && NamedRoadPattern.IsMatch(withoutParentheses) + && !GenericLocations.Contains(Normalise(name)) + && !GenericLocations.Contains(Normalise(withoutParentheses)); + } private static string Categorise(string name, string? locationType, string? genericRoomType) { diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml new file mode 100644 index 0000000..1b94ea1 --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocationComplete.cshtml @@ -0,0 +1,53 @@ +@model StoryIntelligenceLocationImportResultViewModel +@{ + ViewData["Title"] = "Locations created"; +} + +
+
+ +
+

Build Story Database

+
+ +

Locations created

+
+

PlotDirector updated your story database from the approved location decisions. Future Story Intelligence stages will continue from here.

+
+ +
+
+ Locations created + @Model.LocationsCreated.ToString("N0") +
+
+ Locations linked + @Model.LocationsLinked.ToString("N0") +
+
+ Aliases added + @Model.LocationAliasesAdded.ToString("N0") +
+
+ Locations ignored + @Model.LocationsIgnored.ToString("N0") +
+
+ Scene World panels updated + @Model.SceneLocationsUpdated.ToString("N0") +
+
+ +
+
Review AssetsComing Soon
+
Review RelationshipsComing Soon
+
Review KnowledgeComing Soon
+
Review ContinuityComing Soon
+
+ + +
+
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml new file mode 100644 index 0000000..8fdeceb --- /dev/null +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -0,0 +1,225 @@ +@model StoryIntelligenceProgressViewModel +@{ + ViewData["Title"] = "Review locations"; +} + +
+
+ +
+

Review Story Intelligence

+

Review Locations

+

Approve the places PlotDirector should create or link from your imported scenes. Nothing is created unless you approve it.

+
+ + @if (TempData["OnboardingStoryIntelligenceError"] is string error) + { +
@error
+ } + @if (TempData["OnboardingStoryIntelligenceMessage"] is string message) + { +
@message
+ } + + + +
+ @if (Model.LocationReview.Candidates.Count == 0) + { +
+ No location decisions are waiting. +

Locations are already up to date, or there were no location suggestions ready to import.

+
+
+ @FutureStage("Review Assets") + @FutureStage("Review Relationships") + @FutureStage("Review Knowledge") + @FutureStage("Review Continuity") +
+ + } + else + { +
+ + + + + +
+ +
+ +
+ @for (var i = 0; i < Model.LocationReview.Candidates.Count; i++) + { + var candidate = Model.LocationReview.Candidates[i]; +
+ + + + @candidate.LocationName + @candidate.Category · @candidate.AppearsInScenes.ToString("N0") scene@(candidate.AppearsInScenes == 1 ? string.Empty : "s") + + @candidate.Confidence + + + + +
+
+
First appearance
@Display(candidate.ExampleFirstAppearance)
+
Parent hint
@Display(candidate.ParentLocationHint)
+
Existing match
@(candidate.ExistingLocationName ?? "None")
+
+ + @if (!string.IsNullOrWhiteSpace(candidate.ExampleContext)) + { +
+ Scene context +

@candidate.ExampleContext

+
+ } + + @if (candidate.IsExistingMatch) + { +
+ Possible existing location +

@candidate.LocationName may already be @candidate.ExistingLocationName. Choose whether to link them or create a separate location.

+
+ } + +
+ Decision + + @if (candidate.IsExistingMatch) + { + + } + + +
+ +
+ + +
+ + +
+
+ } +
+ +
+ Back to characters + +
+
+ } +
+
+
+ +@section Scripts { + +} + +@functions { + private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value; + + private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label) + => new Microsoft.AspNetCore.Html.HtmlString($"
{System.Net.WebUtility.HtmlEncode(label)}Coming Soon
"); +}