Improve post-import review defaults

This commit is contained in:
Nick Beckley 2026-08-24 20:29:28 +00:00
parent 1dfc620e00
commit a90b98f8f0
23 changed files with 448 additions and 21 deletions

View File

@ -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</option>", 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<int> 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));

View File

@ -50,8 +50,10 @@ public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) :
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}

View File

@ -13,6 +13,7 @@ namespace PlotLine.Controllers;
[Route("api/word-companion")]
public sealed class WordCompanionController(
IWordCompanionService wordCompanion,
IWordCompanionPresenceService presence,
IHubContext<WordCompanionFollowHub> 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.");
}
}

View File

@ -141,6 +141,7 @@ public interface ISceneMetricTypeRepository
Task<bool> SetActiveAsync(int metricTypeId, int projectId, bool isActive);
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<int> BackfillDefaultValuesForProjectAsync(int projectId);
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
Task<bool> 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<int> BackfillDefaultValuesForProjectAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.SceneMetric_BackfillDefaultsForProject",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId)
{
using var connection = connectionFactory.CreateConnection();

View File

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

View File

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

View File

@ -309,7 +309,7 @@ public interface ISceneMetricTypeService
Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction);
Task AddMissingDefaultsAsync(int projectId);
Task<int> AddMissingDefaultsAsync(int projectId);
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
@ -5247,7 +5247,11 @@ public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMe
public Task<bool> MoveAsync(int projectId, int metricTypeId, string direction) => metricTypes.MoveAsync(metricTypeId, projectId, direction);
public Task AddMissingDefaultsAsync(int projectId) => metricTypes.AddMissingDefaultsAsync(projectId);
public async Task<int> AddMissingDefaultsAsync(int projectId)
{
await metricTypes.AddMissingDefaultsAsync(projectId);
return await metricTypes.BackfillDefaultValuesForProjectAsync(projectId);
}
public Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ public interface IWordCompanionPresenceService
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
Task<bool> IsConnectedAsync(int userId);
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
Task<string?> 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<WordCompanionPresenceStatus> 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<bool> 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; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -137,7 +137,7 @@
<h3>Scenes</h3>
@foreach (var scene in Model.Scenes)
{
<p><strong>Scene @scene.SceneNumber</strong> <a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@scene.SceneID" asp-route-FocusType="location" asp-route-FocusID="@Model.Location.LocationID">@scene.SceneTitle</a></p>
<p><strong>@SceneLabel(scene.ChapterNumber, scene.SceneNumber)</strong> <a asp-controller="Timeline" asp-action="Index" asp-route-ProjectID="@Model.Project.ProjectID" asp-route-SelectedSceneID="@scene.SceneID" asp-route-FocusType="location" asp-route-FocusID="@Model.Location.LocationID">@scene.SceneTitle</a></p>
}
@if (!Model.Scenes.Any()) { <p class="muted">No scenes set here.</p> }
</div>
@ -145,7 +145,7 @@
<h3>Characters</h3>
@foreach (var character in Model.Characters)
{
<p><strong>@character.CharacterName</strong> <span class="muted">Scene @character.SceneNumber</span></p>
<p><strong>@character.CharacterName</strong> <span class="muted">@SceneLabel(character.ChapterNumber, character.SceneNumber)</span></p>
}
@if (!Model.Characters.Any()) { <p class="muted">No character placements yet.</p> }
</div>
@ -154,7 +154,12 @@
@foreach (var asset in Model.Assets)
{
<p><strong>@asset.AssetName</strong> <span class="muted">@asset.Description</span></p>
}
}
@functions {
private static string SceneLabel(decimal chapterNumber, decimal sceneNumber)
=> chapterNumber > 0 ? $"Chapter {chapterNumber:g}, Scene {sceneNumber:g}" : $"Scene {sceneNumber:g}";
}
@if (!Model.Assets.Any()) { <p class="muted">No asset placements yet.</p> }
</div>
</div>

View File

@ -73,6 +73,7 @@
<div class="col-md-3">
<label class="form-label" for="LocaleCode">Locale</label>
<select class="form-select" id="LocaleCode" name="LocaleCode">
<option value="" selected="@string.IsNullOrWhiteSpace(filter.LocaleCode)">All locales</option>
@foreach (var locale in Model.Locales)
{
<option value="@locale.LocaleCode" selected="@(string.Equals(filter.LocaleCode, locale.LocaleCode, StringComparison.OrdinalIgnoreCase))">@locale.DisplayName</option>

View File

@ -67,6 +67,15 @@
}).catch(() => null);
};
const storyPositionFromWorkspaceStatus = (status) => {
const position = status?.currentStoryPosition || status?.CurrentStoryPosition;
if (!position) {
return null;
}
return window.PlotDirectorStoryPosition?.normalise(position, "WordCompanion") || position;
};
const readStoredTab = () => {
try {
return localStorage.getItem(config.storageKey) || config.defaultTab;
@ -230,8 +239,17 @@
});
connection.start()
.then(() => {
if (!currentSceneId) {
.then(async () => {
const status = await connection.invoke("WatchWritingWorkspace", {
projectID: scopedProjectId,
bookID: scopedBookId
}).catch(() => null);
const position = storyPositionFromWorkspaceStatus(status);
if (position?.sceneId || (config.allowChapterOnly && position?.chapterId)) {
await loadPosition(position);
}
if (!currentSceneId && !(config.allowChapterOnly && currentChapterId)) {
setStatus(config.waitingStatus);
}
})