From a90b98f8f0eeb3d55629b7a38b233dc802e34f17 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 24 Aug 2026 20:29:28 +0000 Subject: [PATCH] Improve post-import review defaults --- PlotLine.Tests/Program.cs | 81 +++++++++++ .../Controllers/ProjectMetricsController.cs | 6 +- .../Controllers/WordCompanionController.cs | 11 ++ PlotLine/Data/Repositories.cs | 10 ++ PlotLine/Models/NameIntelligenceModels.cs | 2 +- .../Models/WordCompanionPresenceModels.cs | 1 + PlotLine/Services/CoreServices.cs | 8 +- .../StoryIntelligenceAssetImportService.cs | 7 +- ...StoryIntelligenceCharacterImportService.cs | 7 +- .../StoryIntelligenceImportCommitService.cs | 7 +- ...StoryIntelligenceKnowledgeImportService.cs | 2 +- .../StoryIntelligenceLocationImportService.cs | 7 +- ...ryIntelligenceRelationshipImportService.cs | 2 +- .../Services/WordCompanionPresenceService.cs | 30 ++++ .../WritingWorkspacePresenceService.cs | 1 + .../Sql/029_ProjectSceneMetricsManagement.sql | 19 +++ PlotLine/Sql/148_Phase23_NameIntelligence.sql | 7 +- .../151_BackfillDefaultSceneMetricValues.sql | 38 +++++ .../Sql/152_NameLibraryAllLocalesDefault.sql | 131 ++++++++++++++++++ .../153_SelfHealingProjectMetricDefaults.sql | 58 ++++++++ PlotLine/Views/Locations/Details.cshtml | 11 +- PlotLine/Views/Names/Index.cshtml | 1 + PlotLine/wwwroot/js/writing-follow.js | 22 ++- 23 files changed, 448 insertions(+), 21 deletions(-) create mode 100644 PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql create mode 100644 PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql create mode 100644 PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 7f7c678..72f2ae7 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -74,12 +74,17 @@ var tests = new (string Name, Action Test)[] ("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), + ("Name Intelligence library defaults to all locales", NameIntelligenceLibraryDefaultsToAllLocales), ("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches), ("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences), ("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), ("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), + ("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels), + ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), + ("Project metrics add defaults backfills scene values", ProjectMetricsAddDefaultsBackfillsSceneValues), + ("Word Companion replays live current scene to writing panes", WordCompanionReplaysLiveCurrentSceneToWritingPanes), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), @@ -1932,6 +1937,20 @@ static void NameIntelligenceReviewWarningIsWired() Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning."); } +static void NameIntelligenceLibraryDefaultsToAllLocales() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var model = File.ReadAllText(Path.Combine(root, "Models/NameIntelligenceModels.cs")); + var view = File.ReadAllText(Path.Combine(root, "Views/Names/Index.cshtml")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/152_NameLibraryAllLocalesDefault.sql")); + + Assert(model.Contains("public string? LocaleCode { get; set; }", StringComparison.Ordinal), "Name library filter should not default LocaleCode to a specific country."); + Assert(!model.Contains("LocaleCode { get; set; } = \"en-US\"", StringComparison.Ordinal), "Name library must not default to the United States locale."); + Assert(view.Contains(">All locales", StringComparison.Ordinal), "Name library locale dropdown should expose an All locales option."); + Assert(migration.Contains("DECLARE @NameLocaleID int = NULL", StringComparison.Ordinal), "Name search procedure should keep null locale as all locales."); + Assert(!migration.Contains("COALESCE(@LocaleCode, N'en-US')", StringComparison.Ordinal), "Name search procedure must not convert null locale back to en-US."); +} + static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches() { Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting, @@ -1993,6 +2012,68 @@ static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases() Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call."); } +static void StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var characterService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var locationService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); + var assetService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceAssetImportService.cs")); + var relationshipService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceRelationshipImportService.cs")); + var knowledgeService = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceKnowledgeImportService.cs")); + var locationView = File.ReadAllText(Path.Combine(root, "Views/Locations/Details.cshtml")); + + Assert(characterService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Character review first appearances should include chapter and scene."); + Assert(locationService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Location review first appearances should include chapter and scene."); + Assert(assetService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Asset review first appearances should include chapter and scene."); + Assert(relationshipService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Relationship review first appearances should include chapter and scene."); + Assert(knowledgeService.Contains("$\"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}\"", StringComparison.Ordinal), "Knowledge review first appearances should include chapter and scene."); + Assert(locationView.Contains("SceneLabel(scene.ChapterNumber, scene.SceneNumber)", StringComparison.Ordinal), "Location detail scene links should include chapter context."); +} + +static void StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceImportCommitService.cs")); + + Assert(service.Contains("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Story Intelligence import should enable default metric types for the project before committing scenes."); + Assert(service.Contains("metric.IsActive && metric.IsEnabledForProject", StringComparison.Ordinal), "Story Intelligence import should only save metrics enabled for the project."); + Assert(service.IndexOf("await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value)", StringComparison.Ordinal) + < service.IndexOf("ListForManagementAsync(run.ProjectID.Value)", StringComparison.Ordinal), "Metric defaults must be enabled before metric types are loaded."); +} + +static void ProjectMetricsAddDefaultsBackfillsSceneValues() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var core = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); + var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectMetricsController.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/151_BackfillDefaultSceneMetricValues.sql")); + var selfHealingDefaults = File.ReadAllText(Path.Combine(root, "Sql/153_SelfHealingProjectMetricDefaults.sql")); + + Assert(core.Contains("Task AddMissingDefaultsAsync(int projectId)", StringComparison.Ordinal), "Metric defaults service should report inserted scene values."); + Assert(core.Contains("BackfillDefaultValuesForProjectAsync(projectId)", StringComparison.Ordinal), "Metric defaults service should backfill scene metric values."); + Assert(repository.Contains("SceneMetric_BackfillDefaultsForProject", StringComparison.Ordinal), "Metric repository should call the scene metric backfill procedure."); + Assert(controller.Contains("backfilled", StringComparison.Ordinal), "Metric controller should tell users when scene values were backfilled."); + Assert(migration.Contains("NOT EXISTS", StringComparison.Ordinal), "Metric backfill must not overwrite existing scene metric values."); + Assert(selfHealingDefaults.Contains("MERGE dbo.SceneMetricTypes", StringComparison.Ordinal), "Add missing defaults should seed missing global metric types."); + Assert(selfHealingDefaults.Contains("Overall Intensity", StringComparison.Ordinal), "Core metric seed should include the default PlotDirector metrics."); +} + +static void WordCompanionReplaysLiveCurrentSceneToWritingPanes() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs")); + var presence = File.ReadAllText(Path.Combine(root, "Services/WordCompanionPresenceService.cs")); + var workspace = File.ReadAllText(Path.Combine(root, "Services/WritingWorkspacePresenceService.cs")); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js")); + + Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Word current-scene updates should update live presence."); + Assert(presence.Contains("CurrentStoryPosition = connected ? record.CurrentStoryPosition : null", StringComparison.Ordinal), "Presence should only replay current scene while Word is live."); + Assert(workspace.Contains("CurrentStoryPosition = wordStatus.CurrentStoryPosition", StringComparison.Ordinal), "Workspace status should carry Word's current scene."); + Assert(script.Contains("storyPositionFromWorkspaceStatus", StringComparison.Ordinal), "Writing panes should read the current scene from workspace status."); + Assert(script.Contains("WatchWritingWorkspace", StringComparison.Ordinal), "Writing panes should request scoped workspace status on startup."); +} + static int CountCsvRows(string path) => File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line)); diff --git a/PlotLine/Controllers/ProjectMetricsController.cs b/PlotLine/Controllers/ProjectMetricsController.cs index c4f6cdf..c6e7f2e 100644 --- a/PlotLine/Controllers/ProjectMetricsController.cs +++ b/PlotLine/Controllers/ProjectMetricsController.cs @@ -50,8 +50,10 @@ public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) : [ValidateAntiForgeryToken] public async Task AddMissingDefaults(int projectId) { - await metrics.AddMissingDefaultsAsync(projectId); - TempData["ProjectMetricMessage"] = "Missing default scene metrics added."; + var backfilled = await metrics.AddMissingDefaultsAsync(projectId); + TempData["ProjectMetricMessage"] = backfilled > 0 + ? $"Missing default scene metrics added. {backfilled:N0} scene metric value(s) backfilled." + : "Missing default scene metrics added. Existing scenes already had values for the enabled defaults."; return RedirectToAction(nameof(Index), new { projectId }); } diff --git a/PlotLine/Controllers/WordCompanionController.cs b/PlotLine/Controllers/WordCompanionController.cs index 827e3a7..978b148 100644 --- a/PlotLine/Controllers/WordCompanionController.cs +++ b/PlotLine/Controllers/WordCompanionController.cs @@ -13,6 +13,7 @@ namespace PlotLine.Controllers; [Route("api/word-companion")] public sealed class WordCompanionController( IWordCompanionService wordCompanion, + IWordCompanionPresenceService presence, IHubContext followHub) : ControllerBase { [HttpGet("projects")] @@ -130,6 +131,8 @@ public sealed class WordCompanionController( return BadRequest(); } + await presence.UpdateCurrentStoryPositionAsync(RequireUserId(), response.Position); + await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new { response.ProjectId, @@ -267,4 +270,12 @@ public sealed class WordCompanionController( await followHub.Clients.User(userId).SendAsync(eventName, payload); } } + + private int RequireUserId() + { + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); + return int.TryParse(raw, out var userId) && userId > 0 + ? userId + : throw new InvalidOperationException("The current user could not be identified."); + } } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 5746f23..739c21f 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -141,6 +141,7 @@ public interface ISceneMetricTypeRepository Task SetActiveAsync(int metricTypeId, int projectId, bool isActive); Task MoveAsync(int metricTypeId, int projectId, string direction); Task AddMissingDefaultsAsync(int projectId); + Task BackfillDefaultValuesForProjectAsync(int projectId); Task RemoveUnusedAsync(int metricTypeId, int projectId); Task HasProjectMetricsAsync(int projectId); } @@ -2864,6 +2865,15 @@ public sealed class SceneMetricTypeRepository(ISqlConnectionFactory connectionFa await connection.ExecuteAsync("dbo.SceneMetricType_AddMissingDefaultsForProject", new { ProjectID = projectId }, commandType: CommandType.StoredProcedure); } + public async Task BackfillDefaultValuesForProjectAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.SceneMetric_BackfillDefaultsForProject", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + public async Task RemoveUnusedAsync(int metricTypeId, int projectId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/NameIntelligenceModels.cs b/PlotLine/Models/NameIntelligenceModels.cs index 1862883..f029602 100644 --- a/PlotLine/Models/NameIntelligenceModels.cs +++ b/PlotLine/Models/NameIntelligenceModels.cs @@ -97,7 +97,7 @@ public sealed class NameSearchFilter public int ProjectID { get; set; } public int? BookID { get; set; } public string? Search { get; set; } - public string? LocaleCode { get; set; } = "en-US"; + public string? LocaleCode { get; set; } public int? BirthYear { get; set; } public string? GenderPreference { get; set; } public decimal? GenderThreshold { get; set; } diff --git a/PlotLine/Models/WordCompanionPresenceModels.cs b/PlotLine/Models/WordCompanionPresenceModels.cs index 3db10b9..874b9ca 100644 --- a/PlotLine/Models/WordCompanionPresenceModels.cs +++ b/PlotLine/Models/WordCompanionPresenceModels.cs @@ -39,6 +39,7 @@ public sealed class WordCompanionPresenceStatus public string? CurrentDocumentName { get; init; } public int? LinkedProjectID { get; init; } public int? LinkedBookID { get; init; } + public StoryPosition? CurrentStoryPosition { get; init; } public DateTime? ConnectedUtc { get; init; } public DateTime? LastHeartbeatUtc { get; init; } } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 65bc5bd..06d78b9 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -309,7 +309,7 @@ public interface ISceneMetricTypeService Task SaveAsync(SceneMetricTypeEditViewModel model); Task SetActiveAsync(int projectId, int metricTypeId, bool isActive); Task MoveAsync(int projectId, int metricTypeId, string direction); - Task AddMissingDefaultsAsync(int projectId); + Task AddMissingDefaultsAsync(int projectId); Task RemoveUnusedAsync(int projectId, int metricTypeId); } @@ -5247,7 +5247,11 @@ public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMe public Task MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction); - public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId); + public async Task AddMissingDefaultsAsync(int projectId) + { + await metricTypes.AddMissingDefaultsAsync(projectId); + return await metricTypes.BackfillDefaultValuesForProjectAsync(projectId); + } public Task RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId); } diff --git a/PlotLine/Services/StoryIntelligenceAssetImportService.cs b/PlotLine/Services/StoryIntelligenceAssetImportService.cs index b6298eb..1869b13 100644 --- a/PlotLine/Services/StoryIntelligenceAssetImportService.cs +++ b/PlotLine/Services/StoryIntelligenceAssetImportService.cs @@ -687,10 +687,13 @@ public sealed class StoryIntelligenceAssetImportService( { var summary = Clean(parsed.Summary?.Short); return string.IsNullOrWhiteSpace(summary) - ? $"Scene {scene.SceneNumber:g}: {assetName}" - : $"Scene {scene.SceneNumber:g}: {summary}"; + ? $"{BuildSceneReference(scene)}: {assetName}" + : $"{BuildSceneReference(scene)}: {summary}"; } + private static string BuildSceneReference(Scene scene) + => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; + private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, string? assetName, int? storyAssetId, bool createdOrLinked) { batch.AssetDecisions.RemoveAll(decision => string.Equals(decision.Key, key, StringComparison.OrdinalIgnoreCase)); diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index db69743..bdac3f1 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -725,13 +725,16 @@ public sealed class StoryIntelligenceCharacterImportService( var note = Clean(character.Notes); if (!string.IsNullOrWhiteSpace(note)) { - return $"Scene {scene.SceneNumber:g}: {note}"; + return $"{BuildSceneReference(scene)}: {note}"; } var summary = Clean(parsed.Summary?.Short); - return string.IsNullOrWhiteSpace(summary) ? $"Scene {scene.SceneNumber:g}: {scene.SceneTitle}" : $"Scene {scene.SceneNumber:g}: {summary}"; + return string.IsNullOrWhiteSpace(summary) ? $"{BuildSceneReference(scene)}: {scene.SceneTitle}" : $"{BuildSceneReference(scene)}: {summary}"; } + private static string BuildSceneReference(Scene scene) + => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; + private static decimal? Max(decimal? current, decimal? next) => current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? next; diff --git a/PlotLine/Services/StoryIntelligenceImportCommitService.cs b/PlotLine/Services/StoryIntelligenceImportCommitService.cs index 4d21ae8..90ec844 100644 --- a/PlotLine/Services/StoryIntelligenceImportCommitService.cs +++ b/PlotLine/Services/StoryIntelligenceImportCommitService.cs @@ -148,8 +148,13 @@ public sealed class StoryIntelligenceImportCommitService( } var lookupData = await lookups.GetAllAsync(); + if (run.ProjectID.HasValue) + { + await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value); + } + var activeMetricTypes = run.ProjectID.HasValue - ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive).ToList() + ? (await metricTypes.ListForManagementAsync(run.ProjectID.Value)).Where(metric => metric.IsActive && metric.IsEnabledForProject).ToList() : []; var noteTypes = await writerWorkspace.ListNoteTypesAsync(); var characterMap = run.ProjectID.HasValue diff --git a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs index 9230e7b..4c03bd2 100644 --- a/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs +++ b/PlotLine/Services/StoryIntelligenceKnowledgeImportService.cs @@ -608,7 +608,7 @@ public sealed class StoryIntelligenceKnowledgeImportService( } private static string BuildSceneReference(Scene scene) - => $"Scene {scene.SceneNumber:g}"; + => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; private static bool IsCharacterEntity(string? entityType) => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); diff --git a/PlotLine/Services/StoryIntelligenceLocationImportService.cs b/PlotLine/Services/StoryIntelligenceLocationImportService.cs index a174e13..fe88899 100644 --- a/PlotLine/Services/StoryIntelligenceLocationImportService.cs +++ b/PlotLine/Services/StoryIntelligenceLocationImportService.cs @@ -730,10 +730,13 @@ public sealed class StoryIntelligenceLocationImportService( { var summary = Clean(parsed.Summary?.Short); return string.IsNullOrWhiteSpace(summary) - ? $"Scene {scene.SceneNumber:g}: {locationName}" - : $"Scene {scene.SceneNumber:g}: {summary}"; + ? $"{BuildSceneReference(scene)}: {locationName}" + : $"{BuildSceneReference(scene)}: {summary}"; } + private static string BuildSceneReference(Scene scene) + => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; + private static string FirstConfigured(params string?[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; diff --git a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs index 0577d6a..c244bef 100644 --- a/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs +++ b/PlotLine/Services/StoryIntelligenceRelationshipImportService.cs @@ -623,7 +623,7 @@ public sealed class StoryIntelligenceRelationshipImportService( } private static string BuildSceneReference(Scene scene) - => $"Scene {scene.SceneNumber:g}"; + => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}"; private static void AddDecision(OnboardingStoryIntelligenceBatch batch, string key, string action, int? relationshipId, string? label, bool createdOrLinked) { diff --git a/PlotLine/Services/WordCompanionPresenceService.cs b/PlotLine/Services/WordCompanionPresenceService.cs index aaf6ff6..a8e2b33 100644 --- a/PlotLine/Services/WordCompanionPresenceService.cs +++ b/PlotLine/Services/WordCompanionPresenceService.cs @@ -9,6 +9,7 @@ public interface IWordCompanionPresenceService Task HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat); Task DisconnectAsync(string connectionId); Task GetStatusAsync(int userId); + Task UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position); Task IsConnectedAsync(int userId); Task> MarkStaleOfflineAsync(); Task GetCompanionConnectionIdAsync(int userId); @@ -68,6 +69,11 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService record.LinkedProjectID = Positive(heartbeat.LinkedProjectID); record.LinkedBookID = Positive(heartbeat.LinkedBookID); record.LastHeartbeatUtc = now; + if (record.CurrentStoryPosition is not null + && (record.LinkedProjectID != record.CurrentStoryPosition.ProjectId || record.LinkedBookID != record.CurrentStoryPosition.BookId)) + { + record.CurrentStoryPosition = null; + } usersByConnection[connectionId] = userId; return Task.FromResult(ToStatus(record, now)); @@ -97,6 +103,28 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService return Task.FromResult(ToStatus(record, DateTime.UtcNow)); } + public Task UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position) + { + var now = DateTime.UtcNow; + var record = recordsByUser.AddOrUpdate( + userId, + _ => new PresenceRecord + { + UserID = userId, + ConnectedUtc = now + }, + (_, existing) => existing); + + if (position?.IsValid == true) + { + record.CurrentStoryPosition = position; + record.LinkedProjectID = position.ProjectId; + record.LinkedBookID = position.BookId; + } + + return Task.FromResult(ToStatus(record, now)); + } + public async Task IsConnectedAsync(int userId) => (await GetStatusAsync(userId)).IsConnected; @@ -156,6 +184,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService CurrentDocumentName = record.CurrentDocumentName, LinkedProjectID = record.LinkedProjectID, LinkedBookID = record.LinkedBookID, + CurrentStoryPosition = connected ? record.CurrentStoryPosition : null, ConnectedUtc = record.ConnectedUtc, LastHeartbeatUtc = record.LastHeartbeatUtc }; @@ -181,6 +210,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService public string? CurrentDocumentName { get; set; } public int? LinkedProjectID { get; set; } public int? LinkedBookID { get; set; } + public StoryPosition? CurrentStoryPosition { get; set; } public DateTime? ConnectedUtc { get; set; } public DateTime? LastHeartbeatUtc { get; set; } } diff --git a/PlotLine/Services/WritingWorkspacePresenceService.cs b/PlotLine/Services/WritingWorkspacePresenceService.cs index d6de055..d42396f 100644 --- a/PlotLine/Services/WritingWorkspacePresenceService.cs +++ b/PlotLine/Services/WritingWorkspacePresenceService.cs @@ -147,6 +147,7 @@ public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceS WordDocumentOpen = wordStatus.DocumentOpen, WordProjectID = wordStatus.LinkedProjectID, WordBookID = wordStatus.LinkedBookID, + CurrentStoryPosition = wordStatus.CurrentStoryPosition, Windows = windows, Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher), Context = LatestRole(windows, WritingWorkspaceRoles.Context), diff --git a/PlotLine/Sql/029_ProjectSceneMetricsManagement.sql b/PlotLine/Sql/029_ProjectSceneMetricsManagement.sql index b59fcff..eb52b36 100644 --- a/PlotLine/Sql/029_ProjectSceneMetricsManagement.sql +++ b/PlotLine/Sql/029_ProjectSceneMetricsManagement.sql @@ -25,6 +25,25 @@ AS BEGIN SET NOCOUNT ON; + MERGE dbo.SceneMetricTypes AS target + USING (VALUES + (N'Overall Intensity', N'General dramatic intensity for the scene.', 1, 10, 5, 10), + (N'Tension', N'Pressure, suspense, uncertainty, or conflict.', 1, 10, 5, 20), + (N'Emotional Weight', N'Emotional load carried by the scene.', 1, 10, 5, 30), + (N'Action', N'Physical action, movement, or event energy.', 1, 10, 3, 40), + (N'Darkness', N'Darkness, bleakness, fear, or threat.', 1, 10, 3, 50), + (N'Romance', N'Romantic focus or intimacy.', 1, 10, 1, 60), + (N'Sexual Charge', N'Sexual tension or sensual charge.', 1, 10, 1, 70), + (N'Violence', N'Violent action, injury, or threat.', 1, 10, 1, 80), + (N'Hope / Lightness', N'Hope, relief, warmth, or tonal lightness.', 1, 10, 4, 90), + (N'Comedy', N'Humour or comic relief.', 1, 10, 1, 100), + (N'Mystery', N'Questions, clue energy, and mystery pressure.', 1, 10, 5, 110) + ) AS source (MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder) + ON target.ProjectID IS NULL AND target.MetricName = source.MetricName + WHEN NOT MATCHED THEN + INSERT (ProjectID, MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder, IsActive) + VALUES (NULL, source.MetricName, source.Description, source.MinValue, source.MaxValue, source.DefaultValue, source.SortOrder, 1); + IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID) BEGIN INSERT dbo.ProjectTimelineSettings diff --git a/PlotLine/Sql/148_Phase23_NameIntelligence.sql b/PlotLine/Sql/148_Phase23_NameIntelligence.sql index d04ad09..7821fa5 100644 --- a/PlotLine/Sql/148_Phase23_NameIntelligence.sql +++ b/PlotLine/Sql/148_Phase23_NameIntelligence.sql @@ -484,8 +484,11 @@ BEGIN IF @PageSize < 1 SET @PageSize = 25; IF @PageSize > 100 SET @PageSize = 100; - DECLARE @NameLocaleID int; - SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = COALESCE(@LocaleCode, N'en-US') AND IsActive = 1; + DECLARE @NameLocaleID int = NULL; + IF @LocaleCode IS NOT NULL + BEGIN + SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = @LocaleCode AND IsActive = 1; + END; ;WITH UsageChoice AS ( diff --git a/PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql b/PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql new file mode 100644 index 0000000..29d5944 --- /dev/null +++ b/PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql @@ -0,0 +1,38 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneMetric_BackfillDefaultsForProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @Inserted table (SceneID int NOT NULL, MetricTypeID int NOT NULL); + + INSERT dbo.SceneMetricValues (SceneID, MetricTypeID, Value, Notes) + OUTPUT inserted.SceneID, inserted.MetricTypeID INTO @Inserted + SELECT s.SceneID, mt.MetricTypeID, mt.DefaultValue, NULL + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + INNER JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = b.ProjectID + INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID + WHERE b.ProjectID = @ProjectID + AND b.IsArchived = 0 + AND c.IsArchived = 0 + AND s.IsArchived = 0 + AND mt.IsActive = 1 + AND (mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID) + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.SceneMetricValues existing + WHERE existing.SceneID = s.SceneID + AND existing.MetricTypeID = mt.MetricTypeID + ); + + SELECT COUNT(*) FROM @Inserted; +END; +GO diff --git a/PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql b/PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql new file mode 100644 index 0000000..665794f --- /dev/null +++ b/PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql @@ -0,0 +1,131 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.GivenName_Search + @UserID int, + @ProjectID int, + @BookID int = NULL, + @Search nvarchar(120) = NULL, + @LocaleCode nvarchar(16) = NULL, + @BirthYear int = NULL, + @GenderPreference nvarchar(12) = NULL, + @GenderThreshold decimal(9,4) = NULL, + @NameType nvarchar(40) = NULL, + @MinimumUsage decimal(9,4) = NULL, + @UsedScope nvarchar(20) = N'Project', + @UsedOnly bit = NULL, + @Page int = 1, + @PageSize int = 25 +AS +BEGIN + SET NOCOUNT ON; + + IF NOT EXISTS (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1) + BEGIN + SELECT TOP (0) CAST(NULL AS int) AS GivenNameID; + RETURN; + END; + + IF @Page < 1 SET @Page = 1; + IF @PageSize < 1 SET @PageSize = 25; + IF @PageSize > 100 SET @PageSize = 100; + + DECLARE @NameLocaleID int = NULL; + IF @LocaleCode IS NOT NULL + BEGIN + SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = @LocaleCode AND IsActive = 1; + END; + + ;WITH UsageChoice AS + ( + SELECT + u.*, + l.LocaleCode, + l.DisplayName AS LocaleDisplayName, + ROW_NUMBER() OVER (PARTITION BY u.GivenNameID ORDER BY + CASE WHEN @BirthYear IS NOT NULL AND @BirthYear BETWEEN u.BirthYearFrom AND u.BirthYearTo THEN 0 ELSE 1 END, + CASE WHEN @BirthYear IS NULL THEN ABS(1960 - ((u.BirthYearFrom + u.BirthYearTo) / 2)) ELSE ABS(@BirthYear - ((u.BirthYearFrom + u.BirthYearTo) / 2)) END, + u.UsageWeight DESC) AS rn + FROM dbo.NameUsages u + INNER JOIN dbo.NameLocales l ON l.NameLocaleID = u.NameLocaleID + WHERE u.IsActive = 1 + AND (@NameLocaleID IS NULL OR u.NameLocaleID = @NameLocaleID) + ), + Used AS + ( + SELECT g.GivenNameID, COUNT(DISTINCT c.CharacterID) AS UsedCount, + STRING_AGG(CONVERT(nvarchar(max), c.CharacterName + N' (' + p.ProjectName + COALESCE(N' / ' + b.BookTitle, N'') + N')'), N'; ') AS UsedSummary + FROM dbo.GivenNames g + INNER JOIN + ( + SELECT c.CharacterID, c.ProjectID, c.CharacterName, c.CharacterName AS MatchedName + FROM dbo.Characters c + WHERE c.IsArchived = 0 + UNION ALL + SELECT c.CharacterID, c.ProjectID, c.CharacterName, ca.Alias AS MatchedName + FROM dbo.CharacterAliases ca + INNER JOIN dbo.Characters c ON c.CharacterID = ca.CharacterID + WHERE c.IsArchived = 0 + ) c ON UPPER(LTRIM(RTRIM(c.MatchedName))) = g.NormalisedName COLLATE Latin1_General_100_CI_AI_SC + INNER JOIN dbo.Projects p ON p.ProjectID = c.ProjectID + INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = c.ProjectID AND pua.UserID = @UserID AND pua.IsActive = 1 + LEFT JOIN dbo.Books b ON b.ProjectID = c.ProjectID AND b.IsArchived = 0 + WHERE (@UsedScope = N'Library' OR c.ProjectID = @ProjectID) + AND (@BookID IS NULL OR @UsedScope <> N'Book' OR b.BookID = @BookID) + GROUP BY g.GivenNameID + ), + Related AS + ( + SELECT x.GivenNameID, + STRING_AGG(CONVERT(nvarchar(max), x.DisplayName), N', ') AS RelatedForms + FROM + ( + SELECT r.FromGivenNameID AS GivenNameID, t.DisplayName + FROM dbo.NameRelationships r + INNER JOIN dbo.GivenNames t ON t.GivenNameID = r.ToGivenNameID + WHERE r.IsActive = 1 + UNION + SELECT r.ToGivenNameID AS GivenNameID, f.DisplayName + FROM dbo.NameRelationships r + INNER JOIN dbo.GivenNames f ON f.GivenNameID = r.FromGivenNameID + WHERE r.IsActive = 1 AND r.IsBidirectional = 1 + ) x + GROUP BY x.GivenNameID + ), + Filtered AS + ( + SELECT + g.GivenNameID, g.GivenNameKey, g.DisplayName, g.NormalisedName, g.NameType, g.IsWordAmbiguous, + u.MaleWeight, u.FemaleWeight, u.UsageWeight, u.Confidence, u.BirthYearFrom, u.BirthYearTo, u.LocaleCode, u.LocaleDisplayName, u.SourceType, + rel.RelatedForms, + COALESCE(used.UsedSummary, N'') AS UsedSummary, + COALESCE(used.UsedCount, 0) AS UsedCount, + COUNT(1) OVER() AS TotalRows + FROM dbo.GivenNames g + LEFT JOIN UsageChoice u ON u.GivenNameID = g.GivenNameID AND u.rn = 1 + LEFT JOIN Used used ON used.GivenNameID = g.GivenNameID + LEFT JOIN Related rel ON rel.GivenNameID = g.GivenNameID + WHERE g.IsActive = 1 + AND (@Search IS NULL OR g.NormalisedName LIKE UPPER(LTRIM(RTRIM(@Search))) + N'%' COLLATE Latin1_General_100_CI_AI_SC OR g.DisplayName LIKE @Search + N'%') + AND (@NameType IS NULL OR g.NameType = @NameType) + AND (@MinimumUsage IS NULL OR u.UsageWeight >= @MinimumUsage) + AND (@UsedOnly IS NULL OR (@UsedOnly = 1 AND COALESCE(used.UsedCount, 0) > 0) OR (@UsedOnly = 0 AND COALESCE(used.UsedCount, 0) = 0)) + AND ( + @GenderPreference IS NULL + OR @GenderThreshold IS NULL + OR (@GenderPreference = N'Female' AND u.FemaleWeight >= @GenderThreshold) + OR (@GenderPreference = N'Male' AND u.MaleWeight >= @GenderThreshold) + OR (@GenderPreference = N'Mixed' AND u.MaleWeight >= (1 - @GenderThreshold) AND u.FemaleWeight >= (1 - @GenderThreshold)) + ) + ) + SELECT * + FROM Filtered + ORDER BY + CASE WHEN @Search IS NOT NULL AND NormalisedName = UPPER(LTRIM(RTRIM(@Search))) COLLATE Latin1_General_100_CI_AI_SC THEN 0 ELSE 1 END, + UsageWeight DESC, + DisplayName + OFFSET (@Page - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY; +END; +GO diff --git a/PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql b/PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql new file mode 100644 index 0000000..a42a004 --- /dev/null +++ b/PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql @@ -0,0 +1,58 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.SceneMetricType_AddMissingDefaultsForProject + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + MERGE dbo.SceneMetricTypes AS target + USING (VALUES + (N'Overall Intensity', N'General dramatic intensity for the scene.', 1, 10, 5, 10), + (N'Tension', N'Pressure, suspense, uncertainty, or conflict.', 1, 10, 5, 20), + (N'Emotional Weight', N'Emotional load carried by the scene.', 1, 10, 5, 30), + (N'Action', N'Physical action, movement, or event energy.', 1, 10, 3, 40), + (N'Darkness', N'Darkness, bleakness, fear, or threat.', 1, 10, 3, 50), + (N'Romance', N'Romantic focus or intimacy.', 1, 10, 1, 60), + (N'Sexual Charge', N'Sexual tension or sensual charge.', 1, 10, 1, 70), + (N'Violence', N'Violent action, injury, or threat.', 1, 10, 1, 80), + (N'Hope / Lightness', N'Hope, relief, warmth, or tonal lightness.', 1, 10, 4, 90), + (N'Comedy', N'Humour or comic relief.', 1, 10, 1, 100), + (N'Mystery', N'Questions, clue energy, and mystery pressure.', 1, 10, 5, 110) + ) AS source (MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder) + ON target.ProjectID IS NULL AND target.MetricName = source.MetricName + WHEN NOT MATCHED THEN + INSERT (ProjectID, MetricName, Description, MinValue, MaxValue, DefaultValue, SortOrder, IsActive) + VALUES (NULL, source.MetricName, source.Description, source.MinValue, source.MaxValue, source.DefaultValue, source.SortOrder, 1); + + IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID) + BEGIN + INSERT dbo.ProjectTimelineSettings + (ProjectID, ShowSceneCards, ShowMetricShape, ShowPlotLines, ShowStoryAssets, ShowCharacterAppearances, ShowWarnings) + VALUES + (@ProjectID, 1, 1, 1, 1, 1, 1); + END; + + INSERT dbo.ProjectTimelineMetricSettings (ProjectID, MetricTypeID, SortOrder) + SELECT @ProjectID, mt.MetricTypeID, mt.SortOrder + FROM dbo.SceneMetricTypes mt + WHERE mt.ProjectID IS NULL + AND mt.IsActive = 1 + AND mt.MetricName IN + ( + N'Overall Intensity', N'Tension', N'Emotional Weight', N'Action', N'Darkness', + N'Romance', N'Sexual Charge', N'Violence', N'Hope / Lightness', N'Comedy', N'Mystery' + ) + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.ProjectTimelineMetricSettings ptms + INNER JOIN dbo.SceneMetricTypes existing ON existing.MetricTypeID = ptms.MetricTypeID + WHERE ptms.ProjectID = @ProjectID + AND LOWER(existing.MetricName) = LOWER(mt.MetricName) + ); +END; +GO diff --git a/PlotLine/Views/Locations/Details.cshtml b/PlotLine/Views/Locations/Details.cshtml index c5bffff..05477ed 100644 --- a/PlotLine/Views/Locations/Details.cshtml +++ b/PlotLine/Views/Locations/Details.cshtml @@ -137,7 +137,7 @@

