Phase 16M - Location Detection Foundation and Alias Improvements

This commit is contained in:
Nick Beckley 2026-06-28 22:15:29 +01:00
parent 749b671834
commit 1f6a88a175
11 changed files with 255 additions and 2 deletions

View File

@ -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);
}

View File

@ -170,6 +170,13 @@ public sealed class WordCompanionController(
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("runtime/locations")]
public async Task<IActionResult> 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<IActionResult> GetManuscriptForBook(int bookId)
{

View File

@ -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);
}

View File

@ -25,6 +25,7 @@ public interface IWordCompanionRepository
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(int userId, WordCompanionRuntimeCharacterSuggestionsRequest request);
Task<IReadOnlyList<WordCompanionRuntimeAssetAliasDto>> ListRuntimeAssetAliasesAsync(int userId, int projectId, Guid documentGuid);
Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(int userId, WordCompanionRuntimeAssetSuggestionsRequest request);
Task<IReadOnlyList<WordCompanionRuntimeLocationAliasDto>> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request);
Task<WordCompanionManuscriptImportResponse?> ImportManuscriptAsync(int userId, WordCompanionManuscriptImportRequest request);
Task<bool> HasProjectAccessAsync(int projectId, int userId);
@ -325,6 +326,16 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
commandType: CommandType.StoredProcedure);
}
public async Task<IReadOnlyList<WordCompanionRuntimeLocationAliasDto>> ListRuntimeLocationAliasesAsync(int userId, int projectId, Guid documentGuid)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WordCompanionRuntimeLocationAliasDto>(
"dbo.WordCompanion_Runtime_LocationAlias_List",
new { UserID = userId, ProjectID = projectId, DocumentGuid = documentGuid },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(int userId, WordCompanionManuscriptAnalyseRequest request)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -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; }

View File

@ -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<WordCompanionRuntimeLocationAliasDto> Locations { get; init; } = [];
}
public sealed class WordCompanionManuscriptDocumentDto
{
public int ManuscriptDocumentId { get; init; }

View File

@ -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);

View File

@ -25,6 +25,7 @@ public interface IWordCompanionService
Task<WordCompanionRuntimeCharacterSuggestionsResponse?> CreateRuntimeCharacterSuggestionsAsync(WordCompanionRuntimeCharacterSuggestionsRequest request);
Task<WordCompanionRuntimeAssetAliasResponse?> ListRuntimeAssetAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeAssetSuggestionsResponse?> CreateRuntimeAssetSuggestionsAsync(WordCompanionRuntimeAssetSuggestionsRequest request);
Task<WordCompanionRuntimeLocationAliasResponse?> ListRuntimeLocationAliasesAsync(int projectId, Guid documentGuid);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
@ -310,6 +311,21 @@ public sealed class WordCompanionService(
: repository.CreateRuntimeAssetSuggestionsAsync(RequireUserId(), request);
}
public async Task<WordCompanionRuntimeLocationAliasResponse?> 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<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
{
if (bookId <= 0 || documentGuid == Guid.Empty)

View File

@ -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

View File

@ -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<SelectListItem> ParentLocationOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> LocationTypeOptions { get; set; } = [];

View File

@ -69,6 +69,20 @@
<div class="form-text">Makes this item available as a draggable shortcut on the Timeline page.</div>
</div>
</div>
<div class="col-12">
<section class="list-section p-3">
<h2 class="h5">Companion Detection</h2>
<div class="form-check mb-3">
<input asp-for="ExcludeFromCompanionDetection" class="form-check-input" />
<label asp-for="ExcludeFromCompanionDetection" class="form-check-label"></label>
<div class="form-text">Use this for generic places that would create noisy suggestions, such as Home, Kitchen, Road or Shop.</div>
</div>
<label asp-for="DetectionPriority" class="form-label"></label>
<input asp-for="DetectionPriority" class="form-control form-control-sm" type="number" min="0" max="1000" />
<span asp-validation-for="DetectionPriority" class="text-danger"></span>
<div class="form-text">Higher values make this location stronger evidence in future Word Companion detection. The default is 50.</div>
</section>
</div>
<div class="col-12">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="5"></textarea>