From 524515e9b63d6c4005b04e2db7e3d841e2d2c6fc Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 29 Jun 2026 13:37:50 +0100 Subject: [PATCH] Phase 16O - Review and Accept Word Companion Location Suggestions --- PlotLine/Controllers/ScenesController.cs | 32 +++ PlotLine/Data/Repositories.cs | 42 ++++ PlotLine/Models/CoreModels.cs | 30 +++ PlotLine/Services/CoreServices.cs | 44 +++- ..._WordCompanionLocationSuggestionReview.sql | 193 ++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 2 + PlotLine/Views/Chapters/Details.cshtml | 12 +- PlotLine/Views/Scenes/_SceneInspector.cshtml | 42 +++- 8 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 PlotLine/Sql/107_Phase16O_WordCompanionLocationSuggestionReview.sql diff --git a/PlotLine/Controllers/ScenesController.cs b/PlotLine/Controllers/ScenesController.cs index 7f8f934..1680e50 100644 --- a/PlotLine/Controllers/ScenesController.cs +++ b/PlotLine/Controllers/ScenesController.cs @@ -307,6 +307,38 @@ public sealed class ScenesController( return RedirectForScene(sceneId, projectId, bookId); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task AcceptLocationSuggestion(int id, int sceneId, int? projectId, int? bookId) + { + try + { + await locations.AcceptSceneLocationSuggestionAsync(id); + } + catch (InvalidOperationException ex) + { + TempData["SceneLocationSuggestionError"] = ex.Message; + } + + return RedirectForScene(sceneId, projectId, bookId); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RejectLocationSuggestion(int id, int sceneId, int? projectId, int? bookId) + { + try + { + await locations.RejectSceneLocationSuggestionAsync(id); + } + catch (InvalidOperationException ex) + { + TempData["SceneLocationSuggestionError"] = ex.Message; + } + + return RedirectForScene(sceneId, projectId, bookId); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task AddSceneDependency(SceneDependencyCreateViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 7251a33..b982d1c 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -261,6 +261,10 @@ public interface ILocationRepository Task> ListSceneAssetLocationsAsync(int sceneId); Task SaveSceneAssetLocationAsync(SceneAssetLocation sceneAssetLocation, bool updateCurrentLocation); Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId); + Task> ListSceneLocationSuggestionsAsync(int sceneId, int userId); + Task AcceptSceneLocationSuggestionAsync(int suggestionId, int userId); + Task RejectSceneLocationSuggestionAsync(int suggestionId, int userId); + Task> CountSceneLocationSuggestionsByChapterAsync(int chapterId, int userId); Task> ListScenesByLocationAsync(int locationId); Task> ListCharactersByLocationAsync(int locationId); Task> ListAssetsByLocationAsync(int locationId); @@ -625,6 +629,44 @@ public sealed class LocationRepository(ISqlConnectionFactory connectionFactory) await connection.ExecuteAsync("dbo.SceneAssetLocation_Delete", new { SceneAssetLocationID = sceneAssetLocationId }, commandType: CommandType.StoredProcedure); } + public async Task> ListSceneLocationSuggestionsAsync(int sceneId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.SceneLocationSuggestions_ListByScene", + new { SceneID = sceneId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task AcceptSceneLocationSuggestionAsync(int suggestionId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.SceneLocationSuggestion_Accept", + new { SceneLocationSuggestionID = suggestionId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task RejectSceneLocationSuggestionAsync(int suggestionId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.SceneLocationSuggestion_Reject", + new { SceneLocationSuggestionID = suggestionId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task> CountSceneLocationSuggestionsByChapterAsync(int chapterId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.SceneLocationSuggestions_CountByChapter", + new { ChapterID = chapterId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task> ListScenesByLocationAsync(int locationId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 49d2117..37be60f 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -403,6 +403,7 @@ public sealed class Scene public int DependencyCount { get; set; } public int PendingCharacterSuggestionCount { get; set; } public int PendingAssetSuggestionCount { get; set; } + public int PendingLocationSuggestionCount { get; set; } public string TimeLabel { @@ -1037,6 +1038,35 @@ public sealed class SceneAssetSuggestionReviewResult public bool SceneAssetCreated { get; set; } } +public sealed class SceneLocationSuggestion +{ + public int SceneLocationSuggestionID { get; set; } + public int SceneID { get; set; } + public int LocationID { get; set; } + public string LocationName { get; set; } = string.Empty; + public string LocationPath { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Confidence { get; set; } = string.Empty; + public string? Reason { get; set; } + public DateTime DetectedUtc { get; set; } +} + +public sealed class SceneLocationSuggestionCount +{ + public int SceneID { get; set; } + public int PendingSuggestionCount { get; set; } +} + +public sealed class SceneLocationSuggestionReviewResult +{ + public int SceneLocationSuggestionID { get; set; } + public int SceneID { get; set; } + public int LocationID { get; set; } + public string Status { get; set; } = string.Empty; + public bool SceneLocationCreated { get; set; } + public bool PrimaryLocationUpdated { get; set; } +} + public sealed class CharacterAttributeType { public int CharacterAttributeTypeID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 6f24f14..1b104fe 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -195,6 +195,8 @@ public interface ILocationService Task ArchiveRelationshipAsync(int locationRelationshipId); Task SaveSceneAssetLocationAsync(SceneAssetLocationCreateViewModel model); Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId); + Task AcceptSceneLocationSuggestionAsync(int suggestionId); + Task RejectSceneLocationSuggestionAsync(int suggestionId); } public interface IFloorPlanService @@ -1293,6 +1295,7 @@ public sealed class ChapterService( ISceneRepository scenes, ICharacterRepository characters, IAssetRepository assets, + ILocationRepository locations, ILookupRepository lookups, ISubscriptionService subscriptions, IProjectActivityService activity, @@ -1320,10 +1323,13 @@ public sealed class ChapterService( var countsByScene = suggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount); var assetSuggestionCounts = await assets.CountSceneAssetSuggestionsByChapterAsync(chapterId, userId); var assetCountsByScene = assetSuggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount); + var locationSuggestionCounts = await locations.CountSceneLocationSuggestionsByChapterAsync(chapterId, userId); + var locationCountsByScene = locationSuggestionCounts.ToDictionary(x => x.SceneID, x => x.PendingSuggestionCount); foreach (var scene in sceneRows) { scene.PendingCharacterSuggestionCount = countsByScene.GetValueOrDefault(scene.SceneID); scene.PendingAssetSuggestionCount = assetCountsByScene.GetValueOrDefault(scene.SceneID); + scene.PendingLocationSuggestionCount = locationCountsByScene.GetValueOrDefault(scene.SceneID); } } @@ -1334,7 +1340,8 @@ public sealed class ChapterService( Chapter = chapter, Scenes = sceneRows, PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount), - PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount) + PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount), + PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount) }; } @@ -1659,6 +1666,7 @@ public sealed class SceneService( model.SceneCharacters = model.SceneID == 0 ? [] : await characters.ListSceneCharactersAsync(model.SceneID); model.CharacterSuggestions = model.SceneID == 0 || currentUser.UserId is not int userId ? [] : await characters.ListSceneCharacterSuggestionsAsync(model.SceneID, userId); model.AssetSuggestions = model.SceneID == 0 || currentUser.UserId is not int assetUserId ? [] : await assets.ListSceneAssetSuggestionsAsync(model.SceneID, assetUserId); + model.LocationSuggestions = model.SceneID == 0 || currentUser.UserId is not int locationUserId ? [] : await locations.ListSceneLocationSuggestionsAsync(model.SceneID, locationUserId); model.CharacterAttributeEvents = model.SceneID == 0 ? [] : await characters.ListAttributeEventsBySceneAsync(model.SceneID); model.Warnings = model.SceneID == 0 ? [] : await warnings.ListBySceneAsync(model.SceneID); model.AgeContinuityWarnings = await BuildAgeContinuityWarningsAsync(model); @@ -7772,7 +7780,11 @@ public sealed class AssetService( => items.Select(x => new SelectListItem(new string(' ', x.Depth * 2) + x.LocationName, x.LocationID.ToString())).ToList(); } -public sealed class LocationService(IProjectRepository projects, ILocationRepository locations, IProjectActivityService activity) : ILocationService +public sealed class LocationService( + IProjectRepository projects, + ILocationRepository locations, + IProjectActivityService activity, + ICurrentUserService currentUser) : ILocationService { public async Task GetLocationsAsync(int projectId) { @@ -7924,6 +7936,34 @@ public sealed class LocationService(IProjectRepository projects, ILocationReposi public Task DeleteSceneAssetLocationAsync(int sceneAssetLocationId) => locations.DeleteSceneAssetLocationAsync(sceneAssetLocationId); + public async Task AcceptSceneLocationSuggestionAsync(int suggestionId) + { + if (currentUser.UserId is not int userId) + { + throw new InvalidOperationException("Sign in to accept location suggestions."); + } + + var result = await locations.AcceptSceneLocationSuggestionAsync(suggestionId, userId); + if (result is null) + { + throw new InvalidOperationException("Unable to accept this location suggestion."); + } + } + + public async Task RejectSceneLocationSuggestionAsync(int suggestionId) + { + if (currentUser.UserId is not int userId) + { + throw new InvalidOperationException("Sign in to reject location suggestions."); + } + + var result = await locations.RejectSceneLocationSuggestionAsync(suggestionId, userId); + if (result is null) + { + throw new InvalidOperationException("Unable to reject this location suggestion."); + } + } + private async Task PopulateLocationListsAsync(LocationEditViewModel model) { var lookups = await locations.GetLookupsAsync(); diff --git a/PlotLine/Sql/107_Phase16O_WordCompanionLocationSuggestionReview.sql b/PlotLine/Sql/107_Phase16O_WordCompanionLocationSuggestionReview.sql new file mode 100644 index 0000000..a3a3076 --- /dev/null +++ b/PlotLine/Sql/107_Phase16O_WordCompanionLocationSuggestionReview.sql @@ -0,0 +1,193 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneLocationSuggestions_ListByScene + @SceneID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT suggestion.SceneLocationSuggestionID, + suggestion.SceneID, + suggestion.LocationID, + location.LocationName, + location.LocationPath, + suggestion.Status, + suggestion.Confidence, + suggestion.Reason, + suggestion.DetectedUtc + FROM dbo.SceneLocationSuggestions suggestion + INNER JOIN dbo.Scenes s ON s.SceneID = suggestion.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + INNER JOIN dbo.LocationPaths location ON location.LocationID = suggestion.LocationID + WHERE suggestion.SceneID = @SceneID + AND suggestion.Status = N'Pending' + AND s.IsArchived = 0 + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND location.ProjectID = p.ProjectID + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ORDER BY + CASE suggestion.Confidence WHEN N'High' THEN 0 WHEN N'Medium' THEN 1 ELSE 2 END, + location.LocationPath; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneLocationSuggestion_Accept + @SceneLocationSuggestionID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @SceneID int; + DECLARE @LocationID int; + DECLARE @SceneLocationCreated bit = 0; + DECLARE @PrimaryLocationUpdated bit = 0; + + SELECT @SceneID = suggestion.SceneID, + @LocationID = suggestion.LocationID + FROM dbo.SceneLocationSuggestions suggestion + INNER JOIN dbo.Scenes s ON s.SceneID = suggestion.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + INNER JOIN dbo.Locations location ON location.LocationID = suggestion.LocationID AND location.ProjectID = p.ProjectID + WHERE suggestion.SceneLocationSuggestionID = @SceneLocationSuggestionID + AND suggestion.Status = N'Pending' + AND s.IsArchived = 0 + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND location.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; + + IF @SceneID IS NULL OR @LocationID IS NULL + RETURN; + + IF NOT EXISTS (SELECT 1 FROM dbo.WordCompanionSceneLocations WHERE SceneID = @SceneID AND LocationID = @LocationID) + BEGIN + INSERT dbo.WordCompanionSceneLocations (SceneID, LocationID) + VALUES (@SceneID, @LocationID); + + SET @SceneLocationCreated = 1; + END; + + UPDATE dbo.Scenes + SET PrimaryLocationID = @LocationID, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID + AND PrimaryLocationID IS NULL; + + IF @@ROWCOUNT > 0 + SET @PrimaryLocationUpdated = 1; + + UPDATE dbo.SceneLocationSuggestions + SET Status = N'Accepted', + ReviewedByUserID = @UserID, + ReviewedUtc = SYSUTCDATETIME() + WHERE SceneLocationSuggestionID = @SceneLocationSuggestionID + AND Status = N'Pending'; + + SELECT @SceneLocationSuggestionID AS SceneLocationSuggestionID, + @SceneID AS SceneID, + @LocationID AS LocationID, + N'Accepted' AS Status, + @SceneLocationCreated AS SceneLocationCreated, + @PrimaryLocationUpdated AS PrimaryLocationUpdated; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneLocationSuggestion_Reject + @SceneLocationSuggestionID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @SceneID int; + DECLARE @LocationID int; + + SELECT @SceneID = suggestion.SceneID, + @LocationID = suggestion.LocationID + FROM dbo.SceneLocationSuggestions suggestion + INNER JOIN dbo.Scenes s ON s.SceneID = suggestion.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + INNER JOIN dbo.Locations location ON location.LocationID = suggestion.LocationID AND location.ProjectID = p.ProjectID + WHERE suggestion.SceneLocationSuggestionID = @SceneLocationSuggestionID + AND suggestion.Status = N'Pending' + AND s.IsArchived = 0 + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND location.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1; + + IF @SceneID IS NULL OR @LocationID IS NULL + RETURN; + + UPDATE dbo.SceneLocationSuggestions + SET Status = N'Rejected', + ReviewedByUserID = @UserID, + ReviewedUtc = SYSUTCDATETIME() + WHERE SceneLocationSuggestionID = @SceneLocationSuggestionID + AND Status = N'Pending'; + + SELECT @SceneLocationSuggestionID AS SceneLocationSuggestionID, + @SceneID AS SceneID, + @LocationID AS LocationID, + N'Rejected' AS Status, + CAST(0 AS bit) AS SceneLocationCreated, + CAST(0 AS bit) AS PrimaryLocationUpdated; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneLocationSuggestions_CountByChapter + @ChapterID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS + ( + SELECT 1 + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID + WHERE c.ChapterID = @ChapterID + AND c.IsArchived = 0 + AND b.IsArchived = 0 + AND p.IsArchived = 0 + AND pua.UserID = @UserID + AND pua.IsActive = 1 + ) + RETURN; + + SELECT s.SceneID, + COUNT(suggestion.SceneLocationSuggestionID) AS PendingSuggestionCount + FROM dbo.Scenes s + INNER JOIN dbo.SceneLocationSuggestions suggestion ON suggestion.SceneID = s.SceneID + INNER JOIN dbo.Locations location ON location.LocationID = suggestion.LocationID + WHERE s.ChapterID = @ChapterID + AND s.IsArchived = 0 + AND location.IsArchived = 0 + AND suggestion.Status = N'Pending' + GROUP BY s.SceneID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index afa8482..15f06c8 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -268,6 +268,7 @@ public sealed class ChapterDetailViewModel public IReadOnlyList Scenes { get; set; } = []; public int PendingCharacterSuggestionCount { get; set; } public int PendingAssetSuggestionCount { get; set; } + public int PendingLocationSuggestionCount { get; set; } } public sealed class SceneEditViewModel @@ -342,6 +343,7 @@ public sealed class SceneEditViewModel public IReadOnlyList SceneCharacters { get; set; } = []; public IReadOnlyList CharacterSuggestions { get; set; } = []; public IReadOnlyList AssetSuggestions { get; set; } = []; + public IReadOnlyList LocationSuggestions { get; set; } = []; public IReadOnlyList CharacterAttributeEvents { get; set; } = []; public IReadOnlyList CharacterKnowledge { get; set; } = []; public IReadOnlyList RelationshipEvents { get; set; } = []; diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index f6e05f1..2170650 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -51,7 +51,7 @@ -@if (Model.PendingCharacterSuggestionCount > 0 || Model.PendingAssetSuggestionCount > 0) +@if (Model.PendingCharacterSuggestionCount > 0 || Model.PendingAssetSuggestionCount > 0 || Model.PendingLocationSuggestionCount > 0) {

Word Companion Suggestions

@@ -63,6 +63,10 @@ {

@Model.PendingAssetSuggestionCount asset suggestion@(Model.PendingAssetSuggestionCount == 1 ? "" : "s") awaiting review in this chapter.

} + @if (Model.PendingLocationSuggestionCount > 0) + { +

@Model.PendingLocationSuggestionCount location suggestion@(Model.PendingLocationSuggestionCount == 1 ? "" : "s") awaiting review in this chapter.

+ }
} @@ -95,7 +99,7 @@ @scene.SceneNumber @scene.SceneTitle - @if (scene.PendingCharacterSuggestionCount > 0 || scene.PendingAssetSuggestionCount > 0) + @if (scene.PendingCharacterSuggestionCount > 0 || scene.PendingAssetSuggestionCount > 0 || scene.PendingLocationSuggestionCount > 0) { var suggestionParts = new List(); if (scene.PendingCharacterSuggestionCount > 0) @@ -106,6 +110,10 @@ { suggestionParts.Add($"{scene.PendingAssetSuggestionCount} Asset{(scene.PendingAssetSuggestionCount == 1 ? "" : "s")}"); } + if (scene.PendingLocationSuggestionCount > 0) + { + suggestionParts.Add($"{scene.PendingLocationSuggestionCount} Location{(scene.PendingLocationSuggestionCount == 1 ? "" : "s")}"); + } @string.Join(" • ", suggestionParts) } diff --git a/PlotLine/Views/Scenes/_SceneInspector.cshtml b/PlotLine/Views/Scenes/_SceneInspector.cshtml index 6d6b8fa..9397d10 100644 --- a/PlotLine/Views/Scenes/_SceneInspector.cshtml +++ b/PlotLine/Views/Scenes/_SceneInspector.cshtml @@ -69,7 +69,7 @@ } - @if (Model.CharacterSuggestions.Any() || Model.AssetSuggestions.Any()) + @if (Model.CharacterSuggestions.Any() || Model.AssetSuggestions.Any() || Model.LocationSuggestions.Any()) {

Word Companion Suggestions

@@ -81,6 +81,10 @@ {

@assetSuggestionError

} + @if (TempData["SceneLocationSuggestionError"] is string locationSuggestionError) + { +

@locationSuggestionError

+ }
@if (Model.CharacterSuggestions.Any()) @@ -151,6 +155,40 @@ } } + @if (Model.LocationSuggestions.Any()) + { +

Locations

+ @foreach (var suggestion in Model.LocationSuggestions) + { +
+
+ @(!string.IsNullOrWhiteSpace(suggestion.LocationPath) ? suggestion.LocationPath : suggestion.LocationName) + @suggestion.Confidence confidence + @if (!string.IsNullOrWhiteSpace(suggestion.Reason)) + { + @suggestion.Reason + } + Detected @suggestion.DetectedUtc.ToLocalTime().ToString("d MMM yyyy HH:mm") +
+
+
+ + + + + +
+
+ + + + + +
+
+
+ } + }
@@ -160,7 +198,7 @@