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 reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish),
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures), ("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired), ("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 link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches),
("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences), ("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities), ("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), ("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), ("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions), ("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation), ("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."); 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() static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
{ {
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting, 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."); 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) static int CountCsvRows(string path)
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line)); => File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));

View File

@ -50,8 +50,10 @@ public sealed class ProjectMetricsController(ISceneMetricTypeService metrics) :
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> AddMissingDefaults(int projectId) public async Task<IActionResult> AddMissingDefaults(int projectId)
{ {
await metrics.AddMissingDefaultsAsync(projectId); var backfilled = await metrics.AddMissingDefaultsAsync(projectId);
TempData["ProjectMetricMessage"] = "Missing default scene metrics added."; 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 }); return RedirectToAction(nameof(Index), new { projectId });
} }

View File

@ -13,6 +13,7 @@ namespace PlotLine.Controllers;
[Route("api/word-companion")] [Route("api/word-companion")]
public sealed class WordCompanionController( public sealed class WordCompanionController(
IWordCompanionService wordCompanion, IWordCompanionService wordCompanion,
IWordCompanionPresenceService presence,
IHubContext<WordCompanionFollowHub> followHub) : ControllerBase IHubContext<WordCompanionFollowHub> followHub) : ControllerBase
{ {
[HttpGet("projects")] [HttpGet("projects")]
@ -130,6 +131,8 @@ public sealed class WordCompanionController(
return BadRequest(); return BadRequest();
} }
await presence.UpdateCurrentStoryPositionAsync(RequireUserId(), response.Position);
await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new await BroadcastToCurrentUserAsync("wordCompanionSceneChanged", new
{ {
response.ProjectId, response.ProjectId,
@ -267,4 +270,12 @@ public sealed class WordCompanionController(
await followHub.Clients.User(userId).SendAsync(eventName, payload); 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> SetActiveAsync(int metricTypeId, int projectId, bool isActive);
Task<bool> MoveAsync(int metricTypeId, int projectId, string direction); Task<bool> MoveAsync(int metricTypeId, int projectId, string direction);
Task AddMissingDefaultsAsync(int projectId); Task AddMissingDefaultsAsync(int projectId);
Task<int> BackfillDefaultValuesForProjectAsync(int projectId);
Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId); Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId);
Task<bool> HasProjectMetricsAsync(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); 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) public async Task<bool> RemoveUnusedAsync(int metricTypeId, int projectId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -97,7 +97,7 @@ public sealed class NameSearchFilter
public int ProjectID { get; set; } public int ProjectID { get; set; }
public int? BookID { get; set; } public int? BookID { get; set; }
public string? Search { 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 int? BirthYear { get; set; }
public string? GenderPreference { get; set; } public string? GenderPreference { get; set; }
public decimal? GenderThreshold { get; set; } public decimal? GenderThreshold { get; set; }

View File

@ -39,6 +39,7 @@ public sealed class WordCompanionPresenceStatus
public string? CurrentDocumentName { get; init; } public string? CurrentDocumentName { get; init; }
public int? LinkedProjectID { get; init; } public int? LinkedProjectID { get; init; }
public int? LinkedBookID { get; init; } public int? LinkedBookID { get; init; }
public StoryPosition? CurrentStoryPosition { get; init; }
public DateTime? ConnectedUtc { get; init; } public DateTime? ConnectedUtc { get; init; }
public DateTime? LastHeartbeatUtc { get; init; } public DateTime? LastHeartbeatUtc { get; init; }
} }

View File

@ -309,7 +309,7 @@ public interface ISceneMetricTypeService
Task<int> SaveAsync(SceneMetricTypeEditViewModel model); Task<int> SaveAsync(SceneMetricTypeEditViewModel model);
Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive); Task<bool> SetActiveAsync(int projectId, int metricTypeId, bool isActive);
Task<bool> MoveAsync(int projectId, int metricTypeId, string direction); 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); 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<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); 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); var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {assetName}" ? $"{BuildSceneReference(scene)}: {assetName}"
: $"Scene {scene.SceneNumber:g}: {summary}"; : $"{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) 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)); 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); var note = Clean(character.Notes);
if (!string.IsNullOrWhiteSpace(note)) if (!string.IsNullOrWhiteSpace(note))
{ {
return $"Scene {scene.SceneNumber:g}: {note}"; return $"{BuildSceneReference(scene)}: {note}";
} }
var summary = Clean(parsed.Summary?.Short); 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) private static decimal? Max(decimal? current, decimal? next)
=> current.HasValue && next.HasValue ? Math.Max(current.Value, next.Value) : current ?? 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(); var lookupData = await lookups.GetAllAsync();
if (run.ProjectID.HasValue)
{
await metricTypes.AddMissingDefaultsAsync(run.ProjectID.Value);
}
var activeMetricTypes = run.ProjectID.HasValue 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 noteTypes = await writerWorkspace.ListNoteTypesAsync();
var characterMap = run.ProjectID.HasValue var characterMap = run.ProjectID.HasValue

