Improve post-import review defaults
This commit is contained in:
parent
1dfc620e00
commit
a90b98f8f0
@ -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));
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
(
|
||||
|
||||
38
PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql
Normal file
38
PlotLine/Sql/151_BackfillDefaultSceneMetricValues.sql
Normal 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
|
||||
131
PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql
Normal file
131
PlotLine/Sql/152_NameLibraryAllLocalesDefault.sql
Normal 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
|
||||
58
PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql
Normal file
58
PlotLine/Sql/153_SelfHealingProjectMetricDefaults.sql
Normal 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
|
||||
@ -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>
|
||||
@ -155,6 +155,11 @@
|
||||
{
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user