From 1f6a88a175bd708f4218e509c58917d5d2767997 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sun, 28 Jun 2026 22:15:29 +0100 Subject: [PATCH] Phase 16M - Location Detection Foundation and Alias Improvements --- PlotLine/Controllers/LocationsController.cs | 2 + .../Controllers/WordCompanionController.cs | 7 + PlotLine/Data/Repositories.cs | 4 +- PlotLine/Data/WordCompanionRepository.cs | 11 ++ PlotLine/Models/CoreModels.cs | 2 + PlotLine/Models/WordCompanionApiModels.cs | 18 ++ PlotLine/Services/CoreServices.cs | 6 +- PlotLine/Services/WordCompanionService.cs | 16 ++ ...105_Phase16M_LocationDetectionMetadata.sql | 172 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 5 + PlotLine/Views/Locations/Edit.cshtml | 14 ++ 11 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Sql/105_Phase16M_LocationDetectionMetadata.sql diff --git a/PlotLine/Controllers/LocationsController.cs b/PlotLine/Controllers/LocationsController.cs index 47c40fe..4d59753 100644 --- a/PlotLine/Controllers/LocationsController.cs +++ b/PlotLine/Controllers/LocationsController.cs @@ -52,6 +52,8 @@ public sealed class LocationsController(ILocationService locations) : Controller editModel.LocationTypeID = model.LocationTypeID; editModel.Description = model.Description; editModel.ShowInQuickAddBar = model.ShowInQuickAddBar; + editModel.ExcludeFromCompanionDetection = model.ExcludeFromCompanionDetection; + editModel.DetectionPriority = model.DetectionPriority; return View("Edit", editModel); } diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index fd36c4e..c125fcf 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -170,6 +170,13 @@ public sealed class WordCompanionController( return response is null ? BadRequest() : Ok(response); } + [HttpGet("runtime/locations")] + public async Task GetRuntimeLocations([FromQuery] int projectId, [FromQuery] Guid documentGuid) + { + var response = await wordCompanion.ListRuntimeLocationAliasesAsync(projectId, documentGuid); + return response is null ? BadRequest() : Ok(response); + } + [HttpGet("manuscript/book/{bookId:int}")] public async Task GetManuscriptForBook(int bookId) { diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index f5af755..7251a33 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -502,7 +502,9 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) location.LocationName, location.LocationTypeID, location.Description, - location.ShowInQuickAddBar + location.ShowInQuickAddBar, + location.ExcludeFromCompanionDetection, + location.DetectionPriority }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Data/WordCompanionRepository.cs b/PlotLine/Data/WordCompanionRepository.cs index bc572f7..5684733 100644 --- a/PlotLine/Data/WordCompanionRepository.cs +++ b/PlotLine/Data/WordCompanionRepository.cs @@ -25,6 +25,7 @@ public interface IWordCompanionRepository Task CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request); Task> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid); Task CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request); + Task> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid); Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request); Task ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request); Task HasProjectAccessAsync(int projectId, int userId); @@ -325,6 +326,16 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact commandType: CommandType.StoredProcedure); } + public async Task> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.WordCompanion_Runtime_LocationAlias_List", + new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 6ee678d..49d2117 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1246,6 +1246,8 @@ public sealed class LocationItem public string LocationPath { get; set; } = string.Empty; public int Depth { get; set; } public bool ShowInQuickAddBar { get; set; } + public bool ExcludeFromCompanionDetection { get; set; } + public int DetectionPriority { get; set; } = 50; public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } diff --git a/PlotLine/Models/WordCompanionApiModels.cs b/PlotLine/Models/WordCompanionApiModels.cs index d8406aa..789700e 100644 --- a/PlotLine/Models/WordCompanionApiModels.cs +++ b/PlotLine/Models/WordCompanionApiModels.cs @@ -304,6 +304,24 @@ public sealed class WordCompanionRuntimeAssetSuggestionsResponse public string Message { get; init; } = "No new asset suggestions."; } +public sealed class WordCompanionRuntimeLocationAliasDto +{ + public int LocationId { get; init; } + public int? ParentLocationId { get; init; } + public string Name { get; init; } = string.Empty; + public string LocationPath { get; init; } = string.Empty; + public string MatchText { get; init; } = string.Empty; + public bool IsDisplayName { get; init; } + public bool ExcludeFromCompanionDetection { get; init; } + public int DetectionPriority { get; init; } = 50; +} + +public sealed class WordCompanionRuntimeLocationAliasResponse +{ + public int ProjectId { get; init; } + public IReadOnlyList Locations { get; init; } = []; +} + public sealed class WordCompanionManuscriptDocumentDto { public int ManuscriptDocumentId { get; init; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 4fbd94d..6f24f14 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -7815,6 +7815,8 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi LocationTypeID = location.LocationTypeID, Description = location.Description, ShowInQuickAddBar = location.ShowInQuickAddBar, + ExcludeFromCompanionDetection = location.ExcludeFromCompanionDetection, + DetectionPriority = location.DetectionPriority, Project = await projects.GetAsync(location.ProjectID) }); } @@ -7868,7 +7870,9 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi LocationName = model.LocationName, LocationTypeID = model.LocationTypeID, Description = model.Description, - ShowInQuickAddBar = model.ShowInQuickAddBar + ShowInQuickAddBar = model.ShowInQuickAddBar, + ExcludeFromCompanionDetection = model.ExcludeFromCompanionDetection, + DetectionPriority = model.DetectionPriority }); await SyncLocationAliasesAsync(locationId, model.Aliases); await activity.RecordAsync(model.ProjectID, isNew ? "Created" : "Updated", "Location", locationId, model.LocationName); diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index dac898b..0fc2547 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -25,6 +25,7 @@ public interface IWordCompanionService Task CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request); Task ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid); Task CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request); + Task ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid); Task GetRuntimeBookAsync(int bookId, Guid documentGuid); Task GetManuscriptForBookAsync(int bookId); Task LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request); @@ -310,6 +311,21 @@ public sealed class WordCompanionService( : repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request); } + public async Task ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid) + { + if (projectId <= 0 || documentGuid == Guid.Empty) + { + return null; + } + + var aliases = await repository.ListRuntimeLocationAliasesAsync(RequireUserId(), projectId, documentGuid); + return new WordCompanionRuntimeLocationAliasResponse + { + ProjectId = projectId, + Locations = aliases + }; + } + public async Task GetRuntimeBookAsync(int bookId, Guid documentGuid) { if (bookId <= 0 || documentGuid == Guid.Empty) diff --git a/PlotLine/Sql/105_Phase16M_LocationDetectionMetadata.sql b/PlotLine/Sql/105_Phase16M_LocationDetectionMetadata.sql new file mode 100644 index 0000000..4a24e12 --- /dev/null +++ b/PlotLine/Sql/105_Phase16M_LocationDetectionMetadata.sql @@ -0,0 +1,172 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.Locations', N'ExcludeFromCompanionDetection') IS NULL + ALTER TABLE dbo.Locations ADD ExcludeFromCompanionDetection bit NOT NULL CONSTRAINT DF_Locations_ExcludeFromCompanionDetection DEFAULT (0); +GO + +IF COL_LENGTH(N'dbo.Locations', N'DetectionPriority') IS NULL + ALTER TABLE dbo.Locations ADD DetectionPriority int NOT NULL CONSTRAINT DF_Locations_DetectionPriority DEFAULT (50); +GO + +CREATE OR ALTER VIEW dbo.LocationPaths +AS +WITH LocationTree AS +( + SELECT LocationID, ProjectID, ParentLocationID, LocationName, LocationTypeID, Description, ShowInQuickAddBar, + ExcludeFromCompanionDetection, DetectionPriority, + CAST(LocationName AS nvarchar(max)) AS LocationPath, + CAST(RIGHT('000000' + CAST(LocationID AS varchar(6)), 6) AS nvarchar(max)) AS SortPath, + 0 AS Depth + FROM dbo.Locations + WHERE ParentLocationID IS NULL AND IsArchived = 0 + UNION ALL + SELECT child.LocationID, child.ProjectID, child.ParentLocationID, child.LocationName, child.LocationTypeID, child.Description, child.ShowInQuickAddBar, + child.ExcludeFromCompanionDetection, child.DetectionPriority, + CAST(parent.LocationPath + N' > ' + child.LocationName AS nvarchar(max)), + CAST(parent.SortPath + N'.' + RIGHT('000000' + CAST(child.LocationID AS varchar(6)), 6) AS nvarchar(max)), + parent.Depth + 1 + FROM dbo.Locations child + INNER JOIN LocationTree parent ON parent.LocationID = child.ParentLocationID + WHERE child.IsArchived = 0 +) +SELECT LocationID, ProjectID, ParentLocationID, LocationName, LocationTypeID, Description, ShowInQuickAddBar, + ExcludeFromCompanionDetection, DetectionPriority, LocationPath, SortPath, Depth +FROM LocationTree; +GO + +CREATE OR ALTER PROCEDURE dbo.Location_ListByProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT lp.LocationID, lp.ProjectID, lp.ParentLocationID, lp.LocationName, lp.LocationTypeID, lt.TypeName AS LocationTypeName, + lp.Description, lp.ShowInQuickAddBar, lp.ExcludeFromCompanionDetection, lp.DetectionPriority, + lp.LocationPath, lp.Depth, l.CreatedDate, l.UpdatedDate, l.IsArchived + FROM dbo.LocationPaths lp + INNER JOIN dbo.Locations l ON l.LocationID = lp.LocationID + LEFT JOIN dbo.LocationTypes lt ON lt.LocationTypeID = lp.LocationTypeID + WHERE lp.ProjectID = @ProjectID + ORDER BY lp.SortPath; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Location_Get + @LocationID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT lp.LocationID, lp.ProjectID, lp.ParentLocationID, parent.LocationName AS ParentLocationName, + lp.LocationName, lp.LocationTypeID, lt.TypeName AS LocationTypeName, lp.Description, lp.ShowInQuickAddBar, + lp.ExcludeFromCompanionDetection, lp.DetectionPriority, lp.LocationPath, lp.Depth, + l.CreatedDate, l.UpdatedDate, l.IsArchived + FROM dbo.LocationPaths lp + INNER JOIN dbo.Locations l ON l.LocationID = lp.LocationID + LEFT JOIN dbo.Locations parent ON parent.LocationID = lp.ParentLocationID + LEFT JOIN dbo.LocationTypes lt ON lt.LocationTypeID = lp.LocationTypeID + WHERE lp.LocationID = @LocationID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Location_Save + @LocationID int = NULL, + @ProjectID int, + @ParentLocationID int = NULL, + @LocationName nvarchar(200), + @LocationTypeID int = NULL, + @Description nvarchar(max) = NULL, + @ShowInQuickAddBar bit = 0, + @ExcludeFromCompanionDetection bit = 0, + @DetectionPriority int = 50 +AS +BEGIN + SET NOCOUNT ON; + + SET @DetectionPriority = COALESCE(@DetectionPriority, 50); + + IF @LocationID IS NULL OR @LocationID = 0 + BEGIN + INSERT dbo.Locations (ProjectID, ParentLocationID, LocationName, LocationTypeID, Description, ShowInQuickAddBar, ExcludeFromCompanionDetection, DetectionPriority) + VALUES (@ProjectID, @ParentLocationID, @LocationName, @LocationTypeID, @Description, @ShowInQuickAddBar, @ExcludeFromCompanionDetection, @DetectionPriority); + + SET @LocationID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Locations + SET ParentLocationID = @ParentLocationID, + LocationName = @LocationName, + LocationTypeID = @LocationTypeID, + Description = @Description, + ShowInQuickAddBar = @ShowInQuickAddBar, + ExcludeFromCompanionDetection = @ExcludeFromCompanionDetection, + DetectionPriority = @DetectionPriority, + UpdatedDate = SYSUTCDATETIME() + WHERE LocationID = @LocationID; + END + + SELECT @LocationID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.WordCompanion_Runtime_LocationAlias_List + @UserID int, + @ProjectID int, + @DocumentGuid uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + IF @DocumentGuid IS NULL + OR @DocumentGuid = '00000000-0000-0000-0000-000000000000' + OR @ProjectID <= 0 + RETURN; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.ManuscriptDocuments md + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = md.ProjectID + INNER JOIN dbo.Projects p ON p.ProjectID = md.ProjectID + WHERE md.DocumentGuid = @DocumentGuid + AND md.ProjectID = @ProjectID + AND md.IsActive = 1 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + SELECT lp.LocationID AS LocationId, + lp.ParentLocationID AS ParentLocationId, + lp.LocationName AS Name, + lp.LocationPath, + lp.LocationName AS MatchText, + CAST(1 AS bit) AS IsDisplayName, + lp.ExcludeFromCompanionDetection, + lp.DetectionPriority + FROM dbo.LocationPaths lp + WHERE lp.ProjectID = @ProjectID + AND NULLIF(LTRIM(RTRIM(lp.LocationName)), N'') IS NOT NULL + + UNION ALL + + SELECT lp.LocationID AS LocationId, + lp.ParentLocationID AS ParentLocationId, + lp.LocationName AS Name, + lp.LocationPath, + la.Alias AS MatchText, + CAST(0 AS bit) AS IsDisplayName, + lp.ExcludeFromCompanionDetection, + lp.DetectionPriority + FROM dbo.LocationPaths lp + INNER JOIN dbo.LocationAliases la ON la.LocationID = lp.LocationID + WHERE lp.ProjectID = @ProjectID + AND NULLIF(LTRIM(RTRIM(la.Alias)), N'') IS NOT NULL + ORDER BY DetectionPriority DESC, Name, LocationId, IsDisplayName DESC, MatchText; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index cb0ed4c..afa8482 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1937,6 +1937,11 @@ public sealed class LocationEditViewModel public string? Description { get; set; } [Display(Name = "Show in Quick Add Bar")] public bool ShowInQuickAddBar { get; set; } + [Display(Name = "Exclude from Word Companion detection")] + public bool ExcludeFromCompanionDetection { get; set; } + [Display(Name = "Detection priority")] + [Range(0, 1000, ErrorMessage = "Detection priority must be between 0 and 1000.")] + public int DetectionPriority { get; set; } = 50; public Project? Project { get; set; } public IReadOnlyList ParentLocationOptions { get; set; } = []; public IReadOnlyList LocationTypeOptions { get; set; } = []; diff --git a/PlotLine/Views/Locations/Edit.cshtml b/PlotLine/Views/Locations/Edit.cshtml index 2fe354e..1cd6ad4 100644 --- a/PlotLine/Views/Locations/Edit.cshtml +++ b/PlotLine/Views/Locations/Edit.cshtml @@ -69,6 +69,20 @@
Makes this item available as a draggable shortcut on the Timeline page.
+
+
+

Companion Detection

+
+ + +
Use this for generic places that would create noisy suggestions, such as Home, Kitchen, Road or Shop.
+
+ + + +
Higher values make this location stronger evidence in future Word Companion detection. The default is 50.
+
+