View File

@ -608,7 +608,7 @@ public sealed class StoryIntelligenceKnowledgeImportService(
} }
private static string BuildSceneReference(Scene scene) private static string BuildSceneReference(Scene scene)
=> $"Scene {scene.SceneNumber:g}"; => $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static bool IsCharacterEntity(string? entityType) private static bool IsCharacterEntity(string? entityType)
=> string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase); => string.Equals(Clean(entityType), "Character", StringComparison.OrdinalIgnoreCase);

View File

@ -730,10 +730,13 @@ public sealed class StoryIntelligenceLocationImportService(
{ {
var summary = Clean(parsed.Summary?.Short); var summary = Clean(parsed.Summary?.Short);
return string.IsNullOrWhiteSpace(summary) return string.IsNullOrWhiteSpace(summary)
? $"Scene {scene.SceneNumber:g}: {locationName}" ? $"{BuildSceneReference(scene)}: {locationName}"
: $"Scene {scene.SceneNumber:g}: {summary}"; : $"{BuildSceneReference(scene)}: {summary}";
} }
private static string BuildSceneReference(Scene scene)
=> $"Chapter {scene.ChapterNumber:g}, Scene {scene.SceneNumber:g}";
private static string FirstConfigured(params string?[] values) private static string FirstConfigured(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty; => 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) 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) 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> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId); Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId); Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
Task<WordCompanionPresenceStatus> UpdateCurrentStoryPositionAsync(int userId, StoryPosition? position);
Task<bool> IsConnectedAsync(int userId); Task<bool> IsConnectedAsync(int userId);
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync(); Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
Task<string?> GetCompanionConnectionIdAsync(int userId); Task<string?> GetCompanionConnectionIdAsync(int userId);
@ -68,6 +69,11 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID); record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
record.LinkedBookID = Positive(heartbeat.LinkedBookID); record.LinkedBookID = Positive(heartbeat.LinkedBookID);
record.LastHeartbeatUtc = now; 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; usersByConnection[connectionId] = userId;
return Task.FromResult(ToStatus(record, now)); return Task.FromResult(ToStatus(record, now));
@ -97,6 +103,28 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
return Task.FromResult(ToStatus(record, DateTime.UtcNow)); 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) public async Task<bool> IsConnectedAsync(int userId)
=> (await GetStatusAsync(userId)).IsConnected; => (await GetStatusAsync(userId)).IsConnected;
@ -156,6 +184,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
CurrentDocumentName = record.CurrentDocumentName, CurrentDocumentName = record.CurrentDocumentName,
LinkedProjectID = record.LinkedProjectID, LinkedProjectID = record.LinkedProjectID,
LinkedBookID = record.LinkedBookID, LinkedBookID = record.LinkedBookID,
CurrentStoryPosition = connected ? record.CurrentStoryPosition : null,
ConnectedUtc = record.ConnectedUtc, ConnectedUtc = record.ConnectedUtc,
LastHeartbeatUtc = record.LastHeartbeatUtc LastHeartbeatUtc = record.LastHeartbeatUtc
}; };
@ -181,6 +210,7 @@ public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
public string? CurrentDocumentName { get; set; } public string? CurrentDocumentName { get; set; }
public int? LinkedProjectID { get; set; } public int? LinkedProjectID { get; set; }
public int? LinkedBookID { get; set; } public int? LinkedBookID { get; set; }
public StoryPosition? CurrentStoryPosition { get; set; }
public DateTime? ConnectedUtc { get; set; } public DateTime? ConnectedUtc { get; set; }
public DateTime? LastHeartbeatUtc { get; set; } public DateTime? LastHeartbeatUtc { get; set; }
} }

