Establish canonical story position context
This commit is contained in:
parent
f6b4263f31
commit
9f0fdbc366
@ -70,7 +70,10 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
("Story Intelligence experience boot does not serialise live model", StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel),
|
||||||
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
("Live visualisation strips illustration diagnostics", LiveVisualisationStripsIllustrationDiagnostics),
|
||||||
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence)
|
("Illustration evidence trace does not recursively store previous evidence", IllustrationEvidenceTraceDoesNotRecursivelyStorePreviousEvidence),
|
||||||
|
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
|
||||||
|
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
|
||||||
|
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var test in tests)
|
foreach (var test in tests)
|
||||||
@ -86,6 +89,53 @@ static void RepairsTrailingDecimalConfidence()
|
|||||||
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
|
Assert(result.RepairedJson.Contains("\"confidence\":0.0", StringComparison.Ordinal), result.RepairedJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryPositionRejectsInvalidRuntimeScenePositions()
|
||||||
|
{
|
||||||
|
Assert(StoryPosition.FromRuntimeCurrentScene(new WordCompanionRuntimeCurrentSceneRequest
|
||||||
|
{
|
||||||
|
DocumentGuid = Guid.NewGuid(),
|
||||||
|
ProjectId = 1,
|
||||||
|
BookId = 2,
|
||||||
|
ChapterId = 3,
|
||||||
|
SceneId = 0
|
||||||
|
}) is null, "Runtime scene positions require a valid SceneId.");
|
||||||
|
|
||||||
|
Assert(StoryPosition.FromTimelineSelection(0, 2, 3, 4) is null, "Story positions require a valid ProjectId.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryPositionBuildsCanonicalRuntimeSceneNavigation()
|
||||||
|
{
|
||||||
|
var response = new WordCompanionRuntimeCurrentSceneResponse
|
||||||
|
{
|
||||||
|
ProjectId = 10,
|
||||||
|
BookId = 20,
|
||||||
|
ChapterId = 30,
|
||||||
|
SceneId = 40,
|
||||||
|
SceneUrl = "/Scenes/Edit/40"
|
||||||
|
};
|
||||||
|
|
||||||
|
var position = response.Position;
|
||||||
|
Assert(position is not null, "Runtime response should expose a canonical StoryPosition.");
|
||||||
|
Assert(position!.ProjectId == 10 && position.BookId == 20 && position.ChapterId == 30 && position.SceneId == 40, "Runtime response position IDs were not preserved.");
|
||||||
|
Assert(position.SceneEditUrl == "/Scenes/Edit/40", "Scene positions must use the canonical Scene Edit route.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryPositionDerivesCanonicalSceneContextIdentity()
|
||||||
|
{
|
||||||
|
var model = new SceneEditViewModel
|
||||||
|
{
|
||||||
|
SceneID = 7,
|
||||||
|
ChapterID = 6,
|
||||||
|
Project = new Project { ProjectID = 4 },
|
||||||
|
Book = new Book { BookID = 5 },
|
||||||
|
Chapter = new Chapter { ChapterID = 6 }
|
||||||
|
};
|
||||||
|
|
||||||
|
var position = StoryPosition.FromScene(model);
|
||||||
|
Assert(position is not null, "Scene workspace should expose a canonical StoryPosition.");
|
||||||
|
Assert(position!.ProjectId == 4 && position.BookId == 5 && position.ChapterId == 6 && position.SceneId == 7, "Scene workspace position IDs were not preserved.");
|
||||||
|
}
|
||||||
|
|
||||||
static void StripsMarkdownFence()
|
static void StripsMarkdownFence()
|
||||||
{
|
{
|
||||||
var result = StoryIntelligenceJsonRepair.Repair("""
|
var result = StoryIntelligenceJsonRepair.Repair("""
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using PlotLine.Models;
|
||||||
using PlotLine.Services;
|
using PlotLine.Services;
|
||||||
|
|
||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
@ -9,7 +10,10 @@ public sealed class RelationshipMapController(IRelationshipMapService relationsh
|
|||||||
{
|
{
|
||||||
public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false)
|
public async Task<IActionResult> Index(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork = false)
|
||||||
{
|
{
|
||||||
var model = await relationshipMap.GetAsync(projectId, bookId, chapterId, sceneId, focusCharacterId, showFullNetwork);
|
var position = StoryPosition.FromTimelineSelection(projectId, bookId, chapterId, sceneId);
|
||||||
|
var model = position is null
|
||||||
|
? null
|
||||||
|
: await relationshipMap.GetAsync(position, focusCharacterId, showFullNetwork);
|
||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
PlotLine/Models/StoryPosition.cs
Normal file
59
PlotLine/Models/StoryPosition.cs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Models;
|
||||||
|
|
||||||
|
public sealed record StoryPosition(int ProjectId, int? BookId = null, int? ChapterId = null, int? SceneId = null)
|
||||||
|
{
|
||||||
|
public bool IsValid => ProjectId > 0 && (BookId is null or > 0) && (ChapterId is null or > 0) && (SceneId is null or > 0);
|
||||||
|
public bool IsScenePosition => SceneId.HasValue;
|
||||||
|
|
||||||
|
public static StoryPosition? FromScene(SceneEditViewModel? scene)
|
||||||
|
{
|
||||||
|
if (scene?.Project is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryPosition(scene.Project.ProjectID, scene.Book?.BookID, scene.ChapterID, scene.SceneID > 0 ? scene.SceneID : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StoryPosition? FromScene(Scene? scene, int projectId)
|
||||||
|
{
|
||||||
|
if (scene is null || projectId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryPosition(projectId, scene.BookID > 0 ? scene.BookID : null, scene.ChapterID, scene.SceneID);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneRequest request)
|
||||||
|
{
|
||||||
|
var position = new StoryPosition(request.ProjectId, request.BookId, request.ChapterId, request.SceneId);
|
||||||
|
return position.IsValid ? position : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StoryPosition? FromRuntimeCurrentScene(WordCompanionRuntimeCurrentSceneResponse? response)
|
||||||
|
{
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var position = new StoryPosition(response.ProjectId, response.BookId, response.ChapterId, response.SceneId);
|
||||||
|
return position.IsValid ? position : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StoryPosition? FromTimelineSelection(int projectId, int? bookId, int? chapterId, int? sceneId)
|
||||||
|
{
|
||||||
|
if (projectId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var position = new StoryPosition(projectId, bookId, chapterId, sceneId);
|
||||||
|
return position.IsValid ? position : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? SceneEditUrl => SceneId.HasValue ? $"/Scenes/Edit/{SceneId.Value}" : null;
|
||||||
|
}
|
||||||
@ -167,6 +167,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||||
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
|
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
|
||||||
|
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
|
||||||
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
||||||
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
||||||
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
|
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
|
||||||
|
|||||||
@ -146,6 +146,23 @@ public interface IStoryStateService
|
|||||||
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
|
Task<List<ContinuityWarningAcknowledgement>> GetAcknowledgementsAsync(int projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface IStoryContextService
|
||||||
|
{
|
||||||
|
Task<StoryContextViewModel?> GetAsync(StoryPosition position);
|
||||||
|
Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StoryContextViewModel
|
||||||
|
{
|
||||||
|
public StoryPosition Position { get; init; } = new(0);
|
||||||
|
public Project? Project { get; init; }
|
||||||
|
public Book? Book { get; init; }
|
||||||
|
public Chapter? Chapter { get; init; }
|
||||||
|
public SceneEditViewModel? Scene { get; init; }
|
||||||
|
public StoryStateViewModel StoryState { get; init; } = new();
|
||||||
|
public string? CanonicalUrl => Position.SceneEditUrl;
|
||||||
|
}
|
||||||
|
|
||||||
public interface IPlotService
|
public interface IPlotService
|
||||||
{
|
{
|
||||||
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
|
Task<PlotLineListViewModel?> GetPlotLinesAsync(int projectId);
|
||||||
@ -313,6 +330,7 @@ public interface IStoryBibleService
|
|||||||
|
|
||||||
public interface IRelationshipMapService
|
public interface IRelationshipMapService
|
||||||
{
|
{
|
||||||
|
Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork);
|
||||||
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
|
Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2017,12 +2035,69 @@ public sealed class SceneService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryContextService(ISceneService scenes) : IStoryContextService
|
||||||
|
{
|
||||||
|
public async Task<StoryContextViewModel?> GetAsync(StoryPosition position)
|
||||||
|
{
|
||||||
|
if (!position.IsValid)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.SceneId.HasValue)
|
||||||
|
{
|
||||||
|
return await GetForSceneAsync(position.SceneId.Value, position.ProjectId, position.BookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryContextViewModel
|
||||||
|
{
|
||||||
|
Position = position
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StoryContextViewModel?> GetForSceneAsync(int sceneId, int? projectId = null, int? bookId = null)
|
||||||
|
{
|
||||||
|
if (sceneId <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scene = await scenes.GetEditAsync(sceneId);
|
||||||
|
var position = StoryPosition.FromScene(scene);
|
||||||
|
if (position is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectId.HasValue && projectId.Value != position.ProjectId)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookId.HasValue && position.BookId.HasValue && bookId.Value != position.BookId.Value)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoryContextViewModel
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Project = scene!.Project,
|
||||||
|
Book = scene.Book,
|
||||||
|
Chapter = scene.Chapter,
|
||||||
|
Scene = scene,
|
||||||
|
StoryState = scene.StoryState
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class TimelineService(
|
public sealed class TimelineService(
|
||||||
ITimelineRepository timelineRepository,
|
ITimelineRepository timelineRepository,
|
||||||
ITimelinePresetRepository presets,
|
ITimelinePresetRepository presets,
|
||||||
ITimelineSettingsRepository timelineSettings,
|
ITimelineSettingsRepository timelineSettings,
|
||||||
IProjectRepository projects,
|
IProjectRepository projects,
|
||||||
IBookRepository books,
|
IBookRepository books,
|
||||||
|
IStoryContextService storyContext,
|
||||||
ISceneService scenes,
|
ISceneService scenes,
|
||||||
IAssetRepository assets,
|
IAssetRepository assets,
|
||||||
ICharacterRepository characters,
|
ICharacterRepository characters,
|
||||||
@ -2141,9 +2216,10 @@ public sealed class TimelineService(
|
|||||||
var characterLanes = settings.ShowCharacterAppearances
|
var characterLanes = settings.ShowCharacterAppearances
|
||||||
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
|
? await TimeTimelineLoadAsync("TimelineService.BuildCharacterLanes", () => Task.FromResult(BuildCharacterLanes(characterTimeline.Characters, characterTimeline.Appearances, orderedScenes)), projectId, bookId)
|
||||||
: [];
|
: [];
|
||||||
var selectedScene = selectedSceneId.HasValue
|
var selectedContext = selectedSceneId.HasValue
|
||||||
? await TimeTimelineLoadAsync("SceneService.GetEditAsync", () => scenes.GetEditAsync(selectedSceneId.Value), projectId, bookId)
|
? await TimeTimelineLoadAsync("StoryContextService.GetForSceneAsync", () => storyContext.GetForSceneAsync(selectedSceneId.Value, projectId, bookId), projectId, bookId)
|
||||||
: null;
|
: null;
|
||||||
|
var selectedScene = selectedContext?.Scene;
|
||||||
if (selectedScene is not null)
|
if (selectedScene is not null)
|
||||||
{
|
{
|
||||||
selectedScene.ReturnProjectID = projectId;
|
selectedScene.ReturnProjectID = projectId;
|
||||||
@ -5340,6 +5416,13 @@ public sealed class StoryBibleService(IProjectRepository projects, IStoryBibleRe
|
|||||||
|
|
||||||
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
|
public sealed class RelationshipMapService(IProjectRepository projects, ICharacterRepository characters) : IRelationshipMapService
|
||||||
{
|
{
|
||||||
|
public Task<RelationshipMapViewModel?> GetAsync(StoryPosition position, int? focusCharacterId, bool showFullNetwork)
|
||||||
|
{
|
||||||
|
return !position.IsValid
|
||||||
|
? Task.FromResult<RelationshipMapViewModel?>(null)
|
||||||
|
: GetAsync(position.ProjectId, position.BookId, position.ChapterId, position.SceneId, focusCharacterId, showFullNetwork);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
|
public async Task<RelationshipMapViewModel?> GetAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? focusCharacterId, bool showFullNetwork)
|
||||||
{
|
{
|
||||||
if (projectId <= 0)
|
if (projectId <= 0)
|
||||||
|
|||||||
@ -416,6 +416,7 @@ public sealed class SceneEditViewModel
|
|||||||
public int? ReturnProjectID { get; set; }
|
public int? ReturnProjectID { get; set; }
|
||||||
public int? ReturnBookID { get; set; }
|
public int? ReturnBookID { get; set; }
|
||||||
public bool ReturnToTimeline { get; set; }
|
public bool ReturnToTimeline { get; set; }
|
||||||
|
public StoryPosition? Position => StoryPosition.FromScene(this);
|
||||||
|
|
||||||
public Chapter? Chapter { get; set; }
|
public Chapter? Chapter { get; set; }
|
||||||
public Book? Book { get; set; }
|
public Book? Book { get; set; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user