Scenes

@foreach (var scene in Model.Scenes) { -

Scene @scene.SceneNumber @scene.SceneTitle

+

@SceneLabel(scene.ChapterNumber, scene.SceneNumber) @scene.SceneTitle

} @if (!Model.Scenes.Any()) {

No scenes set here.

} @@ -145,7 +145,7 @@

Characters

@foreach (var character in Model.Characters) { -

@character.CharacterName Scene @character.SceneNumber

+

@character.CharacterName @SceneLabel(character.ChapterNumber, character.SceneNumber)

} @if (!Model.Characters.Any()) {

No character placements yet.

} @@ -154,7 +154,12 @@ @foreach (var asset in Model.Assets) {

@asset.AssetName @asset.Description

- } +} + +@functions { + private static string SceneLabel(decimal chapterNumber, decimal sceneNumber) + => chapterNumber > 0 ? $"Chapter {chapterNumber:g}, Scene {sceneNumber:g}" : $"Scene {sceneNumber:g}"; +} @if (!Model.Assets.Any()) {

No asset placements yet.

} diff --git a/PlotLine/Views/Names/Index.cshtml b/PlotLine/Views/Names/Index.cshtml index 5c99eeb..c2d4a4b 100644 --- a/PlotLine/Views/Names/Index.cshtml +++ b/PlotLine/Views/Names/Index.cshtml @@ -73,6 +73,7 @@