View File

@ -147,6 +147,7 @@ public sealed class WritingWorkspacePresenceService : IWritingWorkspacePresenceS
WordDocumentOpen = wordStatus.DocumentOpen, WordDocumentOpen = wordStatus.DocumentOpen,
WordProjectID = wordStatus.LinkedProjectID, WordProjectID = wordStatus.LinkedProjectID,
WordBookID = wordStatus.LinkedBookID, WordBookID = wordStatus.LinkedBookID,
CurrentStoryPosition = wordStatus.CurrentStoryPosition,
Windows = windows, Windows = windows,
Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher), Launcher = LatestRole(windows, WritingWorkspaceRoles.Launcher),
Context = LatestRole(windows, WritingWorkspaceRoles.Context), Context = LatestRole(windows, WritingWorkspaceRoles.Context),

View File

@ -25,6 +25,25 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; 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) IF NOT EXISTS (SELECT 1 FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID)
BEGIN BEGIN
INSERT dbo.ProjectTimelineSettings INSERT dbo.ProjectTimelineSettings

View File

@ -484,8 +484,11 @@ BEGIN
IF @PageSize < 1 SET @PageSize = 25; IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 100 SET @PageSize = 100; IF @PageSize > 100 SET @PageSize = 100;
DECLARE @NameLocaleID int; DECLARE @NameLocaleID int = NULL;
SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = COALESCE(@LocaleCode, N'en-US') AND IsActive = 1; IF @LocaleCode IS NOT NULL
BEGIN
SELECT TOP (1) @NameLocaleID = NameLocaleID FROM dbo.NameLocales WHERE LocaleCode = @LocaleCode AND IsActive = 1;
END;
;WITH UsageChoice AS ;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> <h3>Scenes</h3>
@foreach (var scene in Model.Scenes) @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> } @if (!Model.Scenes.Any()) { <p class="muted">No scenes set here.</p> }
</div> </div>
@ -145,7 +145,7 @@
<h3>Characters</h3> <h3>Characters</h3>
@foreach (var character in Model.Characters) @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> } @if (!Model.Characters.Any()) { <p class="muted">No character placements yet.</p> }
</div> </div>
@ -155,6 +155,11 @@
{ {
<p><strong>@asset.AssetName</strong> <span class="muted">@asset.Description</span></p> <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> } @if (!Model.Assets.Any()) { <p class="muted">No asset placements yet.</p> }
</div> </div>
</div> </div>

View File

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

View File

@ -67,6 +67,15 @@
}).catch(() => null); }).catch(() => null);
}; };
const storyPositionFromWorkspaceStatus = (status) => {
const position = status?.currentStoryPosition || status?.CurrentStoryPosition;
if (!position) {
return null;
}
return window.PlotDirectorStoryPosition?.normalise(position, "WordCompanion") || position;
};
const readStoredTab = () => { const readStoredTab = () => {
try { try {
return localStorage.getItem(config.storageKey) || config.defaultTab; return localStorage.getItem(config.storageKey) || config.defaultTab;
@ -230,8 +239,17 @@
}); });
connection.start() connection.start()
.then(() => { .then(async () => {
if (!currentSceneId) { 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); setStatus(config.waitingStatus);
} }
